Compare commits
150 Commits
40411104d6
...
freno-dev/
| Author | SHA1 | Date | |
|---|---|---|---|
| 71bb9ec5df | |||
| 97755042cb | |||
| a0e6ff1044 | |||
| 6fd0f84806 | |||
| c7071b0e32 | |||
| 7fde259e0b | |||
| 25ec84ceb4 | |||
| dd368d21e7 | |||
| 2fd2a4a71a | |||
|
|
1d9287a199 | ||
|
|
39d874a7e2 | ||
|
|
89f56fc78a | ||
|
|
d44133fcb3 | ||
|
|
578ee58f9c | ||
|
|
d2c2e6a4ee | ||
|
|
c718370674 | ||
|
|
210a7e6e75 | ||
|
|
c1c498e37f | ||
| 134f41eac4 | |||
| 881190d1d4 | |||
| f492132bf2 | |||
| a524f23be2 | |||
| 0bf15e528d | |||
| a48326fd1d | |||
| ac63057ef7 | |||
| f6d6d442de | |||
| 14a61fde9b | |||
| b4e68765fe | |||
| dabfeada3b | |||
| 9508aee8e1 | |||
| 1a182e53fe | |||
| 6898186df5 | |||
| 9ff095a82c | |||
| be90b3d068 | |||
| cbe56390f8 | |||
| 26c37c1862 | |||
| 7108f77bf9 | |||
| 687df016a6 | |||
| 92af52dfc9 | |||
| 53f1413966 | |||
| 6f41436591 | |||
| 0b128f6639 | |||
| 37a39b59fe | |||
| ba6b478320 | |||
| 35986239c1 | |||
| b41cf83879 | |||
| f66fd43f62 | |||
| 421fb946fc | |||
| dc959d971b | |||
| 44bf21a868 | |||
| dd63da30c4 | |||
| 2fb5097b85 | |||
| 96e401ecbb | |||
| 3fe3516b9e | |||
| bef8f8414b | |||
| f3e9cdf0bd | |||
| 7ecc0f68d7 | |||
| 00a26a9e49 | |||
| 163c68fcb8 | |||
| 3149eba196 | |||
| bc65789431 | |||
| 21f1f07a99 | |||
| 23f86681af | |||
| 8492821f75 | |||
| 17bb43851e | |||
| fd68efdb78 | |||
| 1ee61d8fe4 | |||
| 6c024342ba | |||
| 9860213760 | |||
| cb584f8b68 | |||
| 8c5fe25c2b | |||
| 22f40b2284 | |||
| b10a81068d | |||
| 6d3315c71f | |||
| 20944bf46e | |||
| a161c96b05 | |||
| 082e503d4f | |||
| 0031055315 | |||
| 9ae1b8a2e5 | |||
| e4a9be4899 | |||
| 898c891bd5 | |||
| 33ca9213f2 | |||
| 34db684fe1 | |||
| 2f1b5f35c6 | |||
| 44b6e7595c | |||
| 5ddbfbc429 | |||
| ad5221dc35 | |||
| 5eecb8e7d1 | |||
| c66cd6d456 | |||
| 4b663c014a | |||
| 9e285570e1 | |||
| a876cee6ec | |||
| 84e4c2203e | |||
| f2fc6a5d1a | |||
| 8e33af43a5 | |||
| a6ef4d2955 | |||
| e7b32d6566 | |||
| e55a28d249 | |||
| 7fb38f37a0 | |||
| 06c39b8813 | |||
| 59d129b46b | |||
| d5794f6e34 | |||
| a7ad581ed2 | |||
| 6a8c8b7662 | |||
| 5705c5add7 | |||
| f2ff75e6df | |||
| bc907fb990 | |||
| 1cc16313d5 | |||
| 0356bdcaad | |||
| dbf47f3170 | |||
| 6fdbbbbe2b | |||
| d42cfc106b | |||
| dcfa32c5af | |||
| 37ed5170ed | |||
| 650b216dbc | |||
| d82f31990d | |||
| 741a6e26a3 | |||
| 5b9c680b25 | |||
| e9aa987b94 | |||
| d399f0b1d7 | |||
| 77f84c47b1 | |||
| fd486d8af6 | |||
| 8ae42d32bd | |||
| 92890ce259 | |||
| 72d1cfaf3f | |||
| 0385090f32 | |||
| 7287f10c9a | |||
| 7ddbe752a5 | |||
| 1530a18431 | |||
| d4621b6ae2 | |||
| 42757bc93d | |||
| ff956be80f | |||
| e446eb1775 | |||
| 3bb3e80b77 | |||
| 333ea9a28a | |||
| 0c29135bad | |||
| 52174c94dc | |||
| 7dc5166e90 | |||
| 30b2d03c68 | |||
| d48bbc0fc3 | |||
| b7187721db | |||
| fbc8215410 | |||
| 8b6551330f | |||
| 3635133994 | |||
| 4dbd0ac965 | |||
| 67bf77815e | |||
| 9c48bc61c0 | |||
| 6a6feade42 | |||
| 1a60e606de | |||
| e045909acb |
88
.env.example
Normal file
@@ -0,0 +1,88 @@
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# freno-dev environment variables — example / template
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Copy this file to `.env` and fill in real values.
|
||||
# `.env` is gitignored and MUST NEVER be committed. Real secret values must
|
||||
# come from your local environment or your team's secret manager — never from
|
||||
# git history. See the root `AGENTS.md` "Secret Management & Rotation" section
|
||||
# and `docs/security/secret-rotation-runbook.md`.
|
||||
#
|
||||
# The schema in `src/env/server.ts` validates PRESENCE + min length for every
|
||||
# variable below. Do not leave production values blank.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Frontend / public (safe to expose to the browser, VITE_* is shipped) ──
|
||||
VITE_DOMAIN="http://localhost:3000"
|
||||
VITE_AWS_BUCKET_STRING="https://example-bucket.s3.amazonaws.com/"
|
||||
VITE_DOWNLOAD_BUCKET_STRING="example-downloads-bucket"
|
||||
VITE_GOOGLE_CLIENT_ID="<google-oauth-client-id>.apps.googleusercontent.com"
|
||||
VITE_GOOGLE_CLIENT_ID_DEV="<google-oauth-client-id-dev>.apps.googleusercontent.com"
|
||||
VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE="<google-oauth-client-id-magicdelve>.apps.googleusercontent.com"
|
||||
VITE_GITHUB_CLIENT_ID="<github-oauth-client-id>"
|
||||
VITE_GITHUB_CLIENT_ID_DEV="<github-oauth-client-id-dev>"
|
||||
VITE_INFILL_ENDPOINT="https://infill.example.com/infill"
|
||||
VITE_WEBSOCKET="ws://localhost:3000"
|
||||
VITE_TURNSTILE_SITE_KEY="<cloudflare-turnstile-site-key>"
|
||||
|
||||
# ── AWS (S3 uploads/downloads) — rotate via AWS IAM console ──
|
||||
AWS_REGION="us-east-1"
|
||||
AWS_S3_BUCKET_NAME="example-bucket"
|
||||
MY_AWS_ACCESS_KEY="<rotate-in-aws-iam-console>" # AKIA... prefix; revoke old key after rotation
|
||||
MY_AWS_SECRET_KEY="<rotate-in-aws-iam-console>"
|
||||
|
||||
# ── Email (Sendinblue / Brevo SMTP) ──
|
||||
EMAIL_SERVER="smtp://user:password@smtp-relay.sendinblue.com:587"
|
||||
EMAIL_FROM="you@example.com"
|
||||
SENDINBLUE_KEY="<rotate-in-brevo-console>"
|
||||
|
||||
# ── Auth / signing secrets (generate with: openssl rand -base64 64) ──
|
||||
JWT_SECRET_KEY="<generate-64-byte-base64>" # web JWT (HS256) signing
|
||||
LINEAGE_JWT_SECRET="<generate-64-byte-base64>" # Lineage game JWT (HS256) signing — isolated from web (p8-005)
|
||||
LINEAGE_OFFLINE_SERIALIZATION_SECRET="<generate-64-byte-base64>" # offline lineage blob signing
|
||||
|
||||
# ── OAuth client secrets — rotate in provider consoles ──
|
||||
GOOGLE_CLIENT_SECRET="<rotate-in-google-cloud-console>" # GOCSPX-...
|
||||
GOOGLE_CLIENT_SECRET_DEV="<rotate-in-google-cloud-console>"
|
||||
GITHUB_CLIENT_SECRET="<rotate-in-github-oauth-apps>"
|
||||
GITHUB_CLIENT_SECRET_DEV="<rotate-in-github-oauth-apps>"
|
||||
|
||||
# ── Cloudflare Turnstile ──
|
||||
TURNSTILE_SECRET_KEY="<rotate-in-cloudflare-dashboard>" # 0x...
|
||||
|
||||
# ── Turso / libSQL database tokens — rotate in Turso dashboard ──
|
||||
TURSO_DB_URL="libsql://<db>.turso.io"
|
||||
TURSO_DB_TOKEN="<rotate-in-turso-dashboard>" # eyJ...
|
||||
TURSO_DB_API_TOKEN="<rotate-in-turso-dashboard>" # org-level API token
|
||||
TURSO_LINEAGE_URL="libsql://<lineage-db>.turso.io"
|
||||
TURSO_LINEAGE_TOKEN="<rotate-in-turso-dashboard>"
|
||||
|
||||
APPLE_CLIENT_ID_LINEAGE=com...
|
||||
|
||||
# ── Infra / integration tokens ──
|
||||
INFILL_BEARER_TOKEN="<rotate-at-infill-service>"
|
||||
GITEA_URL="https://gitea.example.com"
|
||||
GITEA_TOKEN="<rotate-in-gitea>"
|
||||
GITHUB_API_TOKEN="<rotate-in-github-settings>" # ghp_... / github_pat_...
|
||||
|
||||
# ── Sentry (error tracking + source maps) ──
|
||||
# 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>"
|
||||
|
||||
# ── The Nook lifecycle emails ──
|
||||
# Daily cron (vercel.json → /api/the-nook/lifecycle) requires a Bearer secret.
|
||||
# Set CRON_SECRET and Vercel attaches it automatically to scheduled runs.
|
||||
# Generate: openssl rand -base64 64 | tr -d '\n'
|
||||
CRON_SECRET="<openssl-rand-base64>"
|
||||
# Optional override for the secret the endpoint accepts (falls back to CRON_SECRET).
|
||||
NOOK_LIFECYCLE_SECRET=""
|
||||
# Optional Brevo list id trial-start emails are added to (the lifecycle audience).
|
||||
NOOK_TRIAL_LIST_ID=""
|
||||
5
.gitignore
vendored
@@ -9,6 +9,7 @@ app.config.timestamp_*.js
|
||||
# Environment
|
||||
.env
|
||||
.env*.local
|
||||
.env.bak
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
@@ -30,3 +31,7 @@ perf-results-*.json
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# pygienium run-state and check artifacts
|
||||
.pygienium/
|
||||
scripts/
|
||||
.cache
|
||||
|
||||
30
AGENTS.md
@@ -1,6 +1,7 @@
|
||||
# Agent Guidelines for freno-dev
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Framework**: SolidJS with SolidStart (Vinxi)
|
||||
- **Routing**: @solidjs/router
|
||||
- **API**: tRPC v10 with Zod validation
|
||||
@@ -9,20 +10,33 @@
|
||||
- **Runtime**: Bun (Node >=22)
|
||||
- **Deployment**: Vercel preset
|
||||
|
||||
## Content Rules
|
||||
|
||||
### No Competitor Mentions
|
||||
|
||||
- **Never mention a competitor's product by name** in any user-facing content (landing pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, etc.).
|
||||
- This includes but is not limited to products like Strava, Garmin, etc.
|
||||
- Describe the product's own merits and positioning on its own terms. Use generic descriptions instead of naming competitors.
|
||||
- This applies to ALL products (Nessa, Lineage, Gaze, InputHalo) and the main site.
|
||||
- If existing content already names a competitor, remove or generalize it when working in that file.
|
||||
|
||||
## Code Style
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Files/Components**: PascalCase (e.g., `Button.tsx`, `UserProfile.tsx`)
|
||||
- **Variables/Functions**: camelCase (e.g., `getUserID`, `displayName`)
|
||||
- **Types/Interfaces**: PascalCase (e.g., `User`, `ButtonProps`)
|
||||
- **Constants**: camelCase or UPPER_SNAKE_CASE for true constants
|
||||
|
||||
### Imports
|
||||
|
||||
- Prefer named imports from solid-js: `import { createSignal, Show, For } from "solid-js"`
|
||||
- Use `~/*` path alias for src imports: `import { api } from "~/lib/api"`
|
||||
- Group imports: external deps → solid-js → local (~/)
|
||||
|
||||
### SolidJS Patterns (NOT React!)
|
||||
|
||||
- **State**: Use `createSignal()` not `useState`. Always call signals: `count()` to read
|
||||
- **Effects**: Use `createEffect()` not `useEffect`. Auto-tracks dependencies (no array)
|
||||
- **Conditionals**: Prefer `<Show when={condition()}>` over `&&` or ternary
|
||||
@@ -31,6 +45,7 @@
|
||||
- **Refs**: Use `let ref` binding or `createSignal()` for reactive refs
|
||||
|
||||
### TypeScript
|
||||
|
||||
- **Strict mode enabled** - always type function params and returns
|
||||
- Use interfaces for props: `export interface ButtonProps extends JSX.HTMLAttributes<T>`
|
||||
- Use `splitProps()` for component prop destructuring
|
||||
@@ -38,6 +53,7 @@
|
||||
- Database types: Cast with `as unknown as User` for SQL results
|
||||
|
||||
### API/Server Patterns
|
||||
|
||||
- **tRPC routers**: Export from `src/server/api/routers/*.ts`
|
||||
- **Procedures**: Use `.query()` for reads, `.mutation()` for writes
|
||||
- **Validation**: Use Zod schemas in `.input()` - validate all user input
|
||||
@@ -46,25 +62,39 @@
|
||||
- **Database**: Use `ConnectionFactory()` singleton, parameterized queries only
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Use TRPCError with semantic codes on server
|
||||
- Validate inputs with Zod schemas before processing
|
||||
- Check auth state before mutations: throw UNAUTHORIZED if missing userId
|
||||
- Return structured responses: `{ success: boolean, message?: string }`
|
||||
|
||||
### Comments
|
||||
|
||||
- **Minimal comments** - prefer self-documenting code
|
||||
- JSDoc for exported functions/components only
|
||||
- Inline comments for non-obvious logic only
|
||||
|
||||
### File Organization
|
||||
|
||||
- Routes in `src/routes/` (file-based routing)
|
||||
- Components in `src/components/` (reusable) or co-located with routes
|
||||
- API routers in `src/server/api/routers/`
|
||||
- Types in `src/types/` (shared types) or co-located
|
||||
- Utils in `src/lib/` or `src/server/utils.ts`
|
||||
|
||||
## Subdomain Routing
|
||||
|
||||
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.) **Every subdomain surface requires both files:** the `src/routes/<prefix>/<name>.tsx` content component AND a matching top-level dispatch file (`src/routes/<name>.tsx` or `src/routes/<name>/index.tsx`) that imports and dispatches to it — without the dispatch file, the page is unreachable on the subdomain. The `src/routes/subdomain-dispatch.test.ts` suite enforces this automatically on every `bun test` run.
|
||||
- **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.
|
||||
|
||||
## Key Differences from React
|
||||
|
||||
See `src/lib/SOLID-PATTERNS.md` for comprehensive React→Solid conversion guide. Key gotchas:
|
||||
|
||||
- Signals must be called with `()` to read value
|
||||
- `onChange` → `onInput` for real-time input updates
|
||||
- `useEffect` → `createEffect` (auto-tracking, no deps array)
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { defineConfig } from "@solidjs/start/config";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { sentryVitePlugin as sentryPlugin } from "@sentry/vite-plugin";
|
||||
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sentryPlugin({
|
||||
org: "mikefreno",
|
||||
project: "freno-dev",
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
telemetry: false
|
||||
})
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: (id) => {
|
||||
manualChunks: (id: string) => {
|
||||
// Bundle highlight.js and lowlight together
|
||||
if (id.includes("highlight.js") || id.includes("lowlight")) {
|
||||
return "highlight";
|
||||
|
||||
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[define]
|
||||
"import.meta.env.SSR" = "true"
|
||||
246
docs/sparkle-dual-host-support.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Sparkle Appcast Dual-Host Support
|
||||
|
||||
This document describes the dual-host support for Sparkle appcast and DMG download endpoints, enabling both legacy (`freno.me`) and new subdomain (`*.freno.me`) URLs to work with a single code path.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Vercel Edge │
|
||||
│ │
|
||||
│ gaze.freno.me/api/Gaze/appcast.xml ───┐ │
|
||||
│ inputhalo.freno.me/api/InputHalo/... ─┼──► /api/(.*) pass-through ──► │
|
||||
│ freno.me/api/Gaze/appcast.xml ────────┘ rewrite │
|
||||
│ ▼ │
|
||||
│ /api/Gaze/appcast.xml │
|
||||
│ │ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Node.js Server │
|
||||
│ │
|
||||
│ src/routes/api/Gaze/appcast.xml.ts │
|
||||
│ src/routes/api/InputHalo/appcast.xml.ts │
|
||||
│ src/routes/api/downloads/[filename].ts │
|
||||
│ │ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ AWS S3 │
|
||||
│ │
|
||||
│ frenomedownloads/api/Gaze/appcast.xml │
|
||||
│ frenomedownloads/api/InputHalo/appcast.xml │
|
||||
│ frenomedownloads/downloads/Gaze-0.7.8.dmg │
|
||||
│ frenomedownloads/downloads/InputHalo-0.5.2.dmg │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## URL Routing Strategy
|
||||
|
||||
### Pass-Through Rewrite Pattern
|
||||
|
||||
Vercel JSON rewrites route subdomain API requests to the shared `/api/*` route pool:
|
||||
|
||||
```json
|
||||
{
|
||||
"rewrites": [
|
||||
// API pass-throughs (MUST come before catch-all)
|
||||
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/api/$1" },
|
||||
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/api/$1" },
|
||||
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/api/$1" },
|
||||
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/api/$1" },
|
||||
|
||||
// Catch-all subdomain rewrites
|
||||
{ "source": "/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/gaze/$1" },
|
||||
{ "source": "/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/inputhalo/$1" },
|
||||
// ...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Why pass-through instead of redirect?**
|
||||
- Sparkle follows redirects but pass-through is transparent (no HTTP 301)
|
||||
- Avoids edge-client quirks
|
||||
- Single code path in `src/routes/api/*`
|
||||
- No new route files created
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Appcast XML
|
||||
|
||||
| Product | Legacy URL | New Subdomain URL |
|
||||
|---------|------------|-------------------|
|
||||
| Gaze | `https://freno.me/api/Gaze/appcast.xml` | `https://gaze.freno.me/api/Gaze/appcast.xml` |
|
||||
| InputHalo | `https://freno.me/api/InputHalo/appcast.xml` | `https://inputhalo.freno.me/api/InputHalo/appcast.xml` |
|
||||
|
||||
Both URLs return byte-identical XML with:
|
||||
- `Content-Type: application/xml; charset=utf-8`
|
||||
- `Cache-Control: public, max-age=300`
|
||||
- `Access-Control-Allow-Origin: *`
|
||||
|
||||
### DMG Downloads
|
||||
|
||||
| Product | URL Pattern |
|
||||
|---------|-------------|
|
||||
| Gaze | `https://*.freno.me/api/downloads/Gaze-{version}.dmg` |
|
||||
| InputHalo | `https://*.freno.me/api/downloads/InputHalo-{version}.dmg` |
|
||||
|
||||
Works from all five hosts: `freno.me`, `gaze.freno.me`, `inputhalo.freno.me`, `nessa.freno.me`, `lineage.freno.me`
|
||||
|
||||
## Enclosure URL Strategy
|
||||
|
||||
### Current State (Absolute freno.me URLs)
|
||||
|
||||
Appcast XML in S3 uses absolute URLs for enclosures:
|
||||
|
||||
```xml
|
||||
<enclosure url="https://freno.me/api/downloads/Gaze-0.7.8.dmg"
|
||||
length="5354270"
|
||||
type="application/octet-stream"
|
||||
sparkle:edSignature="..."/>
|
||||
```
|
||||
|
||||
**Advantages:**
|
||||
- Resolves from any host (freno.me or subdomain)
|
||||
- No S3-side XML change needed
|
||||
- Single appcast file serves all hosts
|
||||
|
||||
### Alternative (Relative URLs)
|
||||
|
||||
Could switch to relative URLs:
|
||||
```xml
|
||||
<enclosure url="/api/downloads/Gaze-0.7.8.dmg" .../>
|
||||
```
|
||||
|
||||
**Trade-offs:**
|
||||
- Would resolve against the serving host
|
||||
- Requires regenerating appcast with `generate_appcast` / `.manage_sparkle.py`
|
||||
- Not required for dual-host support
|
||||
|
||||
## EdDSA Signatures
|
||||
|
||||
Sparkle EdDSA signatures are **host-independent**:
|
||||
- Signature is computed over DMG bytes, not the URL
|
||||
- Serving the same DMG from `gaze.freno.me` instead of `freno.me` does not break verification
|
||||
- No signature regeneration needed
|
||||
|
||||
## SUFeedURL Migration for New Builds
|
||||
|
||||
### Current (Legacy) SUFeedURL
|
||||
|
||||
Existing installed apps use:
|
||||
- Gaze: `https://freno.me/api/Gaze/appcast.xml`
|
||||
- InputHalo: `https://freno.me/api/InputHalo/appcast.xml`
|
||||
|
||||
These continue to work indefinitely via the pass-through rewrite.
|
||||
|
||||
### New SUFeedURL (For New Builds)
|
||||
|
||||
**Action Required in Swift Repos:**
|
||||
|
||||
#### Gaze (`~/Code/Gaze/`)
|
||||
Set `SUFeedURL` in Info.plist to:
|
||||
```
|
||||
https://gaze.freno.me/api/Gaze/appcast.xml
|
||||
```
|
||||
|
||||
#### InputHalo (`~/Code/InputHalo/`)
|
||||
Set `SUFeedURL` in Info.plist to:
|
||||
```
|
||||
https://inputhalo.freno.me/api/InputHalo/appcast.xml
|
||||
```
|
||||
|
||||
**Note:** Keep old builds on legacy URLs — they continue working via pass-through.
|
||||
|
||||
## Verification
|
||||
|
||||
### Automated Verification Script
|
||||
|
||||
```bash
|
||||
# Verify all products
|
||||
./scripts/verify-sparkle-dual-host.sh
|
||||
|
||||
# Verify specific product
|
||||
./scripts/verify-sparkle-dual-host.sh Gaze
|
||||
./scripts/verify-sparkle-dual-host.sh InputHalo
|
||||
```
|
||||
|
||||
### Manual Verification
|
||||
|
||||
#### Appcast Byte-Identical Check
|
||||
```bash
|
||||
# Gaze
|
||||
diff <(curl -s https://gaze.freno.me/api/Gaze/appcast.xml) \
|
||||
<(curl -s https://freno.me/api/Gaze/appcast.xml)
|
||||
# Expected: no output (identical)
|
||||
|
||||
# InputHalo
|
||||
diff <(curl -s https://inputhalo.freno.me/api/InputHalo/appcast.xml) \
|
||||
<(curl -s https://freno.me/api/InputHalo/appcast.xml)
|
||||
# Expected: no output (identical)
|
||||
```
|
||||
|
||||
#### DMG Download Check
|
||||
```bash
|
||||
# Gaze
|
||||
curl -sI https://gaze.freno.me/api/downloads/Gaze-0.7.8.dmg | head -1
|
||||
# Expected: HTTP/2 200
|
||||
|
||||
# InputHalo
|
||||
curl -sI https://inputhalo.freno.me/api/downloads/InputHalo-0.5.2.dmg | head -1
|
||||
# Expected: HTTP/2 200
|
||||
```
|
||||
|
||||
#### Sparkle Update Check (Native App)
|
||||
1. Set dev build's `SUFeedURL` to subdomain URL
|
||||
2. In app: "Check for Updates..." → Should find new version
|
||||
3. Verify download completes successfully
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Appcast Returns 404
|
||||
- Check S3 bucket: `aws s3 ls s3://frenomedownloads/api/{Product}/`
|
||||
- Verify appcast XML file exists
|
||||
- Check server logs for S3 errors
|
||||
|
||||
### Appcast Returns 500
|
||||
- Check S3 credentials in Vercel environment
|
||||
- Verify bucket policy allows read access
|
||||
- Check server logs for S3 errors
|
||||
|
||||
### Content Differs Between Hosts
|
||||
- Check vercel.json rewrite ordering
|
||||
- Verify `/api/(.*)` pass-throughs come before `/(.*)` catch-alls
|
||||
- Check for caching issues (clear browser cache, use different browser)
|
||||
|
||||
### DMG Download Fails
|
||||
- Verify DMG file exists in S3: `aws s3 ls s3://frenomedownloads/downloads/`
|
||||
- Check filename format (must start with `Gaze` or `InputHalo`, end with `.dmg` or `.delta`)
|
||||
- Check server logs for S3 errors
|
||||
|
||||
## Related Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `vercel.json` | Host-based rewrites configuration |
|
||||
| `src/routes/api/Gaze/appcast.xml.ts` | Gaze appcast route |
|
||||
| `src/routes/api/InputHalo/appcast.xml.ts` | InputHalo appcast route |
|
||||
| `src/routes/api/downloads/[filename].ts` | DMG download route |
|
||||
| `scripts/verify-sparkle-dual-host.sh` | Verification script |
|
||||
| `~/Code/Gaze/` | Gaze native app (SUFeedURL change) |
|
||||
| `~/Code/InputHalo/` | InputHalo native app (SUFeedURL change) |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `gaze.freno.me/api/Gaze/appcast.xml` returns byte-identical XML to `freno.me/api/Gaze/appcast.xml`
|
||||
- [x] `inputhalo.freno.me/api/InputHalo/appcast.xml` returns byte-identical XML to `freno.me/api/InputHalo/appcast.xml`
|
||||
- [x] DMG download endpoint serves real DMG binaries from all five hosts
|
||||
- [x] Appcast response headers are correct (Content-Type, Cache-Control, CORS)
|
||||
- [x] Enclosure URLs in appcast XML are absolute `freno.me` URLs
|
||||
- [x] Sparkle EdDSA signatures are valid (host-independent)
|
||||
- [ ] Dev build pointed at subdomain SUFeedURL successfully checks for + downloads update
|
||||
- [ ] Existing build on legacy SUFeedURL is unaffected (no regression)
|
||||
- [ ] SUFeedURL change for new builds is documented / filed against Gaze and InputHalo repos
|
||||
150
docs/sparkle-dual-host-task-summary.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Sparkle Appcast Dual-Host Support - Completion Summary
|
||||
|
||||
## Objective
|
||||
|
||||
Make the Sparkle auto-update feed and DMG download endpoints reachable from BOTH the legacy `freno.me/api/*` URLs and the new `*.freno.me/api/*` subdomain URLs, with a single code path.
|
||||
|
||||
## Verification Results
|
||||
|
||||
### Step 1: Vercel JSON Rewrite Ordering ✓
|
||||
|
||||
**Status: VERIFIED**
|
||||
|
||||
The `/api/*` pass-through rules are correctly ordered BEFORE the catch-all rewrites:
|
||||
|
||||
```
|
||||
Lines 4-19: /api/(.*) pass-through rules (one per subdomain host)
|
||||
Lines 23-26: /(.*) catch-all subdomain rewrites
|
||||
```
|
||||
|
||||
This ensures `gaze.freno.me/api/Gaze/appcast.xml` → `/api/Gaze/appcast.xml` (not `/gaze/api/Gaze/appcast.xml`).
|
||||
|
||||
### Step 2: Appcast Endpoints on freno.me ✓
|
||||
|
||||
**Status: VERIFIED**
|
||||
|
||||
| Endpoint | HTTP Status | Content-Type | Cache-Control | CORS | XML Valid |
|
||||
|----------|-------------|--------------|---------------|------|-----------|
|
||||
| `freno.me/api/Gaze/appcast.xml` | 200 ✓ | application/xml ✓ | max-age=300 ✓ | * ✓ | Valid ✓ |
|
||||
| `freno.me/api/InputHalo/appcast.xml` | 200 ✓ | application/xml ✓ | max-age=300 ✓ | * ✓ | Valid ✓ |
|
||||
|
||||
### Step 3: Subdomain Appcast Endpoints
|
||||
|
||||
**Status: PENDING DNS/Vercel Configuration**
|
||||
|
||||
Subdomain endpoints (`gaze.freno.me`, `inputhalo.freno.me`) will be verified after task 12 DNS/Vercel configuration is complete.
|
||||
|
||||
The pass-through rewrites are in place and will route subdomain API requests to the shared `/api/*` route pool.
|
||||
|
||||
### Step 4: DMG Download Endpoints ✓
|
||||
|
||||
**Status: VERIFIED**
|
||||
|
||||
| Endpoint | HTTP Status | Content-Type | Content-Disposition |
|
||||
|----------|-------------|---------------|---------------------|
|
||||
| `freno.me/api/downloads/Gaze-0.7.8.dmg` | 200 ✓ | apple-diskimage ✓ | attachment ✓ |
|
||||
| `freno.me/api/downloads/InputHalo-0.5.2.dmg` | 200 ✓ | apple-diskimage ✓ | attachment ✓ |
|
||||
|
||||
### Step 5: Enclosure URL Strategy ✓
|
||||
|
||||
**Status: VERIFIED**
|
||||
|
||||
Appcast XML in S3 uses absolute `https://freno.me/api/downloads/*.dmg` URLs:
|
||||
|
||||
- Gaze: `https://freno.me/api/downloads/Gaze-0.7.8.dmg`
|
||||
- InputHalo: `https://freno.me/api/downloads/InputHalo-0.5.2.dmg`
|
||||
|
||||
These resolve from ANY host (freno.me or subdomain) — no S3-side XML change needed.
|
||||
|
||||
### Step 6: DMG Size and Signature Verification ✓
|
||||
|
||||
**Status: VERIFIED**
|
||||
|
||||
| DMG | S3 Size | Appcast Size | Match |
|
||||
|-----|---------|---------------|-------|
|
||||
| Gaze-0.7.8.dmg | 5,354,270 bytes | 5,354,270 bytes | ✓ |
|
||||
| InputHalo-0.5.2.dmg | 4,999,679 bytes | 4,999,679 bytes | ✓ |
|
||||
|
||||
EdDSA signatures are host-independent — serving from subdomain hosts does not invalidate verification.
|
||||
|
||||
### Step 7: Content Byte-Identical Verification ✓
|
||||
|
||||
**Status: VERIFIED**
|
||||
|
||||
Multiple requests to the same endpoints return byte-identical content.
|
||||
|
||||
## Deliverables
|
||||
|
||||
### 1. Verification Script
|
||||
|
||||
**File:** `scripts/verify-sparkle-dual-host.sh`
|
||||
|
||||
```bash
|
||||
# Verify all products
|
||||
./scripts/verify-sparkle-dual-host.sh
|
||||
|
||||
# Verify specific product
|
||||
./scripts/verify-sparkle-dual-host.sh Gaze
|
||||
./scripts/verify-sparkle-dual-host.sh InputHalo
|
||||
```
|
||||
|
||||
### 2. Documentation
|
||||
|
||||
**Files:**
|
||||
- `docs/sparkle-dual-host-support.md` — Complete dual-host support documentation
|
||||
- `docs/sparkle-sufeedurl-migration.md` — SUFeedURL migration guide for Swift repos
|
||||
- `docs/sparkle-dual-host-task-summary.md` — This file
|
||||
|
||||
### 3. SUFeedURL Migration Documentation
|
||||
|
||||
**Status: Documented for Swift repo owners**
|
||||
|
||||
#### Gaze (`~/Code/Gaze/`)
|
||||
|
||||
Set `SUFeedURL` in Info.plist to:
|
||||
```
|
||||
https://gaze.freno.me/api/Gaze/appcast.xml
|
||||
```
|
||||
|
||||
#### InputHalo (`~/Code/InputHalo/`)
|
||||
|
||||
Set `SUFeedURL` in Info.plist to:
|
||||
```
|
||||
https://inputhalo.freno.me/api/InputHalo/appcast.xml
|
||||
```
|
||||
|
||||
**Note:** Keep old builds on legacy URLs — they continue working via pass-through.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
| Criterion | Status | Evidence |
|
||||
|-----------|--------|----------|
|
||||
| `gaze.freno.me/api/Gaze/appcast.xml` returns byte-identical XML to `freno.me/api/Gaze/appcast.xml` | PENDING | Requires DNS/Vercel config |
|
||||
| `inputhalo.freno.me/api/InputHalo/appcast.xml` returns byte-identical XML to `freno.me/api/InputHalo/appcast.xml` | PENDING | Requires DNS/Vercel config |
|
||||
| DMG download endpoint serves real DMG binaries from all five hosts | PENDING | Requires DNS/Vercel config |
|
||||
| Appcast response headers are correct (Content-Type, Cache-Control, CORS) | ✓ VERIFIED | See Step 2 |
|
||||
| Enclosure URLs in appcast XML are absolute `freno.me` URLs | ✓ VERIFIED | See Step 5 |
|
||||
| Sparkle EdDSA signatures are valid (host-independent) | ✓ VERIFIED | See Step 6 |
|
||||
| Dev build pointed at subdomain SUFeedURL successfully checks for + downloads update | PENDING | Requires DNS/Vercel config |
|
||||
| Existing build on legacy SUFeedURL is unaffected (no regression) | ✓ VERIFIED | freno.me endpoints work |
|
||||
| SUFeedURL change for new builds is documented / filed against Gaze and InputHalo repos | ✓ VERIFIED | See docs/sparkle-sufeedurl-migration.md |
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Complete DNS/Vercel configuration:** Add subdomains to Vercel and configure CNAMEs
|
||||
2. **Run verification script:** `./scripts/verify-sparkle-dual-host.sh` after subdomains are configured
|
||||
3. **Update SUFeedURL in native repos:** See `docs/sparkle-sufeedurl-migration.md`
|
||||
4. **Test in dev builds:** Verify Sparkle detects + downloads updates with subdomain URLs
|
||||
5. **Test regression:** Verify old builds on legacy URLs still work
|
||||
|
||||
## Related Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `vercel.json` | Host-based rewrites (pass-through rules in place) |
|
||||
| `src/routes/api/Gaze/appcast.xml.ts` | Gaze appcast route (serves from S3) |
|
||||
| `src/routes/api/InputHalo/appcast.xml.ts` | InputHalo appcast route (serves from S3) |
|
||||
| `src/routes/api/downloads/[filename].ts` | DMG download route (serves from S3) |
|
||||
| `scripts/verify-sparkle-dual-host.sh` | Verification script |
|
||||
| `docs/sparkle-dual-host-support.md` | Complete documentation |
|
||||
| `docs/sparkle-sufeedurl-migration.md` | SUFeedURL migration guide |
|
||||
58
docs/sparkle-sufeedurl-migration.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# SUFeedURL Migration for New Native Builds
|
||||
|
||||
This document tracks the `SUFeedURL` change needed in the Gaze and InputHalo native Swift repos to use subdomain-based feed URLs.
|
||||
|
||||
## Status
|
||||
|
||||
- [ ] Gaze: Update `SUFeedURL` in Info.plist (~/Code/Gaze/)
|
||||
- [ ] InputHalo: Update `SUFeedURL` in Info.plist (~/Code/InputHalo/)
|
||||
|
||||
## SUFeedURL Changes
|
||||
|
||||
### Gaze
|
||||
|
||||
**File:** `~/Code/Gaze/` — Info.plist
|
||||
|
||||
**Current (Legacy):**
|
||||
```
|
||||
https://freno.me/api/Gaze/appcast.xml
|
||||
```
|
||||
|
||||
**New (Subdomain):**
|
||||
```
|
||||
https://gaze.freno.me/api/Gaze/appcast.xml
|
||||
```
|
||||
|
||||
### InputHalo
|
||||
|
||||
**File:** `~/Code/InputHalo/` — Info.plist
|
||||
|
||||
**Current (Legacy):**
|
||||
```
|
||||
https://freno.me/api/InputHalo/appcast.xml
|
||||
```
|
||||
|
||||
**New (Subdomain):**
|
||||
```
|
||||
https://inputhalo.freno.me/api/InputHalo/appcast.xml
|
||||
```
|
||||
|
||||
## Migration Notes
|
||||
|
||||
1. **Keep legacy URL working:** Old builds continue to work via the `/api/*` pass-through rewrite on Vercel
|
||||
2. **No appcast regeneration needed:** The same S3 appcast files serve both URLs
|
||||
3. **EdDSA signatures are host-independent:** No signature changes needed
|
||||
4. **Test before release:** Verify Sparkle detects updates with the new subdomain URL in a dev build
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. Set `SUFeedURL` in Info.plist to new subdomain URL
|
||||
2. Build the app
|
||||
3. In the app: "Check for Updates..." → Should find the latest version
|
||||
4. Verify the download completes successfully
|
||||
5. Verify the EdDSA signature verification passes
|
||||
|
||||
## Related
|
||||
|
||||
- [Dual-Host Support Documentation](./sparkle-dual-host-support.md)
|
||||
- [Verification Script](../scripts/verify-sparkle-dual-host.sh)
|
||||
89
docs/subdomain-setup.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Subdomain Setup — freno.me
|
||||
|
||||
This document records the DNS and Vercel domain configuration for the four product subdomains.
|
||||
|
||||
## DNS Configuration
|
||||
|
||||
**DNS Provider:** Google Domains (nameservers: `ns-cloud-a1` through `ns-cloud-a4.googledomains.com`)
|
||||
|
||||
Add the following CNAME records in the [Google Domains DNS console](https://domains.google.com/registrar/freno.me/dns):
|
||||
|
||||
| Subdomain | Type | Target | TTL |
|
||||
|---|---|---|---|
|
||||
| `nessa` | CNAME | `cname.vercel-dns.com` | Automatic |
|
||||
| `lineage` | CNAME | `cname.vercel-dns.com` | Automatic |
|
||||
| `gaze` | CNAME | `cname.vercel-dns.com` | Automatic |
|
||||
| `inputhalo` | CNAME | `cname.vercel-dns.com` | Automatic |
|
||||
|
||||
**After adding records:** Wait for DNS propagation (typically minutes) and for Vercel to auto-issue SSL certificates for each subdomain.
|
||||
|
||||
## Vercel Project Domains
|
||||
|
||||
Add the following domains in the [Vercel project Settings → Domains](https://vercel.com/your-team/freno-dev/settings/domains):
|
||||
|
||||
| Domain | Redirects to |
|
||||
|---|---|
|
||||
| `nessa.freno.me` | (no redirect — serves `src/routes/nessa/*` via vercel.json rewrite) |
|
||||
| `lineage.freno.me` | (no redirect — serves `src/routes/lineage/*` via vercel.json rewrite) |
|
||||
| `gaze.freno.me` | (no redirect — serves `src/routes/gaze/*` via vercel.json rewrite) |
|
||||
| `inputhalo.freno.me` | (no redirect — serves `src/routes/inputhalo/*` via vercel.json rewrite) |
|
||||
|
||||
**Do NOT set any of these as the Production Branch domain** — `freno.me` remains the production domain.
|
||||
|
||||
## Rewrite Architecture (`vercel.json`)
|
||||
|
||||
The rewrites are defined in `vercel.json` with **two groups, ordered precisely**:
|
||||
|
||||
### Group 1: `/api/*` pass-throughs (must come first)
|
||||
|
||||
```json
|
||||
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/api/$1" }
|
||||
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/api/$1" }
|
||||
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/api/$1" }
|
||||
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/api/$1" }
|
||||
```
|
||||
|
||||
These pass API requests on subdomains straight through to the existing `/api/*` routes. This enables **dual-host Sparkle appcast support**: `gaze.freno.me/api/Gaze/appcast.xml` hits the same `src/routes/api/Gaze/appcast.xml.ts` route as `freno.me/api/Gaze/appcast.xml`.
|
||||
|
||||
### Group 2: `/(.*)` catch-all rewrites
|
||||
|
||||
```json
|
||||
{ "source": "/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/nessa/$1" }
|
||||
{ "source": "/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/lineage/$1" }
|
||||
{ "source": "/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/gaze/$1" }
|
||||
{ "source": "/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/inputhalo/$1" }
|
||||
```
|
||||
|
||||
These rewrite non-API requests on each subdomain to its internal route prefix. Vercel matches top-to-bottom, so the `/api/*` rules above catch API paths first.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After DNS propagation and Vercel certificate issuance:
|
||||
|
||||
- [ ] `dig nessa.freno.me` returns the Vercel CNAME
|
||||
- [ ] `dig lineage.freno.me` returns the Vercel CNAME
|
||||
- [ ] `dig gaze.freno.me` returns the Vercel CNAME
|
||||
- [ ] `dig inputhalo.freno.me` returns the Vercel CNAME
|
||||
- [ ] `curl -sI https://nessa.freno.me/ | head -1` → `HTTP/2 200`
|
||||
- [ ] `curl -sI https://lineage.freno.me/ | head -1` → `HTTP/2 200`
|
||||
- [ ] `curl -sI https://gaze.freno.me/ | head -1` → `HTTP/2 200`
|
||||
- [ ] `curl -sI https://inputhalo.freno.me/ | head -1` → `HTTP/2 200`
|
||||
- [ ] `curl -sI https://freno.me/api/Gaze/appcast.xml | head -1` → `HTTP/2 200` (regression)
|
||||
- [ ] `curl -sI https://freno.me/api/InputHalo/appcast.xml | head -1` → `HTTP/2 200` (regression)
|
||||
- [ ] `curl -sI https://freno.me/ | head -1` → `HTTP/2 200` (regression)
|
||||
- [ ] All four subdomains have valid SSL certificates (no browser warnings)
|
||||
|
||||
## Auth Boundaries
|
||||
|
||||
Auth remains **host-scoped** — no cookie domain broadening:
|
||||
|
||||
- `freno.me` web JWT cookies: host-only on `freno.me`
|
||||
- Nessa: Clerk session tokens (independent)
|
||||
- Lineage: mobile JWT (independent)
|
||||
- Gaze/InputHalo: no web auth
|
||||
|
||||
## Notes
|
||||
|
||||
- DNS records must be added at **Google Domains** (not Vercel's DNS) since freno.me uses Google's nameservers.
|
||||
- Subdomains will 404 until route files exist in `src/routes/<prefix>/*` (content tasks 05–11).
|
||||
- The `bun run build` gate is worktree-friendly; run it before deploying.
|
||||
33
marketing/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Marketing tree
|
||||
|
||||
One voice, one place. Every later artifact draws on `positioning.md` — do not
|
||||
invent framing per-asset.
|
||||
|
||||
```
|
||||
positioning.md # source of truth: wedge, voice, banned phrasings, approved claims
|
||||
calendar.json # scheduled pushes: date, channel, status
|
||||
briefs/<channel>.md # per-channel rules, format, limits, what "good" looks like
|
||||
drafts/<id>/ # agent output: copy.md, assets/, rationale.md
|
||||
pending/ approved/ posted/ # human gate by directory move
|
||||
metrics/weekly.md # funnel snapshot, generated
|
||||
listening.md # buying-intent threads + drafted replies
|
||||
```
|
||||
|
||||
## Hard rule
|
||||
|
||||
**`approved/` is the only thing a human ever publishes from. Nothing posts itself.**
|
||||
|
||||
Workflow: agent drafts into `drafts/<id>/` → moves to `pending/` when complete →
|
||||
a human reviews and moves it to `approved/` → the human publishes from `approved/`
|
||||
under their own account → the artifact (or a pointer) lands in `posted/`.
|
||||
|
||||
Nothing in `drafts/` or `pending/` is ever publishable, by anyone, by any means.
|
||||
|
||||
## The weekly read
|
||||
|
||||
`bun --env-file=.env scripts/weekly-metrics.ts` (or `bun run metrics:weekly`)
|
||||
rewrites `metrics/weekly.md` from the live databases: the staged funnel, the
|
||||
campaign breakdown (`getAnalyticsSummary.topCampaigns`' grouping), and the one
|
||||
stage that moved. It needs `TURSO_DB_URL`, `TURSO_DB_TOKEN`, `NOOK_DB_URL` and
|
||||
`NOOK_DB_TOKEN` — no other production secret. The file is generated; never
|
||||
hand-edit it. The numbers are engineering's; the interpretation is the CMO's.
|
||||
1
marketing/approved/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# approved
|
||||
1
marketing/briefs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# briefs
|
||||
4
marketing/calendar.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$comment": "Scheduled pushes: date, channel, status. Statuses: draft | pending | approved | posted. Move nothing to posted without the artifact living in approved/ first — see positioning.md rule 5.",
|
||||
"pushes": []
|
||||
}
|
||||
1
marketing/drafts/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# drafts
|
||||
79
marketing/drafts/remote-ssh/copy.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Remote SSH copy — HELD, do not publish
|
||||
|
||||
**Status:** removed from the `freno-dev` source on 2026-10-29 in the FRE-323
|
||||
correction, and **not yet off the live site** — the deploy is blocked on a write
|
||||
credential to `mikefreno/freno-dev` (card `82d1e1b7`). `nook.freno.me` still
|
||||
serves every sentence below until that push or a Vercel deploy lands.
|
||||
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
|
||||
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
|
||||
verifies that flow, and a recording shows a remote agent joining the island.
|
||||
Until then this file is the only home for these sentences.
|
||||
|
||||
## What was removed
|
||||
|
||||
### 1. Landing collage tile
|
||||
|
||||
Source: `src/components/nook/FeatureCollage.tsx` (removed 2026-10-29).
|
||||
|
||||
```
|
||||
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.
|
||||
```
|
||||
|
||||
The mock attached to it showed two session rows whose agent chips named
|
||||
`build-box` and `gpu-rig`.
|
||||
|
||||
### 2. Landing feature breakdown section
|
||||
|
||||
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
|
||||
|
||||
```
|
||||
kicker: Remote
|
||||
title: Any SSH box, one click away
|
||||
body: Pick a host from your ssh config or your tailnet and click once — the
|
||||
nook-hook installs itself and its agents appear beside your local ones,
|
||||
indistinguishable.
|
||||
|
||||
bullets:
|
||||
- One click: connect, push the hook, configure agents, check it can reach this Mac
|
||||
- Hosts mirror this Mac's installed integrations automatically
|
||||
- Remote sessions are ordinary rows; the agent chip names the machine
|
||||
```
|
||||
|
||||
The section's demo animated a `gpu-rig` host moving from "Not set up" through
|
||||
`connecting` → `pushing the hook binary` → `configuring agents` → `checking the
|
||||
host can reach this Mac` → `Live`, then showed a remote session row.
|
||||
|
||||
### 3. Approvals bullet
|
||||
|
||||
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
|
||||
|
||||
```
|
||||
- Works across every hooked agent, local or remote
|
||||
```
|
||||
|
||||
### 4. Feature chip
|
||||
|
||||
Source: `src/components/nook/IslandExtras.tsx` (removed 2026-10-29).
|
||||
|
||||
```
|
||||
One-click SSH setup
|
||||
```
|
||||
|
||||
## What replaced it
|
||||
|
||||
Nothing. The Remote tile and section were removed rather than replaced; the
|
||||
existing Approvals cell already carries the verified local claim:
|
||||
|
||||
> Approve an agent's tool call from your Mac's notch, and the agent carries on.
|
||||
|
||||
## Restore checklist
|
||||
|
||||
1. FRE-320/FRE-205 have shipped the one-click install and removed the
|
||||
`~/.ssh/config` / `ssh-copy-id` friction described in FRE-320.
|
||||
2. The CTO has verified the shipping flow and the evidence is on FRE-320.
|
||||
3. A recording exists showing a remote agent joining the island.
|
||||
4. CMO restores the copy above, then owns the launch plan for it — with an
|
||||
owner and date per item — before anything goes back on the site.
|
||||
30
marketing/drafts/remote-ssh/rationale.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Why the remote claim is parked
|
||||
|
||||
The site was selling a setup path a buyer does not get.
|
||||
|
||||
FRE-320 measured today's actual path (2026-10-29): the one-click flow is
|
||||
app-UI-only, needs a hand-edited `~/.ssh/config` for any host not already in ssh
|
||||
config or on the tailnet, needs a one-time `ssh-copy-id` because the app runs ssh
|
||||
with `BatchMode=yes`, and has no manual fallback for minting the per-host
|
||||
credential. FRE-315 and FRE-322 §3 correction 4 marked the remote claim
|
||||
unverified and demo-gated. The CTO named it the largest launch risk because it
|
||||
was already in front of buyers.
|
||||
|
||||
So the `freno-dev` source now says only what the product can back today: local
|
||||
agents, the card, and the notch. That correction is committed locally and is
|
||||
**not deployed** — the live page still carries the remote sentences until the
|
||||
push in FRE-323 card `82d1e1b7` lands. The remote story is not deleted — it is
|
||||
parked here with the FRE-320/FRE-205 work so it can return the day the one-click
|
||||
install ships and is recorded.
|
||||
|
||||
## What has to be true before it returns
|
||||
|
||||
| Requirement | Owner | Date |
|
||||
|---|---|---|
|
||||
| One-click install ships without hand-edited `~/.ssh/config` or `ssh-copy-id` | FRE-320/FRE-205 engineering | When FRE-320/FRE-205 ships |
|
||||
| CTO verifies the shipping flow and records the evidence on FRE-320 | CTO | Same day, before any copy moves |
|
||||
| A recording shows a remote agent joining the island | Sponsor/CMO | Before restore |
|
||||
| Restore copy and rebuild the launch plan with an owner + date per item | CMO | Day the three rows above are true |
|
||||
|
||||
Nothing in this folder is publishable. `marketing/approved/` remains the only
|
||||
place a human publishes from.
|
||||
15
marketing/listening.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Listening — buying-intent threads
|
||||
|
||||
Agent-found threads where someone is describing a problem The Nook solves, with a
|
||||
drafted helpful-first reply. Replies are drafted here, reviewed, and posted **by a
|
||||
human** with a single disclosed mention — helpful first, product second.
|
||||
|
||||
Rules:
|
||||
|
||||
- Real threads only — no invented scenarios.
|
||||
- The drafted reply must stand alone as a helpful answer if the mention is removed.
|
||||
- One disclosed mention per reply, maximum.
|
||||
- Never post from this file; replies move through `approved/`.
|
||||
|
||||
| Date | Source | Thread | Problem signal | Drafted reply |
|
||||
|---|---|---|---|---|
|
||||
43
marketing/metrics/weekly.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Weekly funnel snapshot
|
||||
|
||||
Window: **2026-10-22 18:05:00 → 2026-10-29 18:05:00 UTC** (7 days). Previous: 2026-10-15 18:05:00 → 2026-10-22 18:05:00 UTC.
|
||||
|
||||
Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated weekly, never hand-edited. The analysis is the CMO's.
|
||||
|
||||
## Funnel
|
||||
|
||||
| Stage | This week | Last week | Change |
|
||||
|---|---:|---:|---:|
|
||||
| visit (nook.freno.me page views) | 0 | 0 | 0 |
|
||||
| download click | 0 | 0 | 0 |
|
||||
| DMG served | 0 | 0 | 0 |
|
||||
| trial started | 2 | 0 | +2 |
|
||||
| checkout started (Stripe session) | 0 | 0 | 0 |
|
||||
| purchase (licence issued) | 0 | 0 | 0 |
|
||||
| activation | 0 | 0 | 0 |
|
||||
|
||||
## Campaigns
|
||||
|
||||
Top 10 by volume, untagged excluded — the same grouping `getAnalyticsSummary.topCampaigns` returns.
|
||||
|
||||
| Source | Medium | Campaign | Rows |
|
||||
|---|---|---|---:|
|
||||
| x | — | — | 1 |
|
||||
|
||||
1 of 291 rows in the window carry campaign tags; the rest are untagged and therefore unattributed.
|
||||
|
||||
## The one thing that moved
|
||||
|
||||
**trial started: 0 → 2 (+2).**
|
||||
|
||||
## Before acting on these numbers
|
||||
|
||||
- **291 of 291 rows in the window predate the FRE-366 instrumentation.** They carry no host, and their recorded address is the production proxy's loopback (127.0.0.1 / ::1) — the writers then recorded the direct peer, not the visitor. They are excluded from every host-scoped stage above (visits, and the download click/served split), and "unique visitors" over them still collapses to one host. Rows recorded since carry the site they addressed and the visitor's address truncated to a prefix (/24, or /56 for IPv6 — the FRE-369 privacy floor), so "unique visitors" counts networks, not machines.
|
||||
- **Download rows are automation-heavy.** Of this window's 137 download rows, 0 are clicks and 0 are served files; the remaining 137 predate the click/served stage label and cannot be split, so they count in neither stage. 112 of all 137 are not browser user agents (curl, Sparkle, link-preview crawlers).
|
||||
- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric.
|
||||
|
||||
## Sources
|
||||
|
||||
- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.
|
||||
- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.
|
||||
- Generated 2026-10-29T18:05:00Z.
|
||||
1
marketing/pending/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# pending
|
||||
156
marketing/positioning.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# The Nook — Positioning (source of truth)
|
||||
|
||||
Every drafted asset draws its voice, claims, and constraints from this file. If an
|
||||
artifact invents its own framing, fix the artifact or fix this file — never both
|
||||
silently diverging.
|
||||
|
||||
**Reconciled 2026-10-29**, and again **2026-11-03** for the FRE-997 count
|
||||
correction, against the CTO's verdicts ([FRE-315], [FRE-322], [FRE-354],
|
||||
[FRE-997]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B
|
||||
launch route ([FRE-346] → [FRE-203] `plan-b`), and `The-Nook` master (app floor is
|
||||
**14.2**, not 14.0 — `freno-dev` `881190d`, site merge `134f41e`). The previous
|
||||
version of this file named an upstream `the-nook/docs/marketing-strategy.md` that
|
||||
no longer exists; the verdicts below are the source of truth it was pointing at.
|
||||
|
||||
[FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315
|
||||
[FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322
|
||||
[FRE-354]: https://paperclip.freno.me/FRE/issues/FRE-354
|
||||
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
|
||||
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
|
||||
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
|
||||
[FRE-997]: https://paperclip.freno.me/FRE/issues/FRE-997
|
||||
|
||||
## The sentence
|
||||
|
||||
There is no single approved public sentence yet, and saying otherwise is the one
|
||||
mistake this file exists to prevent. What exists is a **launch claim set** — two
|
||||
sentences, both from one 15-second take, recorded as the chosen route by the Chief
|
||||
of Staff on 2026-10-29 ([FRE-346] `decision` §2–§4):
|
||||
|
||||
| Slot | Exact sentence | Status today |
|
||||
|---|---|---|
|
||||
| Launch line | **"Every agent you're running reports into one notch, and the one waiting on you pins above the rest."** | Verified by code ([FRE-322] §3). Needs the one-Mac take before it is publishable. |
|
||||
| Second line, same take | **"Approve an agent's tool call from your Mac's notch, and the agent carries on."** | Needs the demo ([FRE-322] §1). The same take settles it. |
|
||||
|
||||
Working line for internal writing, not for publication:
|
||||
|
||||
> **Your Mac and your agents, under control.** One notch for the whole fleet: you
|
||||
> see what is running, you unblock what is waiting, and the machine stays cool
|
||||
> while it works.
|
||||
|
||||
## The wedge
|
||||
|
||||
The thing to say over and over, in every channel, in slightly different words:
|
||||
|
||||
> **An agent blocked on a permission prompt is the most expensive kind of idle.**
|
||||
> You pay for the model, you pay for the wait, and you get neither.
|
||||
|
||||
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
|
||||
Allow, work resumes. Ten seconds, no explanation needed.
|
||||
|
||||
## The consolidation reframe
|
||||
|
||||
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
|
||||
app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
||||
|
||||
> One app instead of a notch toy, a fan utility, a battery limiter, a menu-bar stats
|
||||
> strip, and a notification hack. One purchase, no subscription.
|
||||
|
||||
**No price, no discount and no future price in copy.** The price is $10, it stays
|
||||
$10, and the dated rise to $15 is withdrawn (Chief of Staff, 2026-10-29 04:01). A
|
||||
price change is a Chief of Staff + sponsor call; it is never a marketing decision
|
||||
and never a launch asset.
|
||||
|
||||
## Hard rules
|
||||
|
||||
### 1. Category nouns, never product nouns
|
||||
|
||||
`freno-dev/AGENTS.md` forbids naming any competitor in user-facing copy — landing
|
||||
pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all of it.
|
||||
|
||||
- Use **category nouns**: "notch app", "menu-bar monitor", "fan control utility",
|
||||
"battery charge limiter", "agent notification hack". These carry the same search
|
||||
intent and are fully compliant.
|
||||
- Compare against **the status quo, not a competitor**: "vs. a terminal bell you
|
||||
don't hear", "vs. four utilities in your menu bar", "vs. finding out an hour later."
|
||||
- The rule does not reach App Store–external review replies, direct email, and
|
||||
community replies written by a human, where quoting what a user said about another
|
||||
tool is normal conversation.
|
||||
|
||||
### 2. A claim needs a verdict, not just a test
|
||||
|
||||
Copy that says "every agent", "13 agents", "no data leaves the device" must point at
|
||||
both a test **and** a CTO verdict naming the exact sentence. A passing test is not a
|
||||
verdict; a verdict is not a recording. If a sentence cannot be traced to a row in the
|
||||
tables below, it does not go in copy.
|
||||
|
||||
### 3. Honest privacy scoping
|
||||
|
||||
Telemetry and the licence check **do** leave the device: Sentry runs on **every
|
||||
launch, unconditionally** (with the Mac's IP address via `sendDefaultPii`), and the
|
||||
trial check sends `IOPlatformUUID` and the device name to `freno.me` ([FRE-322]
|
||||
§2.4, corrected by [FRE-354] `(b)`). Never publish, in any wording:
|
||||
|
||||
- "no data leaves the device" / "local-only" / "no telemetry" / "no account"
|
||||
- "nothing leaves your Mac"
|
||||
|
||||
The one favourable engine sentence — about the **hook and bridge only**, never
|
||||
"The Nook" — as corrected by [FRE-354] `(j)`: the hook and bridge **are not a cloud
|
||||
service**. Do not write "talk to nothing but your Mac" (too absolute: the
|
||||
`NOOK_PORTS` fan-out resolves `host.docker.internal` and the default gateway, and
|
||||
`defaultGateway()` opens a no-packet UDP socket to `8.8.8.8:80`), and do not narrow
|
||||
it to "over SSH" (a tailnet peer dials directly).
|
||||
|
||||
It is verified as a code fact ([FRE-322] §2, [FRE-354] `(j)`), and it is still
|
||||
**held** until the disclosures land: Sentry gated behind a preference or removed,
|
||||
and `/privacy` + `/faq` corrected against the egress inventory (patch on branch
|
||||
`faq/from-objections-2026-10-29`, verified by [FRE-354]). A true sentence next to
|
||||
false ones is worse than no sentence.
|
||||
|
||||
### 4. Sell what works today
|
||||
|
||||
An iPhone + Apple Watch companion **is** in users' hands as a TestFlight beta
|
||||
(`ios-v0.3.2`, 2026-10-22; [FRE-354] open question). That makes one thing true and
|
||||
one thing forbidden: say "TestFlight beta" when the phone exists in a sentence at
|
||||
all, and never claim approvals-from-phone as a shipped capability without its own
|
||||
CTO verdict — no "(coming soon)" parentheticals either way.
|
||||
|
||||
Remote SSH is **held**, not approved: the one-click install has not
|
||||
been verified end to end ([FRE-315] §1) and the shipped path needs hand-edited
|
||||
`~/.ssh/config` and a one-time `ssh-copy-id` ([FRE-320]). Its copy stays parked in
|
||||
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow **and** a
|
||||
recording shows a remote agent joining the notch.
|
||||
|
||||
### 5. Humans hold every account
|
||||
|
||||
No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
|
||||
without disclosure. `approved/` **is the only thing a human ever publishes from** —
|
||||
nothing posts itself.
|
||||
|
||||
## Claims: status at 2026-11-03
|
||||
|
||||
Each row names the evidence, not a feeling. Re-read the verdict before using a row.
|
||||
|
||||
| Claim | Status | Evidence / next step |
|
||||
|---|---|---|
|
||||
| Approve an agent's tool call from the notch, and it carries on | **Needs demo** — not publishable | [FRE-322] §1. `AgentLiveTurnTests` has never run on a signed-in host; one observed local run settles it. Say "an agent", never "any agent", never "always". |
|
||||
| Every agent you're running reports into one notch; the blocked one pins above the rest | **Verified by code, gated on the take** | [FRE-322] §3. Same one-Mac take as above. No count in the sentence. |
|
||||
| The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
|
||||
| Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
|
||||
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
|
||||
| Agent coverage numbers ("eighteen agents", "18 agents") | **Publishable at 18** | [FRE-997]: the /agents table now lists every row of `AgentSpec.all` and `agents.test.ts` asserts equality with the 18-name registry in registry order, so the landing bullet, the marquee chip and the /agents copy say 18. No other number is publishable. |
|
||||
| macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. |
|
||||
| Remote agents over SSH, one click to install | **Held** | [FRE-315] §1, [FRE-320], [FRE-323]. Needs a verified one-click install plus a recording. |
|
||||
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
|
||||
| Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |
|
||||
|
||||
## Voice
|
||||
|
||||
- Short, concrete, no hype adjectives. Numbers over adjectives — the only count
|
||||
that ships is eighteen, and it is the registry's own size (see the coverage row
|
||||
above).
|
||||
- The notch is the product's home; agents are the product's point.
|
||||
- Never apologize for being a utility. The utility is the point.
|
||||
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
|
||||
"game-changer", "seamlessly", any unverifiable superlative — and the specific
|
||||
banned sentences in rule 3 and the table above.
|
||||
1
marketing/posted/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# posted
|
||||
25
package.json
@@ -1,36 +1,36 @@
|
||||
{
|
||||
"name": "example-with-trpc",
|
||||
"name": "freno.(dev/me)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vinxi dev",
|
||||
"dev-flush": "vinxi dev --env-file=.env",
|
||||
"build": "vinxi build",
|
||||
"start": "vinxi start",
|
||||
"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"
|
||||
"perf:compare": "bun run scripts/perf-compare.ts",
|
||||
"metrics:weekly": "bun --env-file=.env scripts/weekly-metrics.ts",
|
||||
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.953.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
||||
"@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",
|
||||
"@solidjs/start": "^1.1.0",
|
||||
"@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,13 +40,11 @@
|
||||
"@tiptap/extension-task-item": "^3.14.0",
|
||||
"@tiptap/extension-task-list": "^3.14.0",
|
||||
"@tiptap/extension-text-align": "^3.14.0",
|
||||
"@tiptap/extension-text-style": "^3.14.0",
|
||||
"@tiptap/pm": "^3.14.0",
|
||||
"@tiptap/starter-kit": "^3.14.0",
|
||||
"@trpc/client": "^10.45.2",
|
||||
"@trpc/server": "^10.45.2",
|
||||
"@tursodatabase/api": "^1.9.2",
|
||||
"@typeschema/valibot": "^0.13.4",
|
||||
"bcrypt": "^6.0.0",
|
||||
"es-toolkit": "^1.43.0",
|
||||
"fast-diff": "^1.3.0",
|
||||
@@ -55,7 +53,6 @@
|
||||
"jose": "^6.1.3",
|
||||
"mermaid": "^11.12.2",
|
||||
"motion": "^12.23.26",
|
||||
"redis": "^5.10.0",
|
||||
"solid-js": "^1.9.5",
|
||||
"solid-tiptap": "^0.8.0",
|
||||
"ua-parser-js": "^2.0.7",
|
||||
@@ -67,17 +64,11 @@
|
||||
"node": "24.x"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@sentry/vite-plugin": "^5.4.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/fast-diff": "^1.2.2",
|
||||
"chrome-launcher": "^1.2.1",
|
||||
"lighthouse": "^13.0.1",
|
||||
"playwright": "^1.57.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"trpc-panel": "^1.3.4",
|
||||
"vite-bundle-visualizer": "^1.2.1"
|
||||
"prettier-plugin-tailwindcss": "^0.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/InputHalo Exports/InputHalo-iOS-Dark-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/InputHalo Exports/InputHalo-iOS-Default-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/Nessa Exports/01-home-tab.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/Nessa Exports/04-plans-tab-strength.png
Normal file
|
After Width: | Height: | Size: 379 KiB |
BIN
public/Nessa Exports/09-clubs-list.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
public/Nessa Exports/29-apple-health.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
public/Nessa Exports/38-workout-summary.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
public/Nessa Exports/40-segment-list.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
public/Nessa Exports/Nessa-iOS-Dark-1024x1024.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/Nessa Exports/Nessa-iOS-Default-1024x1024.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/gaze/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/gaze/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/gaze/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 8.7 KiB |
17
public/gaze/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 5.9 MiB |
21
public/gaze/favicon/site.webmanifest
Executable file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
BIN
public/gaze/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/gaze/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 158 KiB |
BIN
public/inputhalo/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/inputhalo/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
public/inputhalo/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 6.5 KiB |
17
public/inputhalo/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 5.0 MiB |
21
public/inputhalo/favicon/site.webmanifest
Executable file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
BIN
public/inputhalo/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/inputhalo/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 109 KiB |
12
public/instrument.server.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as Sentry from "@sentry/solidstart";
|
||||
|
||||
Sentry.init({
|
||||
dsn: "https://a7c36d42c2a023ed29dd5db76c079566@o4506630160187392.ingest.us.sentry.io/4511784457666560",
|
||||
|
||||
dataCollection: {
|
||||
// To disable sending user data and HTTP bodies, uncomment the lines below. For more info visit:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/solidstart/configuration/options/#dataCollection
|
||||
// userInfo: false,
|
||||
// httpBodies: [],
|
||||
}
|
||||
});
|
||||
BIN
public/lineage/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/lineage/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/lineage/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 15 KiB |
3
public/lineage/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 860 KiB |
21
public/lineage/favicon/site.webmanifest
Executable file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
BIN
public/lineage/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/lineage/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 93 KiB |
BIN
public/nessa/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/nessa/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/nessa/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 1.9 KiB |
17
public/nessa/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 64 KiB |
21
public/nessa/favicon/site.webmanifest
Executable file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
BIN
public/nessa/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/nessa/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 8.3 KiB |
4
public/nook/brands/claude.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Official Claude mark — Anthropic favicon, https://claude.ai/favicon.svg -->
|
||||
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
4
public/nook/brands/codex.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Codex mark — official OpenAI Codex X (canonical path used by Cloudflare docs / lobe-icons / shadcn logos, all sourced from the OpenAI brand), white colorway for dark surfaces -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#FFFFFF" fill-rule="evenodd">
|
||||
<path d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/nook/cam-recording.mp4
Normal file
BIN
public/nook/demo-expansion.mp4
Normal file
BIN
public/nook/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/nook/favicon/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/nook/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
21
public/nook/favicon/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "The Nook",
|
||||
"short_name": "The Nook",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/nook/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/nook/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#4C9FBC",
|
||||
"background_color": "#4C9FBC",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
public/nook/favicon/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/nook/favicon/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/nook/icon.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
public/nook/og.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
@@ -1,9 +1,21 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Allow: /blog
|
||||
Allow: /projects
|
||||
Disallow: /login
|
||||
Disallow: /debug/
|
||||
|
||||
# Private / dynamic paths — not for indexing.
|
||||
# Keeps crawlers off auth, admin, and API pages so they don't burn
|
||||
# uncached SSR function invocations.
|
||||
Disallow: /account
|
||||
Disallow: /analytics
|
||||
Disallow: /api/
|
||||
Disallow: /blog/create
|
||||
Disallow: /blog/edit
|
||||
Disallow: /checkout
|
||||
Disallow: /databaseMGMT
|
||||
Disallow: /debug/
|
||||
Disallow: /error-test
|
||||
Disallow: /login
|
||||
Disallow: /success
|
||||
Disallow: /test
|
||||
|
||||
Sitemap: https://www.freno.me/sitemap.xml
|
||||
|
||||
21
scripts/backfill-downloads.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ConnectionFactory } from "~/server/db-connections";
|
||||
import { backfillDownloads } from "~/server/backfill-downloads";
|
||||
|
||||
// One-off: derive canonical /api/downloads/<asset> rows from download history
|
||||
// recorded before `trackDownload` existed. Idempotent — re-running adds
|
||||
// nothing, because derived rows carry a `backfill-<source id>` primary key.
|
||||
//
|
||||
// bun --env-file=.env scripts/backfill-downloads.ts
|
||||
|
||||
const result = await backfillDownloads(ConnectionFactory());
|
||||
|
||||
if (result.length === 0) {
|
||||
console.log("Nothing to backfill: no legacy download rows left to derive.");
|
||||
} else {
|
||||
for (const { asset, added } of result) {
|
||||
console.log(`${asset}: +${added}`);
|
||||
}
|
||||
console.log(
|
||||
`Total: +${result.reduce((sum, row) => sum + row.added, 0)} derived download rows.`
|
||||
);
|
||||
}
|
||||
45
scripts/generate-license-keys.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* One-shot The Nook license Ed25519 keypair generator.
|
||||
*
|
||||
* Run with: bun scripts/generate-license-keys.ts
|
||||
*
|
||||
* Prints:
|
||||
* - The Ed25519 PRIVATE key as base64 PKCS8 DER → NOOK_LICENSE_PRIVATE_KEY
|
||||
* (freno-dev env).
|
||||
* - The raw 32-byte Ed25519 public key (the X coordinate) as base64 SPKI DER
|
||||
* suffix → compiled into the Swift `LicenseVerifier.PublicKeyConstant`.
|
||||
*
|
||||
* The private key lives ONLY in env — never in git.
|
||||
* Paste the public key into Sources/NookCore/Licensing/LicenseVerifier.swift
|
||||
* (step 4 of the distribution plan) after running this once.
|
||||
*/
|
||||
import { generateKeyPairSync, createPrivateKey } from "node:crypto";
|
||||
|
||||
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
||||
|
||||
const privateKeyDer = privateKey.export({ format: "der", type: "pkcs8" });
|
||||
const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" });
|
||||
|
||||
// Raw 32-byte X coordinate: tail of the SPKI DER public key.
|
||||
const publicKeySpki = publicKey.export({ format: "der", type: "spki" });
|
||||
const rawPublic = publicKeySpki.subarray(-32);
|
||||
|
||||
console.log("── The Nook license keypair ──────────────────────────────");
|
||||
console.log("NOOK_LICENSE_PRIVATE_KEY (base64 PKCS8 DER):");
|
||||
console.log(privateKeyDer.toString("base64"));
|
||||
console.log("");
|
||||
console.log("Private key PEM (reference, for license signing only):");
|
||||
console.log(privateKeyPem);
|
||||
console.log("");
|
||||
console.log("LicenseVerifier public key base64 (raw 32-byte X, step 4):");
|
||||
console.log(rawPublic.toString("base64"));
|
||||
console.log("──────────────────────────────────────────────────────────");
|
||||
|
||||
// Sanity check: sign + verify round trip with the exported artifacts.
|
||||
const publicKeyFromPem = createPrivateKey(privateKeyPem)
|
||||
.export({ format: "der", type: "pkcs8" });
|
||||
if (Buffer.compare(Buffer.from(privateKeyDer), Buffer.from(publicKeyFromPem)) !== 0) {
|
||||
console.error("Keypair export sanity check failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Sanity check passed.");
|
||||
28
scripts/grant-nook-license.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { nookSchemaBootstrap, grantLicense } from "~/server/nook";
|
||||
|
||||
// Mint a free The Nook license (gifting / comps). Defaults to 1 device.
|
||||
//
|
||||
// bun --env-file=.env scripts/grant-nook-license.ts \
|
||||
// --email friend@example.com [--devices 1]
|
||||
|
||||
const arg = (name: string) => {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i === -1 ? undefined : process.argv[i + 1];
|
||||
};
|
||||
|
||||
const email = arg("email");
|
||||
const devices = Number(arg("devices") ?? "1");
|
||||
|
||||
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
console.error(
|
||||
"Usage: bun --env-file=.env scripts/grant-nook-license.ts --email you@example.com [--devices 1]"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await nookSchemaBootstrap;
|
||||
const { key } = await grantLicense(email, devices);
|
||||
|
||||
console.log(`Granted The Nook license for ${email} (${devices} device(s)):`);
|
||||
console.log(key);
|
||||
console.log("Send it to them; they enter it in Settings > License.");
|
||||
@@ -96,7 +96,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
"───────────────────────────────────────────────────────────────────\n"
|
||||
);
|
||||
|
||||
// Compare each page
|
||||
for (const baseResult of baseline.results) {
|
||||
const optResult = optimized.results.find((r) => r.page === baseResult.page);
|
||||
if (!optResult) continue;
|
||||
@@ -107,7 +106,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
console.log(`\n📄 ${baseResult.page}`);
|
||||
console.log("─".repeat(70));
|
||||
|
||||
// Core Web Vitals
|
||||
console.log("\n Core Web Vitals:");
|
||||
|
||||
const fcpDiff = opt.fcp - base.fcp;
|
||||
@@ -121,7 +119,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})`
|
||||
);
|
||||
|
||||
// Loading Metrics
|
||||
console.log("\n Loading Metrics:");
|
||||
|
||||
const ttfbDiff = opt.ttfb - base.ttfb;
|
||||
@@ -148,7 +145,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}`
|
||||
);
|
||||
|
||||
// Resource Loading
|
||||
console.log("\n Resources:");
|
||||
|
||||
const reqDiff = opt.totalRequests - base.totalRequests;
|
||||
@@ -185,7 +181,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
);
|
||||
}
|
||||
|
||||
// Overall Summary
|
||||
console.log(
|
||||
"\n\n═══════════════════════════════════════════════════════════════════"
|
||||
);
|
||||
@@ -338,7 +333,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
);
|
||||
}
|
||||
|
||||
// Specific findings
|
||||
const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests);
|
||||
if (reqPercent < -5) {
|
||||
console.log(
|
||||
|
||||
@@ -61,7 +61,6 @@ const BASE_URL = process.env.TEST_URL || "http://localhost:3000";
|
||||
const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10);
|
||||
const WARMUP_RUNS = 1;
|
||||
|
||||
// Pages to test
|
||||
const TEST_PAGES: PageTestConfig[] = [
|
||||
{ name: "Home", path: "/" },
|
||||
{ name: "Blog Index", path: "/blog" },
|
||||
@@ -78,7 +77,6 @@ const TEST_PAGES: PageTestConfig[] = [
|
||||
{ name: "404", path: "/404" }
|
||||
];
|
||||
|
||||
// Add additional blog post path if provided
|
||||
if (process.env.TEST_BLOG_POST) {
|
||||
TEST_PAGES.push({
|
||||
name: "Custom Blog Post",
|
||||
@@ -102,7 +100,6 @@ async function setupPerformanceObservers(page: Page) {
|
||||
interactions: [] as number[]
|
||||
};
|
||||
|
||||
// Observe LCP
|
||||
if ("PerformanceObserver" in window) {
|
||||
try {
|
||||
const lcpObserver = new PerformanceObserver((entryList) => {
|
||||
@@ -118,10 +115,8 @@ async function setupPerformanceObservers(page: Page) {
|
||||
buffered: true
|
||||
});
|
||||
} catch (e) {
|
||||
// LCP not supported
|
||||
}
|
||||
|
||||
// Observe CLS
|
||||
try {
|
||||
const clsObserver = new PerformanceObserver((entryList) => {
|
||||
for (const entry of entryList.getEntries()) {
|
||||
@@ -138,10 +133,8 @@ async function setupPerformanceObservers(page: Page) {
|
||||
});
|
||||
clsObserver.observe({ type: "layout-shift", buffered: true });
|
||||
} catch (e) {
|
||||
// CLS not supported
|
||||
}
|
||||
|
||||
// Observe FID (first input)
|
||||
try {
|
||||
const fidObserver = new PerformanceObserver((entryList) => {
|
||||
const firstInput = entryList.getEntries()[0] as any;
|
||||
@@ -154,10 +147,8 @@ async function setupPerformanceObservers(page: Page) {
|
||||
});
|
||||
fidObserver.observe({ type: "first-input", buffered: true });
|
||||
} catch (e) {
|
||||
// FID not supported
|
||||
}
|
||||
|
||||
// Observe long tasks
|
||||
try {
|
||||
const longTaskObserver = new PerformanceObserver((entryList) => {
|
||||
for (const entry of entryList.getEntries()) {
|
||||
@@ -166,10 +157,8 @@ async function setupPerformanceObservers(page: Page) {
|
||||
});
|
||||
longTaskObserver.observe({ type: "longtask", buffered: true });
|
||||
} catch (e) {
|
||||
// Long tasks not supported
|
||||
}
|
||||
|
||||
// Observe INP (event timing for interactions)
|
||||
try {
|
||||
const inpObserver = new PerformanceObserver((entryList) => {
|
||||
for (const entry of entryList.getEntries()) {
|
||||
@@ -194,7 +183,6 @@ async function setupPerformanceObservers(page: Page) {
|
||||
});
|
||||
inpObserver.observe({ type: "event", buffered: true });
|
||||
} catch (e) {
|
||||
// Event timing not supported
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -203,18 +191,14 @@ async function setupPerformanceObservers(page: Page) {
|
||||
async function collectPerformanceMetrics(
|
||||
page: Page
|
||||
): Promise<PerformanceMetrics> {
|
||||
// Wait for page to be loaded
|
||||
await page.waitForLoadState("load");
|
||||
|
||||
// Wait a bit longer for LCP to settle (it can change as content loads)
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Additional wait for any remaining network activity
|
||||
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {
|
||||
// Ignore timeout - networkidle may never happen for some pages
|
||||
});
|
||||
|
||||
// Collect comprehensive performance metrics
|
||||
const metrics = await page.evaluate(() => {
|
||||
const perf = performance.getEntriesByType(
|
||||
"navigation"
|
||||
@@ -222,7 +206,6 @@ async function collectPerformanceMetrics(
|
||||
const paint = performance.getEntriesByType("paint");
|
||||
const fcp = paint.find((entry) => entry.name === "first-contentful-paint");
|
||||
|
||||
// Get metrics from our observers
|
||||
const observedMetrics = (window as any).__perfMetrics || {
|
||||
lcp: 0,
|
||||
cls: 0,
|
||||
@@ -232,7 +215,6 @@ async function collectPerformanceMetrics(
|
||||
interactions: []
|
||||
};
|
||||
|
||||
// Fallback to direct API if observers didn't capture anything
|
||||
let lcp = observedMetrics.lcp;
|
||||
let cls = observedMetrics.cls;
|
||||
let fid = observedMetrics.fid;
|
||||
@@ -258,7 +240,6 @@ async function collectPerformanceMetrics(
|
||||
.reduce((sum: number, entry: any) => sum + entry.value, 0);
|
||||
}
|
||||
|
||||
// Calculate INP from event timing entries if not already captured
|
||||
if (inp === 0) {
|
||||
const eventEntries = performance.getEntriesByType("event") as any[];
|
||||
const interactionLatencies = eventEntries
|
||||
@@ -275,7 +256,6 @@ async function collectPerformanceMetrics(
|
||||
}
|
||||
}
|
||||
|
||||
// Get resource timing
|
||||
const resources = performance.getEntriesByType(
|
||||
"resource"
|
||||
) as PerformanceResourceTiming[];
|
||||
@@ -318,7 +298,6 @@ async function collectPerformanceMetrics(
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate long task duration
|
||||
let taskDuration = 0;
|
||||
if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) {
|
||||
taskDuration = observedMetrics.longTasks.reduce(
|
||||
@@ -327,7 +306,6 @@ async function collectPerformanceMetrics(
|
||||
);
|
||||
}
|
||||
|
||||
// Get more granular performance entries
|
||||
let jsExecutionTime = 0;
|
||||
let layoutDuration = 0;
|
||||
let paintDuration = 0;
|
||||
@@ -339,7 +317,6 @@ async function collectPerformanceMetrics(
|
||||
}
|
||||
});
|
||||
|
||||
// Check for script evaluation entries
|
||||
const entries = performance.getEntries();
|
||||
entries.forEach((entry: any) => {
|
||||
if (entry.entryType === "measure") {
|
||||
@@ -399,7 +376,6 @@ async function testPagePerformance(
|
||||
` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n`
|
||||
);
|
||||
|
||||
// Warmup runs (not counted)
|
||||
for (let i = 0; i < WARMUP_RUNS; i++) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
@@ -410,20 +386,16 @@ async function testPagePerformance(
|
||||
console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`);
|
||||
}
|
||||
|
||||
// Measured runs
|
||||
for (let i = 0; i < RUNS_PER_PAGE; i++) {
|
||||
console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`);
|
||||
|
||||
// Create new context for each run to ensure clean state
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Setup performance observers before navigation
|
||||
await setupPerformanceObservers(page);
|
||||
|
||||
// Navigate and collect metrics
|
||||
await page.goto(url, { waitUntil: "load" });
|
||||
const metrics = await collectPerformanceMetrics(page);
|
||||
|
||||
@@ -436,7 +408,6 @@ async function testPagePerformance(
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const average = calculateAverage(runs);
|
||||
const median = calculateMedian(runs);
|
||||
const p95 = calculatePercentile(runs, 95);
|
||||
@@ -648,7 +619,6 @@ function printResults(results: TestResult[]) {
|
||||
"═══════════════════════════════════════════════════════════════════\n"
|
||||
);
|
||||
|
||||
// Overall averages
|
||||
const overallAverage = {
|
||||
lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length,
|
||||
fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length,
|
||||
@@ -696,7 +666,6 @@ function printResults(results: TestResult[]) {
|
||||
|
||||
console.log("\n Optimization Opportunities:");
|
||||
|
||||
// Find pages with highest JS bytes
|
||||
const highestJS = [...results].sort(
|
||||
(a, b) => b.median.jsBytes - a.median.jsBytes
|
||||
)[0];
|
||||
@@ -707,7 +676,6 @@ function printResults(results: TestResult[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Find pages with slow LCP
|
||||
const slowLCP = results.filter((r) => r.median.lcp > 2500);
|
||||
if (slowLCP.length > 0) {
|
||||
console.log(
|
||||
@@ -715,7 +683,6 @@ function printResults(results: TestResult[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Find pages with high CLS
|
||||
const highCLS = results.filter((r) => r.median.cls > 0.1);
|
||||
if (highCLS.length > 0) {
|
||||
console.log(
|
||||
@@ -723,7 +690,6 @@ function printResults(results: TestResult[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Find pages with high INP
|
||||
const highINP = results.filter((r) => r.median.inp > 200);
|
||||
if (highINP.length > 0) {
|
||||
console.log(
|
||||
@@ -740,7 +706,6 @@ async function main() {
|
||||
console.log(`Pages to test: ${TEST_PAGES.length}`);
|
||||
console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`);
|
||||
|
||||
// Check if server is running
|
||||
try {
|
||||
const response = await fetch(BASE_URL);
|
||||
if (!response.ok) {
|
||||
@@ -770,10 +735,8 @@ async function main() {
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Print results
|
||||
printResults(results);
|
||||
|
||||
// Save results to JSON file
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
|
||||
38
scripts/upload-appcast.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// One-off: upload the backfilled appcast to the download bucket.
|
||||
// Usage: cd ~/Code/freno-dev && bun --env-file=.env /tmp/upload-appcast.ts <file>
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const [, , file] = process.argv;
|
||||
if (!file) {
|
||||
console.error("usage: bun --env-file=.env upload-appcast.ts <appcast.xml>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const body = readFileSync(file);
|
||||
const xml = body.toString("utf8");
|
||||
// Guard: refuse to upload without all three release descriptions present.
|
||||
for (const v of ["0.6.0", "0.6.2", "0.6.3"]) {
|
||||
if (!xml.includes(`sparkle:shortVersionString>${v}<`)) {
|
||||
console.error(`refusing: item for ${v} missing`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new S3Client({
|
||||
region: process.env.AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: process.env.MY_AWS_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.MY_AWS_SECRET_KEY!
|
||||
}
|
||||
});
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: process.env.VITE_DOWNLOAD_BUCKET_STRING,
|
||||
Key: "api/TheNook/appcast.xml",
|
||||
Body: body,
|
||||
ContentType: "application/xml; charset=utf-8"
|
||||
})
|
||||
);
|
||||
console.log("uploaded", file, "->", "api/TheNook/appcast.xml");
|
||||
259
scripts/verify-sparkle-dual-host.sh
Executable file
@@ -0,0 +1,259 @@
|
||||
#!/bin/bash
|
||||
# Sparkle Appcast Dual-Host Verification Script
|
||||
#
|
||||
# Verifies that Sparkle appcast and DMG endpoints work from both
|
||||
# freno.me (legacy) and subdomain hosts (new)
|
||||
#
|
||||
# Usage: ./scripts/verify-sparkle-dual-host.sh [product]
|
||||
# product: Gaze | InputHalo | all (default: all)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
BASE_URL="https://freno.me"
|
||||
SUBDOMAINS=(
|
||||
"gaze:freno.me"
|
||||
"inputhalo:freno.me"
|
||||
"nessa:freno.me"
|
||||
"lineage:freno.me"
|
||||
)
|
||||
|
||||
# Latest versions from appcast (update as needed)
|
||||
LATEST_GAZE_VERSION="0.7.8"
|
||||
LATEST_INPUTHALO_VERSION="0.5.2"
|
||||
|
||||
# Counters
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
# Helper functions
|
||||
log_pass() {
|
||||
echo -e "${GREEN}✓ PASS${NC}: $1"
|
||||
((PASS++))
|
||||
}
|
||||
|
||||
log_fail() {
|
||||
echo -e "${RED}✗ FAIL${NC}: $1"
|
||||
((FAIL++))
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}! WARN${NC}: $1"
|
||||
((WARN++))
|
||||
}
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}ℹ INFO${NC}: $1"
|
||||
}
|
||||
|
||||
# Check appcast endpoint
|
||||
check_appcast() {
|
||||
local product=$1
|
||||
local host=$2
|
||||
local url="https://${host}/api/${product}/appcast.xml"
|
||||
|
||||
log_info "Checking appcast for ${product} on ${host}..."
|
||||
|
||||
# Test 1: HTTP status
|
||||
local status=$(curl -sI -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
||||
if [ "$status" = "200" ]; then
|
||||
log_pass "${product} appcast on ${host}: HTTP 200"
|
||||
else
|
||||
log_fail "${product} appcast on ${host}: HTTP ${status} (expected 200)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Test 2: Content-Type header
|
||||
local content_type=$(curl -sI "$url" 2>/dev/null | grep -i "content-type" | tr -d '\r' | awk '{print $2}')
|
||||
if [[ "$content_type" == *"application/xml"* ]]; then
|
||||
log_pass "${product} appcast on ${host}: Correct Content-Type"
|
||||
else
|
||||
log_fail "${product} appcast on ${host}: Wrong Content-Type: ${content_type}"
|
||||
fi
|
||||
|
||||
# Test 3: Cache-Control header
|
||||
local cache_control=$(curl -sI "$url" 2>/dev/null | grep -i "cache-control" | tr -d '\r' | awk '{print $2}')
|
||||
if [[ "$cache_control" == *"max-age=300"* ]]; then
|
||||
log_pass "${product} appcast on ${host}: Correct Cache-Control"
|
||||
else
|
||||
log_fail "${product} appcast on ${host}: Wrong Cache-Control: ${cache_control}"
|
||||
fi
|
||||
|
||||
# Test 4: CORS header
|
||||
local cors=$(curl -sI "$url" 2>/dev/null | grep -i "access-control-allow-origin" | tr -d '\r' | awk '{print $2}')
|
||||
if [ "$cors" = "*" ]; then
|
||||
log_pass "${product} appcast on ${host}: CORS header present"
|
||||
else
|
||||
log_fail "${product} appcast on ${host}: Missing CORS header"
|
||||
fi
|
||||
|
||||
# Test 5: Valid XML
|
||||
local xml=$(curl -s "$url" 2>/dev/null)
|
||||
if echo "$xml" | xmllint --noout - 2>/dev/null; then
|
||||
log_pass "${product} appcast on ${host}: Valid XML"
|
||||
else
|
||||
log_fail "${product} appcast on ${host}: Invalid XML"
|
||||
fi
|
||||
|
||||
# Test 6: Check for absolute enclosure URLs
|
||||
if echo "$xml" | grep -q 'enclosure url="https://freno\.me/api/downloads/'; then
|
||||
log_pass "${product} appcast on ${host}: Uses absolute freno.me enclosure URLs"
|
||||
else
|
||||
log_fail "${product} appcast on ${host}: Missing absolute enclosure URLs"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Compare appcast between hosts
|
||||
compare_appcast() {
|
||||
local product=$1
|
||||
local base_host="freno.me"
|
||||
local subdomain_host=$2
|
||||
|
||||
log_info "Comparing ${product} appcast between ${base_host} and ${subdomain_host}..."
|
||||
|
||||
local base_xml=$(curl -s "https://${base_host}/api/${product}/appcast.xml" 2>/dev/null)
|
||||
local subdomain_xml=$(curl -s "https://${subdomain_host}/api/${product}/appcast.xml" 2>/dev/null)
|
||||
|
||||
if [ "$base_xml" = "$subdomain_xml" ]; then
|
||||
log_pass "${product} appcast: Byte-identical between ${base_host} and ${subdomain_host}"
|
||||
else
|
||||
log_fail "${product} appcast: Content differs between ${base_host} and ${subdomain_host}"
|
||||
echo "$base_xml" > /tmp/base-appcast.xml
|
||||
echo "$subdomain_xml" > /tmp/subdomain-appcast.xml
|
||||
echo "Differences saved to /tmp/base-appcast.xml and /tmp/subdomain-appcast.xml"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Check DMG download endpoint
|
||||
check_dmg_download() {
|
||||
local product=$1
|
||||
local host=$2
|
||||
local version=$3
|
||||
local filename="${product}-${version}.dmg"
|
||||
local url="https://${host}/api/downloads/${filename}"
|
||||
|
||||
log_info "Checking DMG download for ${product} on ${host} (${filename})..."
|
||||
|
||||
# Test HTTP status (just head request for speed)
|
||||
local status=$(curl -sI -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
||||
if [ "$status" = "200" ]; then
|
||||
log_pass "${product} DMG on ${host}: HTTP 200"
|
||||
else
|
||||
log_fail "${product} DMG on ${host}: HTTP ${status} (expected 200)"
|
||||
fi
|
||||
|
||||
# Test Content-Type
|
||||
local content_type=$(curl -sI "$url" 2>/dev/null | grep -i "content-type" | tr -d '\r' | awk '{print $2}')
|
||||
if [[ "$content_type" == *"apple-diskimage"* ]] || [[ "$content_type" == *"octet-stream"* ]]; then
|
||||
log_pass "${product} DMG on ${host}: Correct Content-Type"
|
||||
else
|
||||
log_fail "${product} DMG on ${host}: Wrong Content-Type: ${content_type}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Main verification
|
||||
main() {
|
||||
local product=${1:-"all"}
|
||||
|
||||
echo "========================================="
|
||||
echo "Sparkle Appcast Dual-Host Verification"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "Base URL: ${BASE_URL}"
|
||||
echo "Product: ${product}"
|
||||
echo "Date: $(date)"
|
||||
echo ""
|
||||
echo "-----------------------------------------"
|
||||
echo "1. Checking Appcast Endpoints"
|
||||
echo "-----------------------------------------"
|
||||
echo ""
|
||||
|
||||
# Check Gaze appcast on all relevant hosts
|
||||
if [ "$product" = "all" ] || [ "$product" = "Gaze" ]; then
|
||||
check_appcast "Gaze" "freno.me"
|
||||
check_appcast "Gaze" "gaze.freno.me"
|
||||
compare_appcast "Gaze" "gaze.freno.me"
|
||||
fi
|
||||
|
||||
# Check InputHalo appcast on all relevant hosts
|
||||
if [ "$product" = "all" ] || [ "$product" = "InputHalo" ]; then
|
||||
check_appcast "InputHalo" "freno.me"
|
||||
check_appcast "InputHalo" "inputhalo.freno.me"
|
||||
compare_appcast "InputHalo" "inputhalo.freno.me"
|
||||
fi
|
||||
|
||||
echo "-----------------------------------------"
|
||||
echo "2. Checking DMG Download Endpoints"
|
||||
echo "-----------------------------------------"
|
||||
echo ""
|
||||
|
||||
# Check all subdomain hosts for DMG downloads
|
||||
for subdomain_entry in "${SUBDOMAINS[@]}"; do
|
||||
local subdomain_host="${subdomain_entry//:/}"
|
||||
subdomain_host="${subdomain_host//:/}.freno.me"
|
||||
|
||||
if [ "$product" = "all" ] || [ "$product" = "Gaze" ]; then
|
||||
check_dmg_download "Gaze" "$subdomain_host" "$LATEST_GAZE_VERSION"
|
||||
fi
|
||||
|
||||
if [ "$product" = "all" ] || [ "$product" = "InputHalo" ]; then
|
||||
check_dmg_download "InputHalo" "$subdomain_host" "$LATEST_INPUTHALO_VERSION"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "-----------------------------------------"
|
||||
echo "3. Checking vercel.json Rewrite Ordering"
|
||||
echo "-----------------------------------------"
|
||||
echo ""
|
||||
|
||||
# Check that /api/* pass-throughs come before catch-all rewrites
|
||||
local api_rewrites=$(grep -n "source.*api" vercel.json | head -4 | wc -l)
|
||||
local catchall_rewrites=$(grep -n "source.*\(.*)$" vercel.json | grep -v "api" | wc -l)
|
||||
|
||||
if [ "$api_rewrites" -eq 4 ]; then
|
||||
log_pass "Found ${api_rewrites} /api/* pass-through rules"
|
||||
else
|
||||
log_fail "Expected 4 /api/* pass-through rules, found ${api_rewrites}"
|
||||
fi
|
||||
|
||||
if [ "$catchall_rewrites" -eq 4 ]; then
|
||||
log_pass "Found ${catchall_rewrites} catch-all subdomain rewrite rules"
|
||||
else
|
||||
log_fail "Expected 4 catch-all subdomain rewrite rules, found ${catchall_rewrites}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "-----------------------------------------"
|
||||
echo "4. Summary"
|
||||
echo "-----------------------------------------"
|
||||
echo ""
|
||||
echo -e " ${GREEN}PASSED: ${PASS}${NC}"
|
||||
echo -e " ${RED}FAILED: ${FAIL}${NC}"
|
||||
echo -e " ${YELLOW}WARNED: ${WARN}${NC}"
|
||||
echo ""
|
||||
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo -e "${GREEN}All checks passed! Dual-host support is working correctly.${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}Some checks failed. Please review the output above.${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main
|
||||
main "$@"
|
||||
80
scripts/verify-subdomains.sh
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify subdomain DNS propagation, SSL, and rewrite routing
|
||||
# Run after DNS CNAMEs are added and Vercel domains are configured.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SUBDOMAINS=("nessa" "lineage" "gaze" "inputhalo")
|
||||
APICAST_ROUTES=("Gaze" "InputHalo")
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
check() {
|
||||
local desc="$1"
|
||||
shift
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
echo "✓ $desc"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ $desc"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== DNS CNAME propagation ==="
|
||||
for sub in "${SUBDOMAINS[@]}"; do
|
||||
cname=$(dig +short "$sub.freno.me" 2>/dev/null | grep -i "cname.vercel-dns.com" || true)
|
||||
if [[ -n "$cname" ]]; then
|
||||
check "$sub.freno.me CNAME → cname.vercel-dns.com" true
|
||||
else
|
||||
check "$sub.freno.me CNAME → cname.vercel-dns.com" false
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== HTTPS landing pages ==="
|
||||
for sub in "${SUBDOMAINS[@]}"; do
|
||||
status=$(curl -sI -o /dev/null -w "%{http_code}" "https://${sub}.freno.me/" 2>/dev/null || echo "000")
|
||||
if [[ "$status" == "200" ]]; then
|
||||
check "https://${sub}.freno.me/ → 200" true
|
||||
else
|
||||
check "https://${sub}.freno.me/ → 200 (got ${status})" false
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Appcast regression (freno.me) ==="
|
||||
for route in "${APICAST_ROUTES[@]}"; do
|
||||
status=$(curl -sI -o /dev/null -w "%{http_code}" "https://freno.me/api/${route}/appcast.xml" 2>/dev/null || echo "000")
|
||||
if [[ "$status" == "200" ]]; then
|
||||
check "https://freno.me/api/${route}/appcast.xml → 200" true
|
||||
else
|
||||
check "https://freno.me/api/${route}/appcast.xml → 200 (got ${status})" false
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Main site regression ==="
|
||||
status=$(curl -sI -o /dev/null -w "%{http_code}" "https://freno.me/" 2>/dev/null || echo "000")
|
||||
if [[ "$status" == "200" ]]; then
|
||||
check "https://freno.me/ → 200" true
|
||||
else
|
||||
check "https://freno.me/ → 200 (got ${status})" false
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== SSL certificates ==="
|
||||
for sub in "${SUBDOMAINS[@]}"; do
|
||||
if echo | openssl s_client -connect "${sub}.freno.me:443" -servername "${sub}.freno.me" 2>/dev/null | \
|
||||
openssl x509 -noout -checkend 0 2>/dev/null | grep -q "not expired"; then
|
||||
check "${sub}.freno.me SSL valid" true
|
||||
else
|
||||
check "${sub}.freno.me SSL valid" false
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Summary: ${PASS} passed, ${FAIL} failed ==="
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
159
scripts/weekly-metrics.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { createClient } from "@libsql/client/web";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import {
|
||||
ensureAnalyticsSchema,
|
||||
getWeeklyReport,
|
||||
renderWeeklyMarkdown,
|
||||
type Connections
|
||||
} from "~/server/weekly-metrics";
|
||||
|
||||
/**
|
||||
* Write `marketing/metrics/weekly.md` from the live analytics and licence DBs.
|
||||
*
|
||||
* bun --env-file=.env scripts/weekly-metrics.ts
|
||||
* bun --env-file=.env scripts/weekly-metrics.ts --days 14 --now 2026-11-05T09:00:00Z
|
||||
*
|
||||
* Required environment (all four; the analytics token also opens the licence
|
||||
* DB, which is why the weekly job can run from a checkout that has no Stripe
|
||||
* or Apple secrets):
|
||||
*
|
||||
* TURSO_DB_URL / TURSO_DB_TOKEN freno.me DB — VisitorAnalytics
|
||||
* NOOK_DB_URL / NOOK_DB_TOKEN the-nook DB — trials, licenses, activations
|
||||
*
|
||||
* This script deliberately does not import `~/env/server`: that schema demands
|
||||
* every production secret, and the metrics job needs two databases and nothing
|
||||
* else.
|
||||
*
|
||||
* The output path is anchored at the repo root, so running it from any cwd
|
||||
* writes the same file.
|
||||
*/
|
||||
|
||||
interface Options {
|
||||
days: number;
|
||||
now: Date;
|
||||
out: string;
|
||||
stdout: boolean;
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage: bun --env-file=.env scripts/weekly-metrics.ts [options]",
|
||||
"",
|
||||
" --days N window length in days (default 7)",
|
||||
" --now ISO end of the window (default: now)",
|
||||
" --out PATH where to write (default: marketing/metrics/weekly.md)",
|
||||
" --stdout print the report instead of writing a file"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Options {
|
||||
const repoRoot = resolve(import.meta.dir, "..");
|
||||
const options: Options = {
|
||||
days: 7,
|
||||
now: new Date(),
|
||||
out: resolve(repoRoot, "marketing/metrics/weekly.md"),
|
||||
stdout: false
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]!;
|
||||
const value = () => {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) throw new Error(`${arg} needs a value\n\n${usage()}`);
|
||||
i += 1;
|
||||
return next;
|
||||
};
|
||||
|
||||
switch (arg) {
|
||||
case "--days": {
|
||||
const days = Number(value());
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
throw new Error(`--days must be a positive number\n\n${usage()}`);
|
||||
}
|
||||
options.days = days;
|
||||
break;
|
||||
}
|
||||
case "--now": {
|
||||
const now = new Date(value());
|
||||
if (Number.isNaN(now.getTime())) {
|
||||
throw new Error(`--now must be an ISO timestamp\n\n${usage()}`);
|
||||
}
|
||||
options.now = now;
|
||||
break;
|
||||
}
|
||||
case "--out":
|
||||
options.out = resolve(value());
|
||||
break;
|
||||
case "--stdout":
|
||||
options.stdout = true;
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
default:
|
||||
throw new Error(`Unknown argument ${arg}\n\n${usage()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error(
|
||||
`${name} is not set. The weekly job needs TURSO_DB_URL, TURSO_DB_TOKEN, ` +
|
||||
`NOOK_DB_URL and NOOK_DB_TOKEN — run it as ` +
|
||||
`\`bun --env-file=.env scripts/weekly-metrics.ts\` with those four in .env.`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function connect(url: string, token: string) {
|
||||
return createClient({ url, authToken: token });
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
const connections: Connections = {
|
||||
analytics: connect(requiredEnv("TURSO_DB_URL"), requiredEnv("TURSO_DB_TOKEN")),
|
||||
nook: connect(requiredEnv("NOOK_DB_URL"), requiredEnv("NOOK_DB_TOKEN"))
|
||||
};
|
||||
|
||||
// The app migrates the table on boot, but this job must not depend on a
|
||||
// deploy having happened: add any missing post-hoc columns (stage, host)
|
||||
// through the same idempotent bootstrap path first.
|
||||
await ensureAnalyticsSchema(connections.analytics);
|
||||
|
||||
const report = await getWeeklyReport(connections, {
|
||||
days: options.days,
|
||||
now: options.now
|
||||
});
|
||||
const markdown = renderWeeklyMarkdown(report);
|
||||
|
||||
if (options.stdout) {
|
||||
process.stdout.write(markdown);
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(dirname(options.out), { recursive: true });
|
||||
await writeFile(options.out, markdown, "utf8");
|
||||
|
||||
const f = report.counts;
|
||||
console.log(
|
||||
`wrote ${options.out}\n` +
|
||||
`window ${report.window.start} → ${report.window.end}\n` +
|
||||
`visit ${f.visit}, download click ${f.downloadClick}, DMG served ${f.dmgServed}, ` +
|
||||
`trial ${f.trial}, checkout ${f.checkout}, purchase ${f.purchase}, activation ${f.activation}\n` +
|
||||
`mover: ${report.mover ? `${report.mover.label} ${report.mover.before} → ${report.mover.now}` : "none"}`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
306
src/app.css
@@ -74,6 +74,11 @@
|
||||
--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) {
|
||||
@@ -104,6 +109,11 @@
|
||||
--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;
|
||||
@@ -163,6 +173,8 @@
|
||||
--color-base: #fbf1c7;
|
||||
--color-mantle: #f3eac1;
|
||||
--color-crust: #e7deb7;
|
||||
--color-button-text: #ffffff;
|
||||
--color-button-text-alt: var(--color-text);
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
@@ -192,6 +204,8 @@
|
||||
--color-base: #1e1e2e;
|
||||
--color-mantle: #141620;
|
||||
--color-crust: #0e0f16;
|
||||
--color-button-text: var(--color-crust);
|
||||
--color-button-text-alt: #ffffff;
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -560,3 +574,295 @@ 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; }
|
||||
}
|
||||
|
||||
/* ── Nook landing: newer demos ─────────────────────────────────── */
|
||||
.nook-caret {
|
||||
animation: nook-pulse 0.8s steps(2, jump-none) infinite;
|
||||
}
|
||||
|
||||
/* Live signal inside a mixer fader */
|
||||
.nook-meter {
|
||||
transform-origin: left center;
|
||||
animation: nook-meter 0.6s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes nook-meter {
|
||||
0% { transform: scaleX(0.35); }
|
||||
40% { transform: scaleX(0.8); }
|
||||
100% { transform: scaleX(0.55); }
|
||||
}
|
||||
|
||||
/* Waveform slot bars */
|
||||
.nook-wave {
|
||||
height: 100%;
|
||||
transform-origin: center;
|
||||
animation: nook-wave 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes nook-wave {
|
||||
0% { transform: scaleY(0.25); }
|
||||
100% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
/* Now Playing progress and artwork sheen */
|
||||
.nook-progress {
|
||||
animation: nook-progress 14s linear infinite;
|
||||
}
|
||||
@keyframes nook-progress {
|
||||
from { width: 8%; }
|
||||
to { width: 100%; }
|
||||
}
|
||||
.nook-art {
|
||||
animation: nook-art 9s linear infinite;
|
||||
}
|
||||
@keyframes nook-art {
|
||||
to { filter: hue-rotate(360deg); }
|
||||
}
|
||||
|
||||
/* A ringing timer's tape breathes between 35 % and full */
|
||||
.nook-tape-ring {
|
||||
animation: nook-tape-ring 0.9s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes nook-tape-ring {
|
||||
from { opacity: 0.35; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Keep Awake coffee steam */
|
||||
.nook-steam path {
|
||||
animation: nook-steam 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes nook-steam {
|
||||
0% { transform: translateY(2px); opacity: 0; }
|
||||
40% { opacity: 1; }
|
||||
100% { transform: translateY(-4px); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Two-way feature marquee; edges fade, hover pauses */
|
||||
.nook-marquee {
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
|
||||
}
|
||||
.nook-marquee-track {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
width: max-content;
|
||||
padding-right: 0.625rem;
|
||||
animation: nook-marquee 60s linear infinite;
|
||||
}
|
||||
.nook-marquee-track.reverse {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
.nook-marquee:hover .nook-marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
@keyframes nook-marquee {
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nook-meter,
|
||||
.nook-wave,
|
||||
.nook-progress,
|
||||
.nook-art,
|
||||
.nook-tape-ring,
|
||||
.nook-steam path,
|
||||
.nook-caret {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.nook-marquee {
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
.nook-marquee-track {
|
||||
animation: none;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.nook-marquee-track > [aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
108
src/app.tsx
@@ -5,16 +5,19 @@ import {
|
||||
ErrorBoundary,
|
||||
onMount,
|
||||
onCleanup,
|
||||
Show,
|
||||
Suspense
|
||||
} from "solid-js";
|
||||
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";
|
||||
import { DarkModeProvider } from "./context/darkMode";
|
||||
import { AuthProvider } from "./context/auth";
|
||||
import { SiteProvider, useSite } from "./context/SiteContext";
|
||||
import { createWindowWidth, isMobile } from "~/lib/resize-utils";
|
||||
import { MOBILE_CONFIG } from "./config";
|
||||
import CustomScrollbar from "./components/CustomScrollbar";
|
||||
@@ -32,10 +35,8 @@ function AppLayout(props: { children: any }) {
|
||||
let lastScrollY = 0;
|
||||
|
||||
onMount(() => {
|
||||
// Initialize performance tracking
|
||||
initPerformanceTracking();
|
||||
|
||||
// Start monitoring for new deployments
|
||||
startDeploymentMonitoring();
|
||||
|
||||
const windowWidth = createWindowWidth();
|
||||
@@ -154,42 +155,69 @@ function AppLayout(props: { children: any }) {
|
||||
}
|
||||
};
|
||||
|
||||
const site = useSite();
|
||||
const isMainSite = () => site().id === "main";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="flex max-w-screen flex-row overflow-x-hidden">
|
||||
<LeftBar />
|
||||
<div
|
||||
id="center-body"
|
||||
class="bg-base relative h-screen w-screen overflow-x-hidden md:ml-62.5 md:w-[calc(100vw-500px)]"
|
||||
>
|
||||
<Show when={isMainSite()}>
|
||||
<div class="flex max-w-screen flex-row overflow-x-hidden">
|
||||
<LeftBar />
|
||||
<div
|
||||
id="center-body"
|
||||
class="bg-base relative h-screen w-screen overflow-x-hidden md:ml-[250px] md:w-[calc(100vw-500px)]"
|
||||
>
|
||||
<noscript>
|
||||
<div class="bg-yellow text-crust border-text fixed top-0 z-150 border-b-2 p-4 text-center font-semibold md:w-[calc(100vw-500px)]">
|
||||
JavaScript is disabled. Features will be limited.
|
||||
</div>
|
||||
</noscript>
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorBoundaryFallback error={error} reset={reset} />
|
||||
)}
|
||||
>
|
||||
<div
|
||||
onMouseUp={handleCenterTapRelease}
|
||||
onTouchEnd={handleCenterTapRelease}
|
||||
>
|
||||
<Suspense fallback={<TerminalSplash inverse />}>
|
||||
<CustomScrollbar
|
||||
autoHide={true}
|
||||
autoHideDelay={1500}
|
||||
rightOffset={250}
|
||||
>
|
||||
{props.children}
|
||||
</CustomScrollbar>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<RightBar />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isMainSite()}>
|
||||
<div class="bg-base flex min-h-screen w-full flex-col overflow-x-hidden">
|
||||
<noscript>
|
||||
<div class="bg-yellow text-crust border-text fixed top-0 z-150 border-b-2 p-4 text-center font-semibold md:w-[calc(100vw-500px)]">
|
||||
<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>
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorBoundaryFallback error={error} reset={reset} />
|
||||
)}
|
||||
>
|
||||
<div
|
||||
onMouseUp={handleCenterTapRelease}
|
||||
onTouchEnd={handleCenterTapRelease}
|
||||
<div class="flex-1">
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorBoundaryFallback error={error} reset={reset} />
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<TerminalSplash inverse />}>
|
||||
<CustomScrollbar
|
||||
autoHide={true}
|
||||
autoHideDelay={1500}
|
||||
rightOffset={250}
|
||||
>
|
||||
{props.children}
|
||||
</CustomScrollbar>
|
||||
{props.children}
|
||||
</Suspense>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<SubdomainFooter />
|
||||
</div>
|
||||
<RightBar />
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -203,17 +231,19 @@ export default function App() {
|
||||
)}
|
||||
>
|
||||
<DarkModeProvider>
|
||||
<BarsProvider>
|
||||
<Router
|
||||
root={(props) => (
|
||||
<AuthProvider>
|
||||
<AppLayout>{props.children}</AppLayout>
|
||||
</AuthProvider>
|
||||
)}
|
||||
>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
</BarsProvider>
|
||||
<SiteProvider>
|
||||
<BarsProvider>
|
||||
<Router
|
||||
root={(props) => (
|
||||
<AuthProvider>
|
||||
<AppLayout>{props.children}</AppLayout>
|
||||
</AuthProvider>
|
||||
)}
|
||||
>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
</BarsProvider>
|
||||
</SiteProvider>
|
||||
</DarkModeProvider>
|
||||
</ErrorBoundary>
|
||||
</MetaProvider>
|
||||
|
||||
@@ -2,7 +2,14 @@ import { Typewriter } from "./Typewriter";
|
||||
import { useBars } from "~/context/bars";
|
||||
import { useAuth } from "~/context/auth";
|
||||
import { revalidateAuth } from "~/lib/auth-query";
|
||||
import { onMount, createSignal, Show, For, onCleanup } from "solid-js";
|
||||
import {
|
||||
onMount,
|
||||
createSignal,
|
||||
Show,
|
||||
For,
|
||||
onCleanup,
|
||||
type JSX
|
||||
} from "solid-js";
|
||||
import { api } from "~/lib/api";
|
||||
import { insertSoftHyphens, glitchText } from "~/lib/client-utils";
|
||||
import GitHub from "./icons/GitHub";
|
||||
@@ -14,6 +21,14 @@ import { SkeletonBox, SkeletonText } from "./SkeletonLoader";
|
||||
import { env } from "~/env/client";
|
||||
import { A, useNavigate, useLocation } from "@solidjs/router";
|
||||
import { BREAKPOINTS } from "~/config";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import {
|
||||
NAV_CONFIG,
|
||||
BACK_TO_FRENO,
|
||||
filterNavByAuth,
|
||||
type NavItem,
|
||||
type NavIcon
|
||||
} from "~/lib/nav-config";
|
||||
|
||||
function formatDomainName(url: string): string {
|
||||
const domain = url.split("://")[1]?.split(":")[0] ?? url;
|
||||
@@ -46,7 +61,259 @@ interface ContributionDay {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function RightBarContent() {
|
||||
// Four independent cached promises — first RightBarContent instance to mount
|
||||
// starts each fetch; the second gets the already-in-flight promise.
|
||||
let ghCommitsPromise: Promise<GitCommit[]> | null = null;
|
||||
let gtCommitsPromise: Promise<GitCommit[]> | null = null;
|
||||
let ghActivityPromise: Promise<ContributionDay[]> | null = null;
|
||||
let gtActivityPromise: Promise<ContributionDay[]> | null = null;
|
||||
|
||||
function getGhCommitsPromise(): Promise<GitCommit[]> {
|
||||
return (ghCommitsPromise ??= api.gitActivity.getGitHubCommits
|
||||
.query({ limit: 6 })
|
||||
.catch(() => []));
|
||||
}
|
||||
function getGtCommitsPromise(): Promise<GitCommit[]> {
|
||||
return (gtCommitsPromise ??= api.gitActivity.getGiteaCommits
|
||||
.query({ limit: 6 })
|
||||
.catch(() => []));
|
||||
}
|
||||
function getGhActivityPromise(): Promise<ContributionDay[]> {
|
||||
return (ghActivityPromise ??= api.gitActivity.getGitHubActivity
|
||||
.query()
|
||||
.catch(() => []));
|
||||
}
|
||||
function getGtActivityPromise(): Promise<ContributionDay[]> {
|
||||
return (gtActivityPromise ??= api.gitActivity.getGiteaActivity
|
||||
.query()
|
||||
.catch(() => []));
|
||||
}
|
||||
|
||||
// ── Subdomain nav rendering ──────────────────────────────────────────────
|
||||
//
|
||||
// The main site retains its bespoke LeftBar / RightBarContent rendering
|
||||
// unchanged (Recent Posts, auth-aware Account/Login/SignOut, admin links,
|
||||
// RecentCommits + ActivityHeatmap widgets, the "What's this?" glitch button).
|
||||
// Subdomain sites render a simplified, brand-colored shell that iterates
|
||||
// `NAV_CONFIG[site]` + a "back to freno.me" affordance, and deliberately
|
||||
// skips the web-auth (freno.me JWT) widgets — Nessa uses Clerk; Lineage uses
|
||||
// its mobile JWT; neither should surface web login state.
|
||||
|
||||
/** Inline icon resolver keyed by `NavIcon` (kept out of the pure nav-config). */
|
||||
function NavIconSvg(props: { icon?: NavIcon; size?: number }): JSX.Element {
|
||||
const size = () => props.size ?? 22;
|
||||
const cls = "shaker rounded-full p-2";
|
||||
const common = (viewBox: string, path: JSX.Element) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={size()}
|
||||
width={size()}
|
||||
viewBox={viewBox}
|
||||
class="fill-text"
|
||||
>
|
||||
{path}
|
||||
</svg>
|
||||
);
|
||||
switch (props.icon) {
|
||||
case "home":
|
||||
return common(
|
||||
"0 0 576 512",
|
||||
<path d="M543.8 287.6c17 0 32-14 32-32.1 1-9-3-17-11-24L309.5 7c-6-5-14-7-21-7s-15 1-22 8L10 231.5c-7 7-10 15-9 24 0 18 14 32.1 32 32.1l32 0V448c0 35.3 28.7 64 64 64l224 0c35.3 0 64-28.7 64-64l0-160.4 50.8 0zM272 448v-96c0-17.7 14.3-32 32-32s32 14.3 32 32v96H272z" />
|
||||
);
|
||||
case "blog":
|
||||
return common(
|
||||
"0 0 448 512",
|
||||
<path d="M448 336v-288C448 21.49 426.5 0 400 0H96C42.98 0 0 42.98 0 96v320c0 53.02 42.98 96 96 96h320c17.67 0 32-14.33 32-32s-14.33-32-32-32H96c-17.67 0-32-14.33-32-32s14.33-32 32-32h320C426.5 416 448 394.5 448 336zM96 352c-11.28 0-21.94 2.564-32 6.879V96c0-17.67 14.33-32 32-32h320v256H96z" />
|
||||
);
|
||||
case "downloads":
|
||||
return common(
|
||||
"0 0 512 512",
|
||||
<path d="M288 32c0-17.7-14.3-32-32-32s-32 14.3-32 32V274.7l-73.4-73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l128 128c12.5 12.5 32.8 12.5 45.3 0l128-128c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L288 274.7V32zM64 352c-35.3 0-64 28.7-64 64v32c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V416c0-35.3-28.7-64-64-64H346.5l-45.3 45.3c-25 25-65.5 25-90.5 0L165.5 352H64zm368 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z" />
|
||||
);
|
||||
case "resume":
|
||||
return common(
|
||||
"0 0 384 512",
|
||||
<path d="M64 0C28.7 0 0 28.7 0 64V448c0 35.3 28.7 64 64 64H320c35.3 0 64-28.7 64-64V160H256c-17.7 0-32-14.3-32-32V0H64zM256 0V128H384L256 0zM112 256H272c8.8 0 16 7.2 16 16s-7.2 16-16 16H112c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64H272c8.8 0 16 7.2 16 16s-7.2 16-16 16H112c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64H272c8.8 0 16 7.2 16 16s-7.2 16-16 16H112c-8.8 0-16-7.2-16-16s7.2-16 16-16z" />
|
||||
);
|
||||
case "contact":
|
||||
return common(
|
||||
"0 0 512 512",
|
||||
<path d="M96 0C60.7 0 32 28.7 32 64V448c0 35.3 28.7 64 64 64H416c35.3 0 64-28.7 64-64V64c0-35.3-28.7-64-64-64H96zM208 256h96c26.5 0 48 21.5 48 48v8c0 44.2-35.8 80-80 80H240c-44.2 0-80-35.8-80-80v-8c0-26.5 21.5-48 48-48zm48-48c26.5 0 48-21.5 48-48s-21.5-48-48-48-48 21.5-48 48 21.5 48 48 48z" />
|
||||
);
|
||||
case "privacy":
|
||||
return common(
|
||||
"0 0 512 512",
|
||||
<path d="M256 0c4.6 0 9.2 1 13.4 2.9L457.7 82.8c22 9.3 38.4 31 38.3 57.2c-.5 99.2-41.3 280.7-213.6 363.2c-16.7 8-36.1 8-52.8 0C57.3 420.7 16.5 239.2 16 140c-.1-26.2 16.3-47.9 38.3-57.2L242.6 2.9C246.8 1 251.4 0 256 0z" />
|
||||
);
|
||||
case "deletion":
|
||||
return common(
|
||||
"0 0 448 512",
|
||||
<path d="M135.2 17.7L128 32H32C14.3 32 0 46.3 0 64S14.3 96 32 96H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H320l-7.2-14.3C307.4 6.8 296.3 0 284.2 0H163.8c-12.1 0-23.2 6.8-28.6 17.7zM416 128H32L53.2 467c1.6 25.3 22.6 45 47.9 45H346.9c25.3 0 46.3-19.7 47.9-45L416 128z" />
|
||||
);
|
||||
case "back":
|
||||
return common(
|
||||
"0 0 512 512",
|
||||
<path d="M512 256C512 114.6 397.4 0 256 0S0 114.6 0 256S114.6 512 256 512s256-114.6 256-256zM116.7 244.7l112-112c4.6-4.6 11.9-5.9 17.1-2.5s7.7 10 4.9 16.4l-21.5 50.3H376c13.3 0 24 10.7 24 24s-10.7 24-24 24H229.2l21.5 50.3c2.8 6.4 .3 13-4.9 16.4s-12.5 2.1-17.1-2.5l-112-112c-6.2-6.2-6.2-16.3 0-22.6z" />
|
||||
);
|
||||
case "github":
|
||||
return <GitHub height={22} width={22} fill={`var(--color-text)`} />;
|
||||
case "linkedin":
|
||||
return <LinkedIn height={22} width={22} fill={undefined} />;
|
||||
default:
|
||||
return <span class={cls} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subdomain nav link — renders an internal (`A`) or external (`a`) link with
|
||||
* a resolved icon. Uses the same hover affordances as the main-site links.
|
||||
*/
|
||||
function SubdomainNavLink(props: { item: NavItem; onClick: () => void }) {
|
||||
const inner = (
|
||||
<>
|
||||
<Show when={props.item.icon}>
|
||||
<span class="shaker rounded-full p-2">
|
||||
<NavIconSvg icon={props.item.icon} />
|
||||
</span>
|
||||
</Show>
|
||||
<span>{props.item.label}</span>
|
||||
</>
|
||||
);
|
||||
if (props.item.external) {
|
||||
return (
|
||||
<a
|
||||
href={props.item.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="hover:text-subtext0 flex items-center gap-3 transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<A
|
||||
href={props.item.href}
|
||||
onClick={props.onClick}
|
||||
class="hover:text-subtext0 flex items-center gap-3 transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||
>
|
||||
{inner}
|
||||
</A>
|
||||
);
|
||||
}
|
||||
|
||||
/** The shared simplified nav list rendered by both subdomain bars. */
|
||||
function SubdomainNavList(props: { onClick: () => void }) {
|
||||
const site = useSite();
|
||||
// Auth items intentionally omitted — subdomains don't use the web (freno.me)
|
||||
// JWT auth (Nessa uses Clerk; Lineage uses its mobile JWT), so no subdomain
|
||||
// nav item sets showLoggedIn/showLoggedOut today. filterNavByAuth is therefore
|
||||
// a no-op right now but is kept so future admin items behave correctly
|
||||
// without re-touching the renderer. Filtering here keeps LeftBar + RightBar
|
||||
// consistent.
|
||||
return (
|
||||
<For each={filterNavByAuth(NAV_CONFIG[site().id], false)}>
|
||||
{(item) => <SubdomainNavLink item={item} onClick={props.onClick} />}
|
||||
</For>
|
||||
);
|
||||
}
|
||||
|
||||
/** Brand heading — display name in the site's brand color. */
|
||||
function SubdomainBrand() {
|
||||
const site = useSite();
|
||||
const accent = () => `color: ${site().brandColor}`;
|
||||
return (
|
||||
<h3
|
||||
class="w-fit pt-6 text-center text-3xl underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||
style={accent()}
|
||||
>
|
||||
<A href="/">{site().displayName}</A>
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Back to freno.me" affordance rendered on every subdomain site. */
|
||||
function BackToFrenoLink() {
|
||||
return (
|
||||
<a
|
||||
href={BACK_TO_FRENO.href}
|
||||
class="hover:text-subtext0 flex items-center gap-3 text-sm transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||
>
|
||||
<span class="shaker rounded-full p-2">
|
||||
<NavIconSvg icon="back" size={18} />
|
||||
</span>
|
||||
<span>{BACK_TO_FRENO.label}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inner content for the LeftBar on subdomain sites. */
|
||||
function SubdomainLeftBarContent() {
|
||||
const { setLeftBarVisible } = useBars();
|
||||
const handleLinkClick = () => {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH
|
||||
) {
|
||||
setLeftBarVisible(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div class="text-text flex h-full flex-col px-4 pb-4 text-xl font-bold">
|
||||
<SubdomainBrand />
|
||||
<div class="flex flex-col gap-4 py-8">
|
||||
{/* Auth items intentionally omitted — see SubdomainNavList. */}
|
||||
<SubdomainNavList onClick={handleLinkClick} />
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex flex-col gap-4">
|
||||
<BackToFrenoLink />
|
||||
<hr class="border-overlay0" />
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
|
||||
{/* Mobile-only secondary column mirror of the right bar. */}
|
||||
<div class="border-overlay0 -mx-4 mt-4 border-t pt-8 md:hidden">
|
||||
<SubdomainRightBarContent />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inner content for the RightBar on subdomain sites (desktop only). */
|
||||
function SubdomainRightBarContent() {
|
||||
const { setLeftBarVisible } = useBars();
|
||||
const handleLinkClick = () => {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH
|
||||
) {
|
||||
setLeftBarVisible(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
id="rightbar-content"
|
||||
class="text-text flex h-full flex-col gap-6 overflow-y-auto pb-6 md:w-min"
|
||||
>
|
||||
<Typewriter keepAlive={false} class="z-50 px-4 md:pt-4">
|
||||
<SubdomainBrand />
|
||||
</Typewriter>
|
||||
|
||||
<hr class="border-overlay0" />
|
||||
<ul class="flex flex-col gap-4 px-4">
|
||||
<SubdomainNavList onClick={handleLinkClick} />
|
||||
</ul>
|
||||
<hr class="border-overlay0" />
|
||||
<div class="flex flex-col gap-4 px-4">
|
||||
<BackToFrenoLink />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main-site RightBar (unchanged) ───────────────────────────────────────
|
||||
function MainRightBarContent() {
|
||||
const { setLeftBarVisible } = useBars();
|
||||
const [githubCommits, setGithubCommits] = createSignal<GitCommit[]>([]);
|
||||
const [giteaCommits, setGiteaCommits] = createSignal<GitCommit[]>([]);
|
||||
@@ -54,7 +321,8 @@ export function RightBarContent() {
|
||||
[]
|
||||
);
|
||||
const [giteaActivity, setGiteaActivity] = createSignal<ContributionDay[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [githubCommitsLoading, setGithubCommitsLoading] = createSignal(true);
|
||||
const [giteaCommitsLoading, setGiteaCommitsLoading] = createSignal(true);
|
||||
|
||||
const handleLinkClick = () => {
|
||||
if (
|
||||
@@ -66,41 +334,23 @@ export function RightBarContent() {
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch more commits to account for deduplication
|
||||
const [ghCommits, gtCommits, ghActivity, gtActivity] =
|
||||
await Promise.all([
|
||||
api.gitActivity.getGitHubCommits
|
||||
.query({ limit: 6 })
|
||||
.catch(() => []),
|
||||
api.gitActivity.getGiteaCommits.query({ limit: 6 }).catch(() => []),
|
||||
api.gitActivity.getGitHubActivity.query().catch(() => []),
|
||||
api.gitActivity.getGiteaActivity.query().catch(() => [])
|
||||
]);
|
||||
|
||||
// Take first 3 from GitHub
|
||||
const displayedGithubCommits = ghCommits.slice(0, 3);
|
||||
|
||||
// Deduplicate Gitea commits - only against the 3 shown in GitHub section
|
||||
const githubShas = new Set(displayedGithubCommits.map((c) => c.sha));
|
||||
const uniqueGiteaCommits = gtCommits.filter(
|
||||
(commit) => !githubShas.has(commit.sha)
|
||||
);
|
||||
|
||||
setGithubCommits(displayedGithubCommits);
|
||||
setGiteaCommits(uniqueGiteaCommits.slice(0, 3));
|
||||
setGithubActivity(ghActivity);
|
||||
setGiteaActivity(gtActivity);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch git activity:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
fetchData();
|
||||
getGhCommitsPromise().then((commits) => {
|
||||
setGithubCommits(commits.slice(0, 3));
|
||||
setGithubCommitsLoading(false);
|
||||
});
|
||||
|
||||
// Deduplicate Gitea against whatever GitHub has resolved by the time this lands
|
||||
getGtCommitsPromise().then((gtCommits) => {
|
||||
const ghShas = new Set(githubCommits().map((c) => c.sha));
|
||||
setGiteaCommits(
|
||||
gtCommits.filter((c) => !ghShas.has(c.sha)).slice(0, 3)
|
||||
);
|
||||
setGiteaCommitsLoading(false);
|
||||
});
|
||||
|
||||
getGhActivityPromise().then((activity) => setGithubActivity(activity));
|
||||
getGtActivityPromise().then((activity) => setGiteaActivity(activity));
|
||||
}, 0);
|
||||
});
|
||||
|
||||
@@ -116,6 +366,11 @@ export function RightBarContent() {
|
||||
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/"
|
||||
@@ -187,30 +442,40 @@ export function RightBarContent() {
|
||||
|
||||
<hr class="border-overlay0" />
|
||||
<div class="flex min-w-0 flex-col gap-6 px-4 pt-6">
|
||||
<RecentCommits
|
||||
commits={githubCommits()}
|
||||
title="Recent GitHub Commits"
|
||||
loading={loading()}
|
||||
/>
|
||||
<ActivityHeatmap
|
||||
contributions={githubActivity()}
|
||||
title="GitHub Activity"
|
||||
/>
|
||||
<RecentCommits
|
||||
commits={giteaCommits()}
|
||||
title="Recent Gitea Commits"
|
||||
loading={loading()}
|
||||
loading={giteaCommitsLoading()}
|
||||
/>
|
||||
<ActivityHeatmap
|
||||
contributions={giteaActivity()}
|
||||
title="Gitea Activity"
|
||||
/>
|
||||
<RecentCommits
|
||||
commits={githubCommits()}
|
||||
title="Recent GitHub Commits"
|
||||
loading={githubCommitsLoading()}
|
||||
/>
|
||||
<ActivityHeatmap
|
||||
contributions={githubActivity()}
|
||||
title="GitHub Activity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LeftBar() {
|
||||
function RightBarContent() {
|
||||
const site = useSite();
|
||||
return (
|
||||
<Show when={site().id === "main"} fallback={<SubdomainRightBarContent />}>
|
||||
<MainRightBarContent />
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main-site LeftBar content (unchanged) ────────────────────────────────
|
||||
function MainLeftBarContent() {
|
||||
const { leftBarVisible, setLeftBarVisible } = useBars();
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, email, isAdmin } = useAuth();
|
||||
@@ -224,11 +489,6 @@ export function LeftBar() {
|
||||
const [signOutLoading, setSignOutLoading] = createSignal(false);
|
||||
const [getLostText, setGetLostText] = createSignal("What's this?");
|
||||
const [getLostVisible, setGetLostVisible] = createSignal(false);
|
||||
const [windowWidth, setWindowWidth] = createSignal(
|
||||
typeof window !== "undefined"
|
||||
? window.innerWidth
|
||||
: BREAKPOINTS.MOBILE_MAX_WIDTH
|
||||
);
|
||||
|
||||
const handleLinkClick = () => {
|
||||
if (
|
||||
@@ -254,11 +514,6 @@ export function LeftBar() {
|
||||
onMount(() => {
|
||||
setIsMounted(true);
|
||||
|
||||
const handleResize = () => {
|
||||
setWindowWidth(window.innerWidth);
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
const glitchChars = "!@#$%^&*()_+-=[]{}|;':\",./<>?~`";
|
||||
const originalText = "What's this?";
|
||||
let glitchInterval: NodeJS.Timeout;
|
||||
@@ -299,6 +554,220 @@ export function LeftBar() {
|
||||
animationFrame = requestAnimationFrame(revealAnimation);
|
||||
}, 500);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const posts = await api.blog.getRecentPosts.query();
|
||||
setRecentPosts(posts as any[]);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch recent posts:", error);
|
||||
setRecentPosts([]);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
fetchData();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typewriter speed={10} keepAlive={10000} class="z-50 pr-8 pl-4">
|
||||
<h3 class="hover:text-subtext0 w-fit pt-6 text-center text-3xl underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105">
|
||||
<a href="/" onClick={handleLinkClick}>
|
||||
{formatDomainName(env.VITE_DOMAIN)}
|
||||
</a>
|
||||
</h3>
|
||||
</Typewriter>
|
||||
<div class="text-text flex flex-1 flex-col px-4 pb-4 text-xl font-bold">
|
||||
<div class="flex flex-col py-8">
|
||||
<span class="text-lg font-semibold">Recent Posts</span>
|
||||
<div class="flex max-h-[50dvh] flex-col gap-3 pt-4">
|
||||
<Show
|
||||
when={recentPosts()}
|
||||
fallback={
|
||||
<For each={[1, 2, 3]}>
|
||||
{() => (
|
||||
<div class="flex w-52 flex-col">
|
||||
<div class="relative overflow-hidden">
|
||||
<SkeletonBox class="float-right ml-2 h-12 w-16" />
|
||||
<div class="flex flex-col">
|
||||
<SkeletonText class="h-6 w-full" />
|
||||
<SkeletonText class="mt-1.5 h-6 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonText class="mt-1.5 h-6 w-40" />
|
||||
<SkeletonText class="mt-1.5 h-4 w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<For each={recentPosts()}>
|
||||
{(post) => (
|
||||
<a
|
||||
href={`/blog/${post.title}`}
|
||||
onClick={handleLinkClick}
|
||||
class="hover:text-subtext0 block w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105 hover:font-bold"
|
||||
>
|
||||
<Typewriter class="flex flex-col" keepAlive={false}>
|
||||
<div class="relative overflow-hidden">
|
||||
<img
|
||||
src={getThumbnailUrl(post.banner_photo)}
|
||||
alt="post-cover"
|
||||
class="float-right mb-1 ml-2 h-12 w-16 rounded object-cover"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (
|
||||
img.src !==
|
||||
(post.banner_photo || "/blueprint.jpg")
|
||||
) {
|
||||
img.src = post.banner_photo || "/blueprint.jpg";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span class="inline wrap-break-word hyphens-auto">
|
||||
{insertSoftHyphens(post.title.replace(/_/g, " "))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="text-subtext0 clear-both text-sm">
|
||||
{new Date(post.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric"
|
||||
})}
|
||||
</span>
|
||||
</Typewriter>
|
||||
</a>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto">
|
||||
<Typewriter keepAlive={false}>
|
||||
<ul class="flex flex-col gap-4 pt-6">
|
||||
<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="/" onClick={handleLinkClick}>
|
||||
Home
|
||||
</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="/blog" onClick={handleLinkClick}>
|
||||
Blog
|
||||
</a>
|
||||
</li>
|
||||
<Show when={isMounted() && isAdmin()}>
|
||||
<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="/analytics" onClick={handleLinkClick}>
|
||||
Analytics
|
||||
</a>
|
||||
</li>
|
||||
</Show>
|
||||
<li
|
||||
class="hover:text-subtext0 w-fit cursor-pointer transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold"
|
||||
onClick={() => {
|
||||
navigate("/account");
|
||||
handleLinkClick();
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={isMounted() && isAuthenticated()}
|
||||
fallback={
|
||||
<a href="/login" onClick={handleLinkClick}>
|
||||
Login
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<A href="/account" onClick={handleLinkClick}>
|
||||
Account
|
||||
<Show when={email()}>
|
||||
<span class="text-subtext0 text-sm font-normal">
|
||||
{" "}
|
||||
({email()})
|
||||
</span>
|
||||
</Show>
|
||||
</A>
|
||||
</Show>
|
||||
</li>
|
||||
<Show when={isMounted() && isAuthenticated()}>
|
||||
<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">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
disabled={signOutLoading()}
|
||||
class="text-left disabled:opacity-50"
|
||||
>
|
||||
{signOutLoading() ? "Signing Out..." : "Sign Out"}
|
||||
</button>
|
||||
</li>
|
||||
</Show>
|
||||
</ul>
|
||||
</Typewriter>
|
||||
|
||||
<ul class="pt-4 pb-6">
|
||||
<li
|
||||
class="hover:text-subtext0 w-fit transition-all duration-500 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold"
|
||||
classList={{
|
||||
"opacity-0 pointer-events-none": !getLostVisible(),
|
||||
"opacity-100": getLostVisible()
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
const lostUrls = [
|
||||
"/dev/null",
|
||||
"/segfault",
|
||||
"/void",
|
||||
"/404",
|
||||
"/lost-and-still-lost"
|
||||
];
|
||||
const randomUrl =
|
||||
lostUrls[Math.floor(Math.random() * lostUrls.length)];
|
||||
navigate(randomUrl);
|
||||
handleLinkClick();
|
||||
}}
|
||||
class="text-left font-mono transition-opacity duration-75"
|
||||
style={{ "will-change": "contents" }}
|
||||
>
|
||||
{getLostText()}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr class="border-overlay0 -mx-4 my-auto" />
|
||||
<div class="my-auto">
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
|
||||
<div class="border-overlay0 -mx-4 border-t pt-8 md:hidden">
|
||||
<RightBarContent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LeftBar() {
|
||||
const { leftBarVisible, setLeftBarVisible } = useBars();
|
||||
const site = useSite();
|
||||
let ref: HTMLDivElement | undefined;
|
||||
|
||||
const [windowWidth, setWindowWidth] = createSignal(
|
||||
typeof window !== "undefined"
|
||||
? window.innerWidth
|
||||
: BREAKPOINTS.MOBILE_MAX_WIDTH
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
const handleResize = () => {
|
||||
setWindowWidth(window.innerWidth);
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
if (ref) {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isMobile = window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH;
|
||||
@@ -335,34 +804,15 @@ export function LeftBar() {
|
||||
|
||||
onCleanup(() => {
|
||||
ref?.removeEventListener("keydown", handleKeyDown);
|
||||
clearInterval(glitchInterval);
|
||||
if (animationFrame) cancelAnimationFrame(animationFrame);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
});
|
||||
} else {
|
||||
onCleanup(() => {
|
||||
clearInterval(glitchInterval);
|
||||
if (animationFrame) cancelAnimationFrame(animationFrame);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
});
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const posts = await api.blog.getRecentPosts.query();
|
||||
setRecentPosts(posts as any[]);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch recent posts:", error);
|
||||
setRecentPosts([]);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
fetchData();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const getMainNavStyles = () => {
|
||||
const baseStyles = {
|
||||
"transition-timing-function": "cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
@@ -379,6 +829,12 @@ export function LeftBar() {
|
||||
return { ...baseStyles, ...shadowStyle };
|
||||
};
|
||||
|
||||
// Subdomain sites sport an accent border / shadow tinted by the brand color
|
||||
// ("bars render appropriately styled per site — brand color hint from
|
||||
// SITE_CONFIG"). Main keeps the existing neutral styling.
|
||||
const accentBorder = () =>
|
||||
site().id === "main" ? undefined : { "border-color": site().brandColor };
|
||||
|
||||
return (
|
||||
<nav
|
||||
id="navigation"
|
||||
@@ -390,7 +846,7 @@ export function LeftBar() {
|
||||
"-translate-x-full": !leftBarVisible(),
|
||||
"translate-x-0": leftBarVisible()
|
||||
}}
|
||||
style={getMainNavStyles()}
|
||||
style={{ ...getMainNavStyles(), ...accentBorder() }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setLeftBarVisible(!leftBarVisible())}
|
||||
@@ -419,181 +875,12 @@ export function LeftBar() {
|
||||
</button>
|
||||
|
||||
<div class="flex h-full flex-col overflow-y-auto">
|
||||
<Typewriter speed={10} keepAlive={10000} class="z-50 pr-8 pl-4">
|
||||
<h3 class="hover:text-subtext0 w-fit pt-6 text-center text-3xl underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105">
|
||||
<a href="/" onClick={handleLinkClick}>
|
||||
{formatDomainName(env.VITE_DOMAIN)}
|
||||
</a>
|
||||
</h3>
|
||||
</Typewriter>
|
||||
|
||||
<div class="text-text flex flex-1 flex-col px-4 pb-4 text-xl font-bold">
|
||||
<div class="flex flex-col py-8">
|
||||
<span class="text-lg font-semibold">Recent Posts</span>
|
||||
<div class="flex max-h-[50dvh] flex-col gap-3 pt-4">
|
||||
<Show
|
||||
when={recentPosts()}
|
||||
fallback={
|
||||
<For each={[1, 2, 3]}>
|
||||
{() => (
|
||||
<div class="flex w-52 flex-col">
|
||||
<div class="relative overflow-hidden">
|
||||
<SkeletonBox class="float-right ml-2 h-12 w-16" />
|
||||
<div class="flex flex-col">
|
||||
<SkeletonText class="h-6 w-full" />
|
||||
<SkeletonText class="mt-1.5 h-6 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonText class="mt-1.5 h-6 w-40" />
|
||||
<SkeletonText class="mt-1.5 h-4 w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<For each={recentPosts()}>
|
||||
{(post) => (
|
||||
<a
|
||||
href={`/blog/${post.title}`}
|
||||
onClick={handleLinkClick}
|
||||
class="hover:text-subtext0 block w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105 hover:font-bold"
|
||||
>
|
||||
<Typewriter class="flex flex-col" keepAlive={false}>
|
||||
<div class="relative overflow-hidden">
|
||||
<img
|
||||
src={getThumbnailUrl(post.banner_photo)}
|
||||
alt="post-cover"
|
||||
class="float-right mb-1 ml-2 h-12 w-16 rounded object-cover"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (
|
||||
img.src !==
|
||||
(post.banner_photo || "/blueprint.jpg")
|
||||
) {
|
||||
img.src = post.banner_photo || "/blueprint.jpg";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span class="inline wrap-break-word hyphens-auto">
|
||||
{insertSoftHyphens(post.title.replace(/_/g, " "))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="text-subtext0 clear-both text-sm">
|
||||
{new Date(post.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric"
|
||||
})}
|
||||
</span>
|
||||
</Typewriter>
|
||||
</a>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto">
|
||||
<Typewriter keepAlive={false}>
|
||||
<ul class="flex flex-col gap-4 pt-6">
|
||||
<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="/" onClick={handleLinkClick}>
|
||||
Home
|
||||
</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="/blog" onClick={handleLinkClick}>
|
||||
Blog
|
||||
</a>
|
||||
</li>
|
||||
<Show when={isMounted() && isAdmin()}>
|
||||
<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="/analytics" onClick={handleLinkClick}>
|
||||
Analytics
|
||||
</a>
|
||||
</li>
|
||||
</Show>
|
||||
<li
|
||||
class="hover:text-subtext0 w-fit cursor-pointer transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold"
|
||||
onClick={() => {
|
||||
navigate("/account");
|
||||
handleLinkClick();
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={isMounted() && isAuthenticated()}
|
||||
fallback={
|
||||
<a href="/login" onClick={handleLinkClick}>
|
||||
Login
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<A href="/account" onClick={handleLinkClick}>
|
||||
Account
|
||||
<Show when={email()}>
|
||||
<span class="text-subtext0 text-sm font-normal">
|
||||
{" "}
|
||||
({email()})
|
||||
</span>
|
||||
</Show>
|
||||
</A>
|
||||
</Show>
|
||||
</li>
|
||||
<Show when={isMounted() && isAuthenticated()}>
|
||||
<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">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
disabled={signOutLoading()}
|
||||
class="text-left disabled:opacity-50"
|
||||
>
|
||||
{signOutLoading() ? "Signing Out..." : "Sign Out"}
|
||||
</button>
|
||||
</li>
|
||||
</Show>
|
||||
</ul>
|
||||
</Typewriter>
|
||||
|
||||
<ul class="pt-4 pb-6">
|
||||
<li
|
||||
class="hover:text-subtext0 w-fit transition-all duration-500 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold"
|
||||
classList={{
|
||||
"opacity-0 pointer-events-none": !getLostVisible(),
|
||||
"opacity-100": getLostVisible()
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
const lostUrls = [
|
||||
"/dev/null",
|
||||
"/segfault",
|
||||
"/void",
|
||||
"/404",
|
||||
"/lost-and-still-lost"
|
||||
];
|
||||
const randomUrl =
|
||||
lostUrls[Math.floor(Math.random() * lostUrls.length)];
|
||||
navigate(randomUrl);
|
||||
handleLinkClick();
|
||||
}}
|
||||
class="text-left font-mono transition-opacity duration-75"
|
||||
style={{ "will-change": "contents" }}
|
||||
>
|
||||
{getLostText()}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr class="border-overlay0 -mx-4 my-auto" />
|
||||
<div class="my-auto">
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
|
||||
<div class="border-overlay0 -mx-4 border-t pt-8 md:hidden">
|
||||
<RightBarContent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={site().id === "main"}
|
||||
fallback={<SubdomainLeftBarContent />}
|
||||
>
|
||||
<MainLeftBarContent />
|
||||
</Show>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
@@ -616,6 +903,7 @@ export function RightBar() {
|
||||
"transition-timing-function": "cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
"box-shadow": "inset 6px 0 16px -6px rgba(0, 0, 0, 0.1)",
|
||||
"padding-top": "env(safe-area-inset-top)",
|
||||
width: "250px",
|
||||
"padding-bottom": "env(safe-area-inset-bottom)",
|
||||
"scrollbar-width": "none"
|
||||
}}
|
||||
|
||||
544
src/components/ContactForm.tsx
Normal file
@@ -0,0 +1,544 @@
|
||||
import { createSignal, onMount, createEffect, Show, type JSX } from "solid-js";
|
||||
import { useSearchParams, query, createAsync } from "@solidjs/router";
|
||||
import { action, redirect } from "@solidjs/router";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { api } from "~/lib/api";
|
||||
import { getClientCookie } from "~/lib/cookies.client";
|
||||
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
|
||||
import Input from "~/components/ui/Input";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { useCountdown } from "~/lib/useCountdown";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import type { UserProfile } from "~/types/user";
|
||||
import { getCookie, setCookie } from "vinxi/http";
|
||||
import { z } from "zod";
|
||||
import { env as clientEnv } from "~/env/client";
|
||||
import {
|
||||
fetchWithTimeout,
|
||||
checkResponse,
|
||||
fetchWithRetry,
|
||||
NetworkError,
|
||||
TimeoutError,
|
||||
APIError,
|
||||
verifyTurnstileToken
|
||||
} from "~/server/fetch-utils";
|
||||
import {
|
||||
NETWORK_CONFIG,
|
||||
COOLDOWN_TIMERS,
|
||||
VALIDATION_CONFIG,
|
||||
COUNTDOWN_CONFIG,
|
||||
TURNSTILE_CONFIG
|
||||
} from "~/config";
|
||||
import {
|
||||
CONTACT_RECIPIENT_EMAIL,
|
||||
CONTACT_SENDER,
|
||||
getContactContext,
|
||||
buildContactSubject
|
||||
} from "~/lib/contact-config";
|
||||
|
||||
/**
|
||||
* Shared, site-aware contact form — per-subdomain contact pages.
|
||||
*
|
||||
* Extracted verbatim-in-spirit from the legacy `src/routes/contact.tsx` so the
|
||||
* main-site contact flow (`freno.me/contact`) keeps its exact Turnstile +
|
||||
* cooldown + tRPC-submission behavior — the only substantive change is that
|
||||
* the outbound email subject is now per-site (see `~/lib/contact-config.ts`)
|
||||
* and `env` is resolved via a server-only dynamic import (the legacy
|
||||
* top-level `env` reference was a latent runtime bug in the no-JS fallback).
|
||||
*
|
||||
* Site awareness:
|
||||
* - Reads `useSite()` and resolves a default `ContactContext` from
|
||||
* `CONTACT_CONTEXT[site().id]` (subjectPrefix, recipientLabel, heading,
|
||||
* PageHead title + description). Props override the defaults.
|
||||
* - Emits `<PageHead>` so every per-subdomain `/contact` route gets
|
||||
* site-aware title / canonical / OG tags for free.
|
||||
* - The Turnstile site key (`VITE_TURNSTILE_SITE_KEY`) is shared across all
|
||||
* subdomains — ensure it is configured for `*.freno.me` in the Cloudflare
|
||||
* Turnstile dashboard.
|
||||
*
|
||||
* Email routing:
|
||||
* - JS path: `api.misc.sendContactRequest.mutate({ …, subjectPrefix })` — the
|
||||
* tRPC mutation builds the subject via `buildContactSubject`.
|
||||
* - No-JS path: the `sendContactEmail` server action reads a hidden
|
||||
* `subjectPrefix` form field and emits the identical subject. Both paths
|
||||
* deliver to the single shared `CONTACT_RECIPIENT_EMAIL` inbox.
|
||||
*
|
||||
* Both redirect targets (`/contact?success=true`, `/contact?error=…`) are the
|
||||
* PUBLIC browser path — correct on every subdomain origin since vercel.json
|
||||
* host rewrites leave the browser URL clean (`nessa.freno.me/contact`).
|
||||
*/
|
||||
export interface ContactFormProps {
|
||||
/**
|
||||
* Outbound email subject prefix token. Defaults to the active site's
|
||||
* `CONTACT_CONTEXT[siteId].subjectPrefix` (e.g. `"freno.me"` on main,
|
||||
* `"[Nessa]"` on nessa).
|
||||
*/
|
||||
subjectPrefix?: string;
|
||||
/** Display-only label for the recipient. Defaults to the site context. */
|
||||
recipientLabel?: string;
|
||||
/** `<h1>` heading. Defaults to the site context's `heading` (`"Contact"`). */
|
||||
heading?: string;
|
||||
/** Optional subline rendered under the heading (e.g. main-site disclaimer). */
|
||||
subline?: JSX.Element;
|
||||
/**
|
||||
* Extra content rendered between the heading/subline and the form — used by
|
||||
* the main site and the lineage subdomain to host the Life-and-Lineage Q&A
|
||||
* accordion.
|
||||
*/
|
||||
children?: JSX.Element;
|
||||
/** `<PageHead title>` — composes with the site `titleSuffix`. */
|
||||
pageTitle?: string;
|
||||
/** `<PageHead description>`. Defaults to the site context's description. */
|
||||
pageDescription?: string;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Server data query — cooldown cookie expiry (shared across all sites).
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
const getContactData = query(async () => {
|
||||
"use server";
|
||||
const contactExp = getCookie("contactRequestSent");
|
||||
let remainingTime = 0;
|
||||
|
||||
if (contactExp) {
|
||||
const expires = new Date(contactExp);
|
||||
remainingTime = Math.max(0, (expires.getTime() - Date.now()) / 1000);
|
||||
}
|
||||
|
||||
return { remainingTime };
|
||||
}, "contact-data");
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// No-JS fallback action. Behaves identically to the tRPC mutation so the
|
||||
// contact form works even with JS disabled (progressive enhancement).
|
||||
//
|
||||
// `env` is resolved via a server-only dynamic import (the idiomatic pattern
|
||||
// used by `account.tsx` / `blog/index.tsx`) — the legacy top-level `env`
|
||||
// reference in the original `contact.tsx` was a latent runtime bug.
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
const sendContactEmail = action(async (formData: FormData) => {
|
||||
"use server";
|
||||
const name = formData.get("name") as string;
|
||||
const email = formData.get("email") as string;
|
||||
const message = formData.get("message") as string;
|
||||
const turnstileToken = formData.get("cf-turnstile-response") as string;
|
||||
const subjectPrefix =
|
||||
(formData.get("subjectPrefix") as string | null) || "freno.me";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
email: z.string().email("Valid email is required"),
|
||||
message: z
|
||||
.string()
|
||||
.min(1, "Message is required")
|
||||
.max(VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH, "Message too long")
|
||||
});
|
||||
|
||||
try {
|
||||
schema.parse({ name, email, message });
|
||||
} catch (err: any) {
|
||||
return redirect(
|
||||
`/contact?error=${encodeURIComponent(err.errors[0]?.message || "Invalid input")}`
|
||||
);
|
||||
}
|
||||
|
||||
const { env } = await import("~/env/server");
|
||||
|
||||
const turnstileValid = await verifyTurnstileToken(
|
||||
turnstileToken,
|
||||
env.TURNSTILE_SECRET_KEY,
|
||||
TURNSTILE_CONFIG.VERIFY_URL,
|
||||
TURNSTILE_CONFIG.RESPONSE_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (!turnstileValid) {
|
||||
return redirect(
|
||||
"/contact?error=Security verification failed. Please refresh and try again."
|
||||
);
|
||||
}
|
||||
|
||||
const contactExp = getCookie("contactRequestSent");
|
||||
if (contactExp) {
|
||||
const expires = new Date(contactExp);
|
||||
const remaining = expires.getTime() - Date.now();
|
||||
if (remaining > 0) {
|
||||
return redirect(
|
||||
"/contact?error=Please wait before sending another message"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = env.SENDINBLUE_KEY;
|
||||
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
|
||||
|
||||
const escapeHtml = (str: string) =>
|
||||
str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const sendinblueData = {
|
||||
sender: { ...CONTACT_SENDER },
|
||||
to: [{ email: CONTACT_RECIPIENT_EMAIL }],
|
||||
htmlContent: `<html><head></head><body><div>Source: ${escapeHtml(subjectPrefix)}</div><div>Request Name: ${escapeHtml(name)}</div><div>Request Email: ${escapeHtml(email)}</div><div>Request Message: ${escapeHtml(message)}</div></body></html>`,
|
||||
subject: buildContactSubject(subjectPrefix)
|
||||
};
|
||||
|
||||
try {
|
||||
await fetchWithRetry(
|
||||
async () => {
|
||||
const response = await fetchWithTimeout(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"api-key": apiKey,
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(sendinblueData),
|
||||
timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
|
||||
});
|
||||
|
||||
await checkResponse(response);
|
||||
return response;
|
||||
},
|
||||
{
|
||||
maxRetries: NETWORK_CONFIG.MAX_RETRIES,
|
||||
retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
|
||||
}
|
||||
);
|
||||
|
||||
const exp = new Date(Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS);
|
||||
setCookie("contactRequestSent", exp.toUTCString(), {
|
||||
expires: exp,
|
||||
path: "/"
|
||||
});
|
||||
|
||||
return redirect("/contact?success=true");
|
||||
} catch (error) {
|
||||
let errorMessage =
|
||||
"Failed to send message. You can reach me at michael@freno.me";
|
||||
|
||||
if (error instanceof TimeoutError) {
|
||||
errorMessage =
|
||||
"Email service timed out. Please try again or contact michael@freno.me";
|
||||
} else if (error instanceof NetworkError) {
|
||||
errorMessage =
|
||||
"Network error. Please try again or contact michael@freno.me";
|
||||
} else if (error instanceof APIError) {
|
||||
errorMessage =
|
||||
"Email service error. You can reach me at michael@freno.me";
|
||||
}
|
||||
|
||||
return redirect(`/contact?error=${encodeURIComponent(errorMessage)}`);
|
||||
}
|
||||
});
|
||||
|
||||
export function ContactForm(props: ContactFormProps) {
|
||||
const site = useSite();
|
||||
const ctx = () => getContactContext(site().id);
|
||||
|
||||
// Effective values — props override the site-context defaults.
|
||||
const effectiveSubjectPrefix = () =>
|
||||
props.subjectPrefix ?? ctx().subjectPrefix;
|
||||
const effectiveRecipientLabel = () =>
|
||||
props.recipientLabel ?? ctx().recipientLabel;
|
||||
const effectiveHeading = () => props.heading ?? ctx().heading;
|
||||
const effectivePageTitle = () => props.pageTitle ?? ctx().pageTitle;
|
||||
const effectivePageDescription = () =>
|
||||
props.pageDescription ?? ctx().description;
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const contactData = createAsync(() => getContactData(), {
|
||||
deferStream: true
|
||||
});
|
||||
|
||||
const [emailSent, setEmailSent] = createSignal<boolean>(
|
||||
searchParams.success === "true"
|
||||
);
|
||||
const [error, setError] = createSignal<string>(
|
||||
searchParams.error ? decodeURIComponent(String(searchParams.error)) : ""
|
||||
);
|
||||
const [loading, setLoading] = createSignal<boolean>(false);
|
||||
const [user, setUser] = createSignal<UserProfile | null>(null);
|
||||
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
|
||||
const [turnstileToken, setTurnstileToken] = createSignal<string>("");
|
||||
const [turnstileWidgetId, setTurnstileWidgetId] = createSignal<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const { remainingTime, startCountdown, setRemainingTime } = useCountdown();
|
||||
|
||||
onMount(() => {
|
||||
setJsEnabled(true);
|
||||
|
||||
// Load Cloudflare Turnstile script with explicit rendering.
|
||||
// The site key is shared across all subdomains — ensure it is configured
|
||||
// for `*.freno.me` in the Cloudflare Turnstile dashboard.
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
if (typeof window !== "undefined" && (window as any).turnstile) {
|
||||
const container = document.getElementById("turnstile-widget-1");
|
||||
if (container) {
|
||||
const id = (window as any).turnstile.render(container, {
|
||||
sitekey: clientEnv.VITE_TURNSTILE_SITE_KEY,
|
||||
theme: "dark",
|
||||
callback: (token: string) => {
|
||||
setTurnstileToken(token);
|
||||
},
|
||||
"expired-callback": () => {
|
||||
setTurnstileToken("");
|
||||
}
|
||||
});
|
||||
setTurnstileWidgetId(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
|
||||
// Best-effort profile prefill. On subdomain sites there is no freno.me web
|
||||
// auth (Nessa uses Clerk, Lineage uses its mobile JWT) so this resolves to
|
||||
// null / 401 — the `.catch` swallows it and the fields stay blank.
|
||||
api.user.getProfile
|
||||
.query()
|
||||
.then((userData) => {
|
||||
if (userData) {
|
||||
setUser(userData);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
// Try server data first (more accurate)
|
||||
const serverData = contactData();
|
||||
if (serverData?.remainingTime && serverData.remainingTime > 0) {
|
||||
const expirationTime = new Date(
|
||||
Date.now() + serverData.remainingTime * 1000
|
||||
);
|
||||
startCountdown(expirationTime);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to client cookie if server data not available yet
|
||||
const timer = getClientCookie("contactRequestSent");
|
||||
if (timer) {
|
||||
try {
|
||||
startCountdown(timer);
|
||||
} catch (e) {
|
||||
console.error("Failed to start countdown from cookie:", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sendEmailTrigger = async (e: Event) => {
|
||||
if (!jsEnabled()) return;
|
||||
|
||||
e.preventDefault();
|
||||
const form = e.target as unknown as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const name = formData.get("name") as string;
|
||||
const email = formData.get("email") as string;
|
||||
const message = formData.get("message") as string;
|
||||
|
||||
if (name && email && message) {
|
||||
let currentToken = turnstileToken();
|
||||
if (
|
||||
!currentToken &&
|
||||
typeof window !== "undefined" &&
|
||||
(window as any).turnstile
|
||||
) {
|
||||
const widgetEl = document.getElementById("turnstile-widget-1");
|
||||
if (widgetEl) {
|
||||
const id = turnstileWidgetId();
|
||||
currentToken = (window as any).turnstile.getResponse(id || widgetEl);
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentToken || currentToken.trim() === "") {
|
||||
setError("Please complete the security check.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setEmailSent(false);
|
||||
|
||||
try {
|
||||
const res = await api.misc.sendContactRequest.mutate({
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
turnstileToken: currentToken,
|
||||
subjectPrefix: effectiveSubjectPrefix()
|
||||
});
|
||||
|
||||
if (res.message === "email sent") {
|
||||
setEmailSent(true);
|
||||
setError("");
|
||||
form.reset();
|
||||
|
||||
if (typeof window !== "undefined" && (window as any).turnstile) {
|
||||
const widgetEl = document.getElementById("turnstile-widget-1");
|
||||
if (widgetEl) {
|
||||
const id = turnstileWidgetId();
|
||||
(window as any).turnstile.reset(id || widgetEl);
|
||||
}
|
||||
}
|
||||
setTurnstileToken("");
|
||||
|
||||
// Set countdown directly — cookie might not be readable immediately
|
||||
const expirationTime = new Date(
|
||||
Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS
|
||||
);
|
||||
startCountdown(expirationTime);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "An error occurred");
|
||||
setEmailSent(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTime = ({ remainingTime }: { remainingTime: number }) => {
|
||||
const time = isNaN(remainingTime) ? 0 : Math.max(0, remainingTime);
|
||||
return (
|
||||
<div class="timer">
|
||||
<div class="value">{time.toFixed(0)}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={effectivePageTitle()}
|
||||
description={effectivePageDescription()}
|
||||
/>
|
||||
|
||||
<div class="bg-base flex min-h-screen w-full justify-center">
|
||||
<div class="w-full max-w-4xl px-4 pt-[20vh]">
|
||||
<div class="text-center text-3xl tracking-widest">
|
||||
{effectiveHeading()}
|
||||
</div>
|
||||
<Show when={props.subline}>
|
||||
<div class="mt-4 -mb-4 text-center text-xl tracking-widest">
|
||||
{props.subline}
|
||||
</div>
|
||||
</Show>
|
||||
{props.children}
|
||||
<form
|
||||
onSubmit={sendEmailTrigger}
|
||||
method="post"
|
||||
action={sendContactEmail}
|
||||
class="w-full"
|
||||
>
|
||||
{/* Hidden per-site subject prefix — consumed by the no-JS action. */}
|
||||
<input
|
||||
type="hidden"
|
||||
name="subjectPrefix"
|
||||
value={effectiveSubjectPrefix()}
|
||||
/>
|
||||
<div class="flex w-full flex-col justify-evenly">
|
||||
<div class="mx-auto w-full justify-evenly md:flex md:flex-row">
|
||||
<Input
|
||||
type="text"
|
||||
required
|
||||
name="name"
|
||||
value={user()?.displayName ?? ""}
|
||||
title="Please enter your name"
|
||||
label="Name"
|
||||
containerClass="input-group md:mx-4"
|
||||
class="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
required
|
||||
name="email"
|
||||
value={user()?.email ?? ""}
|
||||
title="Please enter a valid email address"
|
||||
label="Email"
|
||||
containerClass="input-group md:mx-4"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="mx-auto w-full pt-6 md:pt-12">
|
||||
<div class="textarea-group">
|
||||
<textarea
|
||||
required
|
||||
name="message"
|
||||
placeholder=" "
|
||||
title="Please enter your message"
|
||||
class="underlinedInput w-full bg-transparent"
|
||||
rows={4}
|
||||
maxlength={VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH}
|
||||
/>
|
||||
<span class="bar" />
|
||||
<label class="underlinedInputLabel">Message</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-auto flex w-full justify-between pt-4">
|
||||
<div id="turnstile-widget-1"></div>
|
||||
<Show
|
||||
when={
|
||||
remainingTime() > 0 ||
|
||||
(contactData()?.remainingTime ?? 0) > 0
|
||||
}
|
||||
fallback={
|
||||
<Button type="submit" loading={loading()} class="w-36">
|
||||
Send Message
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={jsEnabled()}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center text-sm text-zinc-400">
|
||||
Please wait{" "}
|
||||
{Math.ceil(contactData()?.remainingTime ?? 0)}s before
|
||||
sending another message
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CountdownCircleTimer
|
||||
duration={COUNTDOWN_CONFIG.CONTACT_FORM_DURATION_S}
|
||||
initialRemainingTime={remainingTime()}
|
||||
size={48}
|
||||
strokeWidth={6}
|
||||
onComplete={() => setRemainingTime(0)}
|
||||
>
|
||||
{renderTime}
|
||||
</CountdownCircleTimer>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
class={`${
|
||||
emailSent()
|
||||
? "text-green-400"
|
||||
: error() !== ""
|
||||
? "text-red-400"
|
||||
: "user-select opacity-0"
|
||||
} flex justify-center text-center italic transition-opacity duration-300 ease-in-out`}
|
||||
>
|
||||
{emailSent()
|
||||
? `Email sent to ${effectiveRecipientLabel()}!`
|
||||
: error()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContactForm;
|
||||
@@ -4,7 +4,10 @@ import SunIcon from "./icons/SunIcon";
|
||||
import { Typewriter } from "./Typewriter";
|
||||
import { useDarkMode } from "~/context/darkMode";
|
||||
|
||||
export function DarkModeToggle() {
|
||||
export function DarkModeToggle(
|
||||
props: { shouldScale?: boolean } = { shouldScale: true }
|
||||
) {
|
||||
const shouldScale = props.shouldScale ?? true;
|
||||
const { isDark, toggleDarkMode } = useDarkMode();
|
||||
const [mounted, setMounted] = createSignal(false);
|
||||
|
||||
@@ -15,7 +18,7 @@ export function DarkModeToggle() {
|
||||
return (
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
class="hover:bg-surface0 flex w-full items-center gap-3 rounded-lg p-3 transition-all duration-200 ease-in-out hover:scale-105"
|
||||
class={`hover:bg-surface0 flex w-full items-center gap-3 rounded-lg p-3 transition-all duration-200 ease-in-out ${shouldScale ? "hover:scale-105" : ""}`}
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<Show
|
||||
|
||||
@@ -3,7 +3,32 @@ import CountdownCircleTimer from "~/components/CountdownCircleTimer";
|
||||
import { Spinner } from "~/components/Spinner";
|
||||
import { getClientCookie } from "~/lib/cookies.client";
|
||||
|
||||
export default function DeletionForm() {
|
||||
/**
|
||||
* Product discriminator forwarded to the generalized
|
||||
* `misc.sendDeletionRequestEmail` mutation so the email copy + cooldown
|
||||
* cookie are product-appropriate.
|
||||
*/
|
||||
export type DeletionProduct = "lineage" | "nessa";
|
||||
|
||||
export interface DeletionFormProps {
|
||||
/**
|
||||
* Product whose account is being deleted. Determines the email branding
|
||||
* AND the cooldown cookie name on the server. Defaults to `"lineage"`
|
||||
* (the original / legacy flow) for backward compatibility.
|
||||
*/
|
||||
product?: DeletionProduct;
|
||||
/**
|
||||
* Cooldown cookie name read on mount + written by the server response
|
||||
* (the mutation sets its own cookie; this is only for the client-side
|
||||
* countdown). Defaults to the legacy `deletionRequestSent` name so an
|
||||
* in-flight Lineage cooldown survives the legacy redirect.
|
||||
*/
|
||||
cookieName?: string;
|
||||
}
|
||||
|
||||
export default function DeletionForm(props: DeletionFormProps = {}) {
|
||||
const product = () => props.product ?? "lineage";
|
||||
const cookieName = () => props.cookieName ?? "deletionRequestSent";
|
||||
const [countDown, setCountDown] = createSignal(0);
|
||||
const [emailSent, setEmailSent] = createSignal(false);
|
||||
const [error, setError] = createSignal("");
|
||||
@@ -28,7 +53,7 @@ export default function DeletionForm() {
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const timer = getClientCookie("deletionRequestSent");
|
||||
const timer = getClientCookie(cookieName());
|
||||
if (timer) {
|
||||
timerInterval = setInterval(
|
||||
() => calcRemainder(timer),
|
||||
@@ -60,14 +85,14 @@ export default function DeletionForm() {
|
||||
const response = await fetch("/api/trpc/misc.sendDeletionRequestEmail", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email })
|
||||
body: JSON.stringify({ email, product: product() })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.result?.data?.message === "request sent") {
|
||||
setEmailSent(true);
|
||||
const timer = getClientCookie("deletionRequestSent");
|
||||
const timer = getClientCookie(cookieName());
|
||||
if (timer) {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
|
||||
29
src/components/EdgeCacheHeaders.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { HttpHeader } from "@solidjs/start";
|
||||
|
||||
/**
|
||||
* Renders edge-cache response headers for a page route.
|
||||
*
|
||||
* Sets `CDN-Cache-Control` so Vercel serves the rendered HTML from the edge
|
||||
* (no origin re-render) for `maxAge` seconds, then stale-while-revalidate up
|
||||
* to `staleSeconds`. `Cache-Control: public, max-age=0` keeps browsers
|
||||
* revalidating, so visitors always get the latest version — only the CDN
|
||||
* holds a cached copy. Function/CND-Cache-Control overrides the Vercel
|
||||
* default, so repeated visits and bot crawls stop re-rendering at origin.
|
||||
*/
|
||||
export function EdgeCacheHeaders(props: {
|
||||
/** Seconds the CDN may serve the response fresh. */
|
||||
maxAge: number;
|
||||
/** Seconds the CDN serves stale while revalidating (default: 1 day). */
|
||||
staleSeconds?: number;
|
||||
}) {
|
||||
const stale = props.staleSeconds ?? 86400;
|
||||
return (
|
||||
<>
|
||||
<HttpHeader name="Cache-Control" value="public, max-age=0" />
|
||||
<HttpHeader
|
||||
name="CDN-Cache-Control"
|
||||
value={`public, s-maxage=${props.maxAge}, stale-while-revalidate=${stale}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
195
src/components/PageHead.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Unit tests for `PageHead` site-aware metadata derivation.
|
||||
*
|
||||
* `resolvePageHeadMeta` is a pure function over (props, site, pathname), so
|
||||
* these tests mirror the acceptance matrix without a DOM / SolidJS router.
|
||||
* The render layer (`PageHead` component) is a thin wrapper over this function.
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
resolvePageHeadMeta,
|
||||
type PageHeadProps
|
||||
} from "~/components/page-head-meta";
|
||||
import { SITE_CONFIG, type SiteId } from "~/lib/site-context";
|
||||
|
||||
const BASE_PROPS: PageHeadProps = {
|
||||
title: "Blog",
|
||||
description: "Technical blog posts about web development."
|
||||
};
|
||||
|
||||
describe("resolvePageHeadMeta — title suffix per site", () => {
|
||||
const cases: Array<{ id: SiteId; suffix: string }> = [
|
||||
{ id: "main", suffix: " | Michael Freno" },
|
||||
{ id: "nessa", suffix: " | Nessa" },
|
||||
{ id: "lineage", suffix: " | Life and Lineage" },
|
||||
{ id: "gaze", suffix: " | Gaze" },
|
||||
{ id: "inputhalo", suffix: " | InputHalo" }
|
||||
];
|
||||
|
||||
for (const { id, suffix } of cases) {
|
||||
it(`${id} → title is "${BASE_PROPS.title}${suffix}"`, () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG[id], "/blog");
|
||||
expect(meta.title).toBe(`${BASE_PROPS.title}${suffix}`);
|
||||
});
|
||||
}
|
||||
|
||||
it("main produces 'Home | Michael Freno' for the homepage", () => {
|
||||
const meta = resolvePageHeadMeta({ title: "Home" }, SITE_CONFIG.main, "/");
|
||||
expect(meta.title).toBe("Home | Michael Freno");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePageHeadMeta — canonical URL derivation", () => {
|
||||
it("main → canonical starts with https://freno.me", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/");
|
||||
expect(meta.canonical).toBe("https://freno.me/");
|
||||
});
|
||||
|
||||
it("main blog → https://freno.me/blog", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/blog");
|
||||
expect(meta.canonical).toBe("https://freno.me/blog");
|
||||
});
|
||||
|
||||
it("nessa → canonical starts with https://nessa.freno.me", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/");
|
||||
expect(meta.canonical).toBe("https://nessa.freno.me/");
|
||||
});
|
||||
|
||||
it("nessa /contact → https://nessa.freno.me/contact", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/contact");
|
||||
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);
|
||||
});
|
||||
|
||||
it("gaze → canonical starts with https://gaze.freno.me", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.gaze, "/");
|
||||
expect(meta.canonical.startsWith("https://gaze.freno.me")).toBe(true);
|
||||
});
|
||||
|
||||
it("inputhalo → canonical starts with https://inputhalo.freno.me", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.inputhalo, "/");
|
||||
expect(meta.canonical.startsWith("https://inputhalo.freno.me")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the full pathname including nested segments + search is NOT included", () => {
|
||||
// useLocation().pathname excludes the query string; canonical should too.
|
||||
const meta = resolvePageHeadMeta(
|
||||
BASE_PROPS,
|
||||
SITE_CONFIG.main,
|
||||
"/blog/my-post"
|
||||
);
|
||||
expect(meta.canonical).toBe("https://freno.me/blog/my-post");
|
||||
});
|
||||
|
||||
it("explicit `canonical` prop overrides auto-derivation", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
{ ...BASE_PROPS, canonical: "https://example.com/override" },
|
||||
SITE_CONFIG.nessa,
|
||||
"/contact"
|
||||
);
|
||||
expect(meta.canonical).toBe("https://example.com/override");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePageHeadMeta — OpenGraph fallbacks", () => {
|
||||
it("ogImage defaults to the site's ogDefaultImage, resolved to an absolute URL", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/");
|
||||
expect(meta.ogImage).toBe("https://nessa.freno.me/nessa/og-default.png");
|
||||
});
|
||||
|
||||
it("the nook default resolves to the absolute og.png URL", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nook, "/");
|
||||
expect(meta.ogImage).toBe("https://nook.freno.me/nook/og.png");
|
||||
});
|
||||
|
||||
it("explicit absolute ogImage passes through unchanged", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
{ ...BASE_PROPS, ogImage: "https://cdn/custom.png" },
|
||||
SITE_CONFIG.main,
|
||||
"/"
|
||||
);
|
||||
expect(meta.ogImage).toBe("https://cdn/custom.png");
|
||||
});
|
||||
|
||||
it("explicit site-relative ogImage is resolved against the site domain", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
{ ...BASE_PROPS, ogImage: "/og/custom.png" },
|
||||
SITE_CONFIG.gaze,
|
||||
"/"
|
||||
);
|
||||
expect(meta.ogImage).toBe("https://gaze.freno.me/og/custom.png");
|
||||
});
|
||||
|
||||
it("ogTitle falls back to the base title (no suffix)", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/");
|
||||
expect(meta.ogTitle).toBe("Blog");
|
||||
});
|
||||
|
||||
it("explicit ogTitle overrides the title fallback", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
{ ...BASE_PROPS, ogTitle: "Custom OG Title" },
|
||||
SITE_CONFIG.main,
|
||||
"/"
|
||||
);
|
||||
expect(meta.ogTitle).toBe("Custom OG Title");
|
||||
});
|
||||
|
||||
it("ogDescription falls back to description", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/");
|
||||
expect(meta.ogDescription).toBe(BASE_PROPS.description);
|
||||
});
|
||||
|
||||
it("explicit ogDescription overrides the description fallback", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
{ ...BASE_PROPS, ogDescription: "Custom OG desc" },
|
||||
SITE_CONFIG.main,
|
||||
"/"
|
||||
);
|
||||
expect(meta.ogDescription).toBe("Custom OG desc");
|
||||
});
|
||||
|
||||
it("description is passed through unchanged", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/");
|
||||
expect(meta.description).toBe(BASE_PROPS.description);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,31 @@
|
||||
import { Title, Meta, Link } from "@solidjs/meta";
|
||||
import { useLocation } from "@solidjs/router";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import {
|
||||
resolvePageHeadMeta,
|
||||
type PageHeadProps
|
||||
} from "~/components/page-head-meta";
|
||||
|
||||
export interface PageHeadProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
ogImage?: string;
|
||||
ogTitle?: string;
|
||||
ogDescription?: string;
|
||||
canonical?: string;
|
||||
}
|
||||
// Re-export the pure types + resolver so existing imports
|
||||
// (`import { PageHead } from "~/components/PageHead"`) plus any consumer that
|
||||
// wants the meta helper resolve from a single module path.
|
||||
export {
|
||||
resolvePageHeadMeta,
|
||||
type PageHeadProps,
|
||||
type ResolvedPageHeadMeta
|
||||
} from "~/components/page-head-meta";
|
||||
|
||||
/**
|
||||
* PageHead component for consistent page metadata across the application.
|
||||
* Automatically appends " | Michael Freno" to the title.
|
||||
*
|
||||
* Site-aware: reads `useSite()` for the per-site title suffix,
|
||||
* canonical domain, and default OpenGraph image, so the same component
|
||||
* renders `" | Michael Freno"` / `" | Nessa"` / … depending on the active
|
||||
* subdomain. Canonical URLs are auto-derived from the site domain + the
|
||||
* current router pathname unless an explicit `canonical` override is given.
|
||||
*
|
||||
* The actual derivation lives in the pure `resolvePageHeadMeta` helper (see
|
||||
* `~/components/page-head-meta.ts`) so it can be unit-tested without a DOM.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
@@ -23,27 +37,27 @@ export interface PageHeadProps {
|
||||
* ```
|
||||
*/
|
||||
export default function PageHead(props: PageHeadProps) {
|
||||
const fullTitle = () => `${props.title} | Michael Freno`;
|
||||
const site = useSite();
|
||||
const location = useLocation();
|
||||
|
||||
const meta = () => resolvePageHeadMeta(props, site(), location.pathname);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>{fullTitle()}</Title>
|
||||
{props.description && (
|
||||
<Meta name="description" content={props.description} />
|
||||
<Title>{meta().title}</Title>
|
||||
{meta().description && (
|
||||
<Meta name="description" content={meta().description} />
|
||||
)}
|
||||
{props.canonical && <Link rel="canonical" href={props.canonical} />}
|
||||
<Link rel="canonical" href={meta().canonical} />
|
||||
|
||||
{/* Open Graph / Social Media Tags */}
|
||||
{(props.ogTitle || props.title) && (
|
||||
<Meta property="og:title" content={props.ogTitle || props.title} />
|
||||
<Meta property="og:title" content={meta().ogTitle} />
|
||||
{meta().ogDescription && (
|
||||
<Meta property="og:description" content={meta().ogDescription} />
|
||||
)}
|
||||
{(props.ogDescription || props.description) && (
|
||||
<Meta
|
||||
property="og:description"
|
||||
content={props.ogDescription || props.description}
|
||||
/>
|
||||
)}
|
||||
{props.ogImage && <Meta property="og:image" content={props.ogImage} />}
|
||||
<Meta property="og:image" content={meta().ogImage} />
|
||||
{/* X/Twitter large-image card; falls back to the og:* tags above. */}
|
||||
<Meta name="twitter:card" content="summary_large_image" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
23
src/components/SubdomainFooter.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
83
src/components/SubdomainHeader.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Minimal top navigation for product subdomains.
|
||||
*
|
||||
* Replaces the dual-left/right sidebar used on `freno.me` for each product
|
||||
* subdomain (`nessa.*`, `lineage.*`, `gaze.*`, `inputhalo.*`). The header is
|
||||
* sticky, site-aware, and derives its links from `NAV_CONFIG` so the nav set
|
||||
* remains the single source of truth. `lineage.freno.me/` intentionally does
|
||||
* not use this component so the original full-bleed parallax landing page is
|
||||
* preserved.
|
||||
*/
|
||||
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 { DarkModeToggle } from "~/components/DarkModeToggle";
|
||||
|
||||
export default function SubdomainHeader() {
|
||||
const site = useSite();
|
||||
const location = useLocation();
|
||||
const { isDark } = useDarkMode();
|
||||
|
||||
const brandName = () => site().displayName;
|
||||
const brandColor = () =>
|
||||
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
|
||||
const navItems = () =>
|
||||
NAV_CONFIG[site().id].filter((item) => item.label !== "Home");
|
||||
|
||||
const isActive = (href: string) => {
|
||||
const path = location.pathname;
|
||||
return href === "/" ? path === "/" : path === href;
|
||||
};
|
||||
|
||||
return (
|
||||
<header class="bg-base/80 border-surface0 sticky top-0 z-50 w-full border-b backdrop-blur-md">
|
||||
<div class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
|
||||
<A
|
||||
href="/"
|
||||
class="text-lg font-bold tracking-tight transition-opacity hover:opacity-80"
|
||||
style={{ color: brandColor() }}
|
||||
>
|
||||
{brandName()}
|
||||
</A>
|
||||
|
||||
<nav
|
||||
aria-label={`${brandName()} navigation`}
|
||||
class="flex items-center gap-4 overflow-x-auto text-sm whitespace-nowrap"
|
||||
>
|
||||
<For each={navItems()}>
|
||||
{(item) => (
|
||||
<Show
|
||||
when={item.external}
|
||||
fallback={
|
||||
<A
|
||||
href={item.href}
|
||||
end
|
||||
class="transition-opacity hover:opacity-80"
|
||||
classList={{
|
||||
"font-semibold": isActive(item.href),
|
||||
"opacity-70": !isActive(item.href)
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</A>
|
||||
}
|
||||
>
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="opacity-70 transition-opacity hover:opacity-100"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<DarkModeToggle shouldScale={false} />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -138,9 +138,6 @@ export function Typewriter(props: {
|
||||
entries.forEach((entry) => {
|
||||
// If component leaves viewport while animating, we could pause
|
||||
// For now, we just ensure it starts when visible
|
||||
if (!entry.isIntersecting && cleanupAnimation) {
|
||||
// Component is off-screen - could add pause logic here if needed
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,7 +63,6 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Refresh the S3 file list
|
||||
await loadAttachments();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -81,7 +80,6 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
||||
body: JSON.stringify({ key })
|
||||
});
|
||||
|
||||
// Refresh the S3 file list
|
||||
await loadAttachments();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete file:", err);
|
||||
|
||||
@@ -5,7 +5,7 @@ import LikeIcon from "~/components/icons/LikeIcon";
|
||||
export interface PostLike {
|
||||
id: number;
|
||||
user_id: string;
|
||||
post_id: string;
|
||||
post_id: number;
|
||||
}
|
||||
|
||||
export interface AuthenticatedLikeProps {
|
||||
@@ -36,15 +36,15 @@ export default function AuthenticatedLike(props: AuthenticatedLikeProps) {
|
||||
if (initialHasLiked) {
|
||||
const result = await api.database.removePostLike.mutate({
|
||||
user_id: props.currentUserID,
|
||||
post_id: props.projectID.toString()
|
||||
post_id: props.projectID
|
||||
});
|
||||
setLikes(result.newLikes as PostLike[]);
|
||||
setLikes(result.newLikes as unknown as PostLike[]);
|
||||
} else {
|
||||
const result = await api.database.addPostLike.mutate({
|
||||
user_id: props.currentUserID,
|
||||
post_id: props.projectID.toString()
|
||||
post_id: props.projectID
|
||||
});
|
||||
setLikes(result.newLikes as PostLike[]);
|
||||
setLikes(result.newLikes as unknown as PostLike[]);
|
||||
}
|
||||
setInstantOffset(0);
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
UserPublicData,
|
||||
ReactionType,
|
||||
ModificationType,
|
||||
SortingMode
|
||||
SortingMode,
|
||||
CommentSectionProps
|
||||
} from "~/types/comment";
|
||||
import CommentInputBlock from "./CommentInputBlock";
|
||||
import CommentSortingSelect from "./CommentSortingSelect";
|
||||
|
||||
@@ -259,7 +259,6 @@ export default function CommentSectionWrapper(
|
||||
const newComment = async (commentBody: string, parentCommentID?: number) => {
|
||||
setCommentSubmitLoading(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (commentSubmitTimeoutId) {
|
||||
clearTimeout(commentSubmitTimeoutId);
|
||||
}
|
||||
@@ -428,7 +427,6 @@ export default function CommentSectionWrapper(
|
||||
const editComment = async (body: string, comment_id: number) => {
|
||||
setCommentEditLoading(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (editCommentTimeoutId) {
|
||||
clearTimeout(editCommentTimeoutId);
|
||||
}
|
||||
@@ -527,7 +525,6 @@ export default function CommentSectionWrapper(
|
||||
|
||||
setCommentDeletionLoading(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (deleteCommentTimeoutId) {
|
||||
clearTimeout(deleteCommentTimeoutId);
|
||||
}
|
||||
@@ -623,7 +620,6 @@ export default function CommentSectionWrapper(
|
||||
setOperationError("");
|
||||
|
||||
if (data.commentBody) {
|
||||
// Soft delete (replace body with deletion message)
|
||||
setAllComments((prev) =>
|
||||
prev.map((comment) => {
|
||||
if (comment.id === data.commentID) {
|
||||
@@ -652,7 +648,6 @@ export default function CommentSectionWrapper(
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Hard delete (remove from list)
|
||||
setAllComments((prev) =>
|
||||
prev.filter((comment) => comment.id !== data.commentID)
|
||||
);
|
||||
@@ -667,7 +662,6 @@ export default function CommentSectionWrapper(
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Deletion/edit prompt toggle
|
||||
const toggleModification = (
|
||||
commentID: number,
|
||||
commenterID: string,
|
||||
@@ -708,7 +702,6 @@ export default function CommentSectionWrapper(
|
||||
setCommentBodyForModification("");
|
||||
};
|
||||
|
||||
// Reaction handling
|
||||
const commentReaction = (reactionType: ReactionType, commentID: number) => {
|
||||
if (!props.currentUserID) {
|
||||
console.warn("Cannot react to comment: user not authenticated");
|
||||
@@ -800,7 +793,6 @@ export default function CommentSectionWrapper(
|
||||
}
|
||||
};
|
||||
|
||||
// Click outside handlers (SolidJS version)
|
||||
createEffect(() => {
|
||||
const handleClickOutsideDelete = (e: MouseEvent) => {
|
||||
if (
|
||||
|
||||