fix: subdomain routing fixed, favicon bundles
@@ -86,7 +86,7 @@
|
||||
|
||||
This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me`, `gaze.freno.me`, `inputhalo.freno.me`) plus the personal site on `freno.me`. See `docs/subdomain-setup.md` for DNS/Vercel configuration.
|
||||
|
||||
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`). An in-app middleware (`src/middleware.ts`, registered via `app.config.ts` → `middleware`) rewrites the request path based on the `Host` header so `lineage.freno.me/privacy` resolves to the `/lineage/privacy` file route. (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 in-app middleware is what actually does the work.)
|
||||
- **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.)
|
||||
- **Site context:** Use `useSite()` (SolidJS) or `getSiteFromEvent`/`getSiteFromRequest` (server) from `src/lib/site-context.ts` to detect the current site. Never host-snoop in route files — SolidStart's router can't match on host.
|
||||
- **API routes:** `/api/*` is a shared pool — subdomain API requests pass through to existing routes via vercel.json pass-through rewrites (ordering matters).
|
||||
- **Auth:** Host-scoped only — no cookie domain broadening.
|
||||
|
||||
@@ -3,7 +3,6 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
import { sentryVitePlugin as sentryPlugin } from "@sentry/vite-plugin";
|
||||
|
||||
export default defineConfig({
|
||||
middleware: "./src/middleware.ts",
|
||||
vite: {
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
|
||||
BIN
public/gaze/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/gaze/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
public/gaze/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 15 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": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
public/gaze/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/gaze/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 140 KiB |
BIN
public/inputhalo/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/inputhalo/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/inputhalo/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 15 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": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
public/inputhalo/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/inputhalo/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 120 KiB |
BIN
public/lineage/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/lineage/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 9.1 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": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
public/lineage/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/lineage/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 115 KiB |
BIN
public/nessa/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/nessa/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 845 B |
BIN
public/nessa/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 15 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": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
public/nessa/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/nessa/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 9.1 KiB |
@@ -60,10 +60,10 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => {
|
||||
expect(meta.canonical).toBe("https://nessa.freno.me/contact");
|
||||
});
|
||||
|
||||
// The in-app subdomain middleware (src/middleware.ts) rewrites the internal
|
||||
// request path to the prefix form (/nessa/contact) before the SolidStart
|
||||
// router matches it. useLocation() therefore reports the prefixed path, and
|
||||
// resolvePageHeadMeta must strip the prefix so the canonical stays public.
|
||||
// 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,
|
||||
|
||||
@@ -12,13 +12,13 @@ import { For, Show } from "solid-js";
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import { useDarkMode } from "~/context/darkMode";
|
||||
import { NAV_CONFIG, BACK_TO_FRENO } from "~/lib/nav-config";
|
||||
import { NAV_CONFIG } from "~/lib/nav-config";
|
||||
import { DarkModeToggle } from "~/components/DarkModeToggle";
|
||||
|
||||
export default function SubdomainHeader() {
|
||||
const site = useSite();
|
||||
const location = useLocation();
|
||||
const { isDark, toggleDarkMode } = useDarkMode();
|
||||
const { isDark } = useDarkMode();
|
||||
|
||||
const brandName = () => site().displayName;
|
||||
const brandColor = () =>
|
||||
@@ -32,10 +32,7 @@ export default function SubdomainHeader() {
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
class="sticky top-0 z-50 w-full border-b backdrop-blur-md"
|
||||
classList={{ "bg-base/80 border-surface0": site().headerOpaque }}
|
||||
>
|
||||
<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="/"
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
* - `title` → `props.title + site.titleSuffix`
|
||||
* - `canonical` → explicit `props.canonical` override wins; otherwise
|
||||
* `https://${site.domain}${pathname}` where `pathname` is the *public*
|
||||
* browser path. Because the subdomain prefix (`/lineage`, `/nessa`, …) is
|
||||
* an internal-only rewrite (applied by `vercel.json` host rewrites or, in
|
||||
* their absence, by `src/middleware.ts`), `useLocation()` reports the
|
||||
* prefixed path (`/lineage/privacy`) and we strip the prefix back off so
|
||||
* the canonical reflects the public URL (`https://lineage.freno.me/privacy`).
|
||||
* browser path. Subdomain routing is host-aware (root routes dispatch by
|
||||
* `useSite()`, see `src/routes/index.tsx`/`privacy.tsx`/`deletion.tsx`),
|
||||
* so `useLocation()` already reports the public path. The defensive
|
||||
* prefix-strip below also handles any legacy prefixed form, so the canonical
|
||||
* is always the public URL.
|
||||
* - `ogImage` → explicit `props.ogImage` wins; otherwise `site.ogDefaultImage`.
|
||||
* - `ogTitle` / `ogDescription` → explicit override wins; otherwise fall
|
||||
* back to the base title (no suffix) / description (existing behavior).
|
||||
@@ -56,13 +56,11 @@ export function resolvePageHeadMeta(
|
||||
): ResolvedPageHeadMeta {
|
||||
const title = `${props.title}${site.titleSuffix}`;
|
||||
/**
|
||||
* The canonical URL is the *public* browser URL, never the internal route
|
||||
* prefix. SolidStart's file router is host-blind, so subdomain routes live
|
||||
* under `src/routes/<prefix>/*` and are served either by `vercel.json` host
|
||||
* rewrites OR by `src/middleware.ts` (the in-app host rewrite). Both append
|
||||
* the prefix to the internal request path (`/privacy` → `/lineage/privacy`),
|
||||
* so `useLocation()` reports the prefixed path — which we strip back off so
|
||||
* the canonical stays `https://lineage.freno.me/privacy`.
|
||||
* The canonical URL is the *public* browser URL, never any internal route
|
||||
* prefix. Subdomain routing is host-aware (root routes dispatch by
|
||||
* `useSite()`), so `useLocation()` returns the public path. The strip below
|
||||
* is a defensive no-op for the public path and still yields the canonical
|
||||
* `https://lineage.freno.me/privacy` if a prefixed form ever appears.
|
||||
*/
|
||||
const publicPath =
|
||||
site.baseRoutePrefix &&
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { JSX, splitProps, Show, createSignal, createEffect } from "solid-js";
|
||||
import {
|
||||
type JSX,
|
||||
splitProps,
|
||||
Show,
|
||||
createSignal,
|
||||
createEffect
|
||||
} from "solid-js";
|
||||
import { Spinner } from "~/components/Spinner";
|
||||
|
||||
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
@@ -6,6 +12,11 @@ export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
size?: "sm" | "md" | "lg";
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
/**
|
||||
* Override the variant's background color with an explicit CSS color.
|
||||
* Used by download pages to theme the button with the product brand color.
|
||||
*/
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export default function Button(props: ButtonProps) {
|
||||
@@ -16,7 +27,8 @@ export default function Button(props: ButtonProps) {
|
||||
"fullWidth",
|
||||
"class",
|
||||
"children",
|
||||
"disabled"
|
||||
"disabled",
|
||||
"color"
|
||||
]);
|
||||
|
||||
let contentRef: HTMLSpanElement | undefined;
|
||||
@@ -53,8 +65,8 @@ export default function Button(props: ButtonProps) {
|
||||
: "bg-surface0 hover:brightness-125 active:scale-90";
|
||||
case "download":
|
||||
return isDisabledOrLoading
|
||||
? "bg-green text-base cursor-not-allowed brightness-75"
|
||||
: "bg-green text-base hover:brightness-125 active:scale-90";
|
||||
? "text-base cursor-not-allowed brightness-75"
|
||||
: "text-base hover:brightness-125 active:scale-90";
|
||||
case "danger":
|
||||
return isDisabledOrLoading
|
||||
? "bg-red cursor-not-allowed brightness-75"
|
||||
@@ -83,11 +95,19 @@ export default function Button(props: ButtonProps) {
|
||||
|
||||
const widthClass = () => (local.fullWidth ? "w-full" : "");
|
||||
|
||||
const buttonStyle = (): JSX.CSSProperties => {
|
||||
if (local.color) {
|
||||
return { background: local.color };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
{...others}
|
||||
disabled={local.disabled || local.loading}
|
||||
class={`${baseClasses} ${variantClasses()} ${sizeClasses()} ${widthClass()} ${local.class || ""}`}
|
||||
style={buttonStyle()}
|
||||
>
|
||||
<Show
|
||||
when={local.loading}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, onMount, onCleanup, Accessor } from "solid-js";
|
||||
import { createSignal, onMount, onCleanup, type Accessor } from "solid-js";
|
||||
|
||||
export const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
@@ -8,8 +8,9 @@ export const MOBILE_BREAKPOINT = 768;
|
||||
* @returns Accessor for current window width
|
||||
*/
|
||||
export function createWindowWidth(debounceMs?: number): Accessor<number> {
|
||||
const initialWidth = typeof window !== "undefined" ? window.innerWidth : 1024;
|
||||
const [width, setWidth] = createSignal(initialWidth);
|
||||
// Use a static default so SSR and initial client render agree; the
|
||||
// real value is set in onMount (after hydration) to avoid mismatches.
|
||||
const [width, setWidth] = createSignal(1024);
|
||||
|
||||
onMount(() => {
|
||||
setWidth(window.innerWidth);
|
||||
|
||||
@@ -35,8 +35,6 @@ export interface Site {
|
||||
brandColor: string;
|
||||
/** Dark mode variant of the brand color (used when dark mode is active). */
|
||||
brandColorDark?: string;
|
||||
/** Whether the subdomain header should use a solid background (default: false for transparent). */
|
||||
headerOpaque?: boolean;
|
||||
/** Default OpenGraph image path (resolved against the site root). */
|
||||
ogDefaultImage: string;
|
||||
/** Favicon path for this site. */
|
||||
@@ -89,7 +87,6 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
||||
titleSuffix: " | Nessa",
|
||||
brandColor: "#527640",
|
||||
brandColorDark: "#6CA86C",
|
||||
headerOpaque: true,
|
||||
ogDefaultImage: "/nessa/og-default.png",
|
||||
faviconPath: "/nessa/favicon.ico"
|
||||
},
|
||||
@@ -100,7 +97,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
||||
baseRoutePrefix: "/lineage",
|
||||
displayName: "Life and Lineage",
|
||||
titleSuffix: " | Life and Lineage",
|
||||
brandColor: "#a6e3a1",
|
||||
brandColor: "#a13536",
|
||||
ogDefaultImage: "/lineage/og-default.png",
|
||||
faviconPath: "/lineage/favicon.ico"
|
||||
},
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// @refresh reload
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/**
|
||||
* In-app host-based subdomain path rewrite.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* `vercel.json` declares host `rewrites` that map each subdomain onto its
|
||||
* internal `src/routes/<prefix>/*` file route (e.g.
|
||||
* `lineage.freno.me/privacy` → `/lineage/privacy`). When Vercel builds with
|
||||
* the Nitro `vercel` preset, however, it emits a Build Output API
|
||||
* `.vercel/output/config.json` with a `routes` array — and per Vercel's
|
||||
* contract, **a `routes` array in `config.json` fully replaces
|
||||
* `vercel.json` `rewrites`/`redirects`/`headers`**. The host rewrites were
|
||||
* therefore silently never applied, which is why subdomain-only routes
|
||||
* (`/privacy`, `/deletion`) 404'd on production while routes that also
|
||||
* exist at the root (`/`, `/contact`, `/downloads`) appeared to work (they
|
||||
* were actually served by the root route, not the subdomain-branded one).
|
||||
*
|
||||
* ## What this does
|
||||
*
|
||||
* This middleware (registered via `app.config.ts` → `middleware`) runs as
|
||||
* an H3 `onRequest` hook BEFORE the SolidStart file router matches the path.
|
||||
* For a request whose `Host` resolves to a subdomain `Site`, it prefixes the
|
||||
* request URL with the site's `baseRoutePrefix` — e.g. `/privacy` on
|
||||
* `lineage.freno.me` becomes `/lineage/privacy` internally, exactly as the
|
||||
* `vercel.json` rewrite intended — so the file router resolves the correct
|
||||
* subdomain route file (`src/routes/lineage/privacy.tsx`).
|
||||
*
|
||||
* The browser URL is untouched (this is an internal rewrite, not a
|
||||
* redirect). `useLocation()` will report the prefixed internal path; the
|
||||
* canonical-URL derivation in `src/components/page-head-meta.ts` strips the
|
||||
* prefix so the public canonical stays `https://lineage.freno.me/privacy`.
|
||||
*
|
||||
* ## Scope / skip conditions
|
||||
*
|
||||
* - Main site / unknown host → no rewrite (no prefix to add).
|
||||
* - `/api/*` → shared API pool (already routed correctly on all hosts; the
|
||||
* `vercel.json` `/api/(.*)` rules are pure pass-throughs, so no prefix).
|
||||
* - Paths already carrying a subdomain prefix (dev path-based URLs like
|
||||
* `localhost:3000/lineage/privacy`, or a request already rewritten) → no
|
||||
* double-prefix.
|
||||
* - `/_build/*` build assets → served statically, not routed.
|
||||
*
|
||||
* ## Dev vs prod
|
||||
*
|
||||
* In dev, the Host is usually `localhost` (→ main → no rewrite); subdomain
|
||||
* pages are reached via their path prefix (`localhost:3000/lineage/privacy`)
|
||||
* which the skip-condition above leaves untouched. Dev subdomains reached
|
||||
* via `lineage.localhost:3000/privacy` (browsers resolve `*.localhost`) ARE
|
||||
* rewritten, keeping dev and prod behavior consistent.
|
||||
*/
|
||||
import { resolveSiteFromHost } from "~/lib/site-context";
|
||||
|
||||
/**
|
||||
* Minimal H3-event shape this middleware touches. Avoids importing
|
||||
* `vinxi/http` (a type-only subpath that some environments can't resolve) —
|
||||
* the real H3Event satisfies this structure at runtime.
|
||||
*/
|
||||
interface RewriteEvent {
|
||||
node?: {
|
||||
req?: {
|
||||
url?: string;
|
||||
originalUrl?: string;
|
||||
headers?: { host?: string };
|
||||
};
|
||||
};
|
||||
_path?: string;
|
||||
}
|
||||
|
||||
export default {
|
||||
onRequest(event: RewriteEvent) {
|
||||
const req = event?.node?.req;
|
||||
if (!req?.headers) return;
|
||||
|
||||
const site = resolveSiteFromHost(req.headers.host);
|
||||
// No prefix to add for the main site / unknown host.
|
||||
if (!site.baseRoutePrefix) return;
|
||||
|
||||
const url = req.url || "/";
|
||||
// Shared API pool — pass through unchanged on every host.
|
||||
if (url === "/api" || url.startsWith("/api/")) return;
|
||||
// Already prefixed (dev path-based URLs, or a request already rewritten).
|
||||
if (
|
||||
url === site.baseRoutePrefix ||
|
||||
url.startsWith(site.baseRoutePrefix + "/")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Static build assets are served as files, not via the router.
|
||||
if (url.startsWith("/_build/")) return;
|
||||
|
||||
// Internal rewrite: /privacy → /lineage/privacy (browser URL unchanged).
|
||||
// H3's toWebRequest derives the URL from `originalUrl ?? event.path`, so
|
||||
// we must set originalUrl too (not just _path/req.url) or the SolidStart
|
||||
// router keeps seeing the unprefixed browser path.
|
||||
const rewritten = site.baseRoutePrefix + url;
|
||||
req.originalUrl = rewritten;
|
||||
req.url = rewritten;
|
||||
event._path = rewritten;
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Show, type JSX } from "solid-js";
|
||||
import { Show, Switch, Match, type JSX } from "solid-js";
|
||||
import { useSearchParams } from "@solidjs/router";
|
||||
import { A } from "@solidjs/router";
|
||||
import RevealDropDown from "~/components/RevealDropDown";
|
||||
import { ContactForm } from "~/components/ContactForm";
|
||||
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import NessaContactPage from "./nessa/contact";
|
||||
import LineageContactPage from "./lineage/contact";
|
||||
import GazeContactPage from "./gaze/contact";
|
||||
import InputHaloContactPage from "./inputhalo/contact";
|
||||
|
||||
/**
|
||||
* Main-site contact page (`freno.me/contact`).
|
||||
@@ -115,7 +120,7 @@ export function LineageContactQuestions(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContactPage() {
|
||||
function MainContactPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const viewer = () => searchParams.viewer ?? "default";
|
||||
|
||||
@@ -131,3 +136,38 @@ export default function ContactPage() {
|
||||
</ContactForm>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-aware contact route handler.
|
||||
*
|
||||
* SolidStart's file router cannot match on host, and the previous in-app
|
||||
* middleware rewrite (`/contact` → `/<prefix>/contact`, server-only) diverged
|
||||
* the SSR path from the browser URL — SolidStart's client `Router` derives its
|
||||
* route purely from `window.location.pathname`, so the client matched the root
|
||||
* route while the server rendered the subdomain route → hydration mismatch.
|
||||
*
|
||||
* The fix mirrors `src/routes/index.tsx`: resolve the public path (`/contact`)
|
||||
* on BOTH server and client, then dispatch to the right subdomain contact
|
||||
* component via `useSite()`. Server and client render the same component for
|
||||
* the same path → hydration matches; nav links (public paths) and the
|
||||
* canonical URL (public path) keep working unchanged.
|
||||
*/
|
||||
export default function ContactPage(): JSX.Element {
|
||||
const site = useSite();
|
||||
return (
|
||||
<Switch fallback={<MainContactPage />}>
|
||||
<Match when={site().id === "nessa"}>
|
||||
<NessaContactPage />
|
||||
</Match>
|
||||
<Match when={site().id === "lineage"}>
|
||||
<LineageContactPage />
|
||||
</Match>
|
||||
<Match when={site().id === "gaze"}>
|
||||
<GazeContactPage />
|
||||
</Match>
|
||||
<Match when={site().id === "inputhalo"}>
|
||||
<InputHaloContactPage />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
41
src/routes/deletion.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Switch, Match } from "solid-js";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import NotFound from "./[...404]";
|
||||
import NessaDeletionPage from "./nessa/deletion";
|
||||
import LineageDeletionPage from "./lineage/deletion";
|
||||
|
||||
/**
|
||||
* Host-aware account-deletion route — `/deletion`.
|
||||
*
|
||||
* The public browser path `/deletion` serves each subdomain's per-product
|
||||
* account-deletion flow. This mirrors `src/routes/index.tsx`'s host-aware
|
||||
* dispatch: the server and client both resolve the public path (`/deletion`)
|
||||
* and select the matching subdomain component via `useSite()`, so the two
|
||||
* render the same component for the same path and hydration matches. This
|
||||
* replaces the previous server-only path rewrite (`/deletion` →
|
||||
* `/<prefix>/deletion`), which diverged the SSR path from the browser URL and
|
||||
* caused hydration mismatches (SolidStart's client `Router` matches on
|
||||
* `window.location.pathname`, not the rewritten server path).
|
||||
*
|
||||
* Only Nessa and Lineage ship a dedicated deletion page. Gaze, InputHalo, and
|
||||
* the main site fall back to the 404 handler — preserving their pre-rewrite
|
||||
* behavior (Gaze/InputHalo have no account system; the main site never had a
|
||||
* `/deletion` route).
|
||||
*
|
||||
* Legacy `/deletion/life-and-lineage` 308-redirects to the Lineage subdomain's
|
||||
* `https://lineage.freno.me/deletion` (see
|
||||
* `src/routes/lineage/deletion-content.ts` → `LEGACY_DELETION_REDIRECT_TARGET`).
|
||||
*/
|
||||
export default function DeletionPage() {
|
||||
const site = useSite();
|
||||
return (
|
||||
<Switch fallback={<NotFound />}>
|
||||
<Match when={site().id === "nessa"}>
|
||||
<NessaDeletionPage />
|
||||
</Match>
|
||||
<Match when={site().id === "lineage"}>
|
||||
<LineageDeletionPage />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { A } from "@solidjs/router";
|
||||
import { createSignal, onMount, onCleanup } from "solid-js";
|
||||
import { createSignal, onMount, onCleanup, Switch, Match } from "solid-js";
|
||||
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
||||
import { glitchText } from "~/lib/client-utils";
|
||||
import { buildSubdomainUrl } from "~/lib/subdomain-url";
|
||||
import Button from "~/components/ui/Button";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import LineageDownloadsPage from "./lineage/downloads";
|
||||
import GazeDownloadsPage from "./gaze/downloads";
|
||||
import InputHaloDownloadsPage from "./inputhalo/downloads";
|
||||
|
||||
export default function DownloadsPage() {
|
||||
function MainDownloadsPage() {
|
||||
const [LaLText, setLaLText] = createSignal("Life and Lineage");
|
||||
const [SwAText, setSwAText] = createSignal("Shapes with Abigail!");
|
||||
const [corkText, setCorkText] = createSignal("Cork");
|
||||
@@ -300,3 +304,30 @@ export default function DownloadsPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-aware downloads route handler.
|
||||
*
|
||||
* Dispatches to the per-subdomain download surface based on the resolved
|
||||
* site (see `src/routes/index.tsx` for the pattern). Nessa has no dedicated
|
||||
* downloads page, so it falls back to the unified downloads listing —
|
||||
* matching the pre-middleware behavior. Resolving the public path on both
|
||||
* server and client avoids the hydration mismatch the path-rewriting
|
||||
* middleware caused.
|
||||
*/
|
||||
export default function DownloadsPage() {
|
||||
const site = useSite();
|
||||
return (
|
||||
<Switch fallback={<MainDownloadsPage />}>
|
||||
<Match when={site().id === "lineage"}>
|
||||
<LineageDownloadsPage />
|
||||
</Match>
|
||||
<Match when={site().id === "gaze"}>
|
||||
<GazeDownloadsPage />
|
||||
</Match>
|
||||
<Match when={site().id === "inputhalo"}>
|
||||
<InputHaloDownloadsPage />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PageHead } from "~/components/PageHead";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||
import Button from "~/components/ui/Button";
|
||||
import { useDarkMode } from "~/context/darkMode";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import { downloadAsset } from "~/lib/download-asset";
|
||||
|
||||
@@ -22,8 +23,12 @@ const GAZE_MIN_MACOS = "14.6";
|
||||
|
||||
export default function GazeDownloadsPage() {
|
||||
const site = useSite();
|
||||
const { isDark } = useDarkMode();
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
|
||||
const brandColor = () =>
|
||||
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
|
||||
|
||||
const handleDownload = () => {
|
||||
if (loading()) return;
|
||||
setLoading(true);
|
||||
@@ -85,6 +90,7 @@ export default function GazeDownloadsPage() {
|
||||
<Button
|
||||
variant="download"
|
||||
size="lg"
|
||||
color={brandColor()}
|
||||
loading={loading()}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||
import Button from "~/components/ui/Button";
|
||||
import { useDarkMode } from "~/context/darkMode";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import { downloadAsset } from "~/lib/download-asset";
|
||||
|
||||
const GAZE_APP_STORE_URL = "https://apps.apple.com/us/app/gaze/id6757759498";
|
||||
@@ -33,9 +34,13 @@ const FEATURES = [
|
||||
] as const;
|
||||
|
||||
export default function GazeLanding() {
|
||||
const site = useSite();
|
||||
const { isDark } = useDarkMode();
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
|
||||
const brandColor = () =>
|
||||
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
|
||||
|
||||
const handleDownload = () => {
|
||||
if (loading()) return;
|
||||
setLoading(true);
|
||||
@@ -56,7 +61,7 @@ export default function GazeLanding() {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title="Eye & posture health reminder for macOS"
|
||||
title="Home"
|
||||
description="Gaze is a macOS menu bar app for eye and posture health — blink reminders, 20-20-20 breaks, posture check-ins, and customizable reminder intervals."
|
||||
ogImage="/look-away.png"
|
||||
ogTitle="Gaze — Eye and posture health reminder for macOS"
|
||||
@@ -100,6 +105,7 @@ export default function GazeLanding() {
|
||||
<Button
|
||||
variant="download"
|
||||
size="lg"
|
||||
color={brandColor()}
|
||||
loading={loading()}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
@@ -157,6 +163,7 @@ export default function GazeLanding() {
|
||||
<Button
|
||||
variant="download"
|
||||
size="lg"
|
||||
color={brandColor()}
|
||||
loading={loading()}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
|
||||
@@ -33,6 +33,8 @@ export default function InputHaloDownloadsPage() {
|
||||
const iconSrc = () =>
|
||||
isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT;
|
||||
|
||||
const brandColor = () => site().brandColor;
|
||||
|
||||
const download = () => {
|
||||
if (loading()) return;
|
||||
setLoading(true);
|
||||
@@ -89,6 +91,7 @@ export default function InputHaloDownloadsPage() {
|
||||
<Button
|
||||
variant="download"
|
||||
size="lg"
|
||||
color={brandColor()}
|
||||
loading={loading()}
|
||||
onClick={download}
|
||||
>
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function InputHaloLanding() {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title="InputHalo"
|
||||
title="Home"
|
||||
description="A polished macOS menu bar app that visualizes keyboard presses, mouse clicks, cursor halos, and scroll events on screen — for streamers, presenters, and developers."
|
||||
/>
|
||||
|
||||
@@ -152,6 +152,7 @@ export default function InputHaloLanding() {
|
||||
<Button
|
||||
variant="download"
|
||||
size="lg"
|
||||
color={brandColor()}
|
||||
loading={loading()}
|
||||
onClick={download}
|
||||
>
|
||||
@@ -250,6 +251,7 @@ export default function InputHaloLanding() {
|
||||
<Button
|
||||
variant="download"
|
||||
size="lg"
|
||||
color={brandColor()}
|
||||
loading={loading()}
|
||||
onClick={download}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ContactForm } from "~/components/ContactForm";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
import { LineageContactQuestions } from "../contact";
|
||||
|
||||
/**
|
||||
* Life and Lineage contact page (`lineage.freno.me/contact`).
|
||||
@@ -24,7 +25,9 @@ export default function LineageContactPage() {
|
||||
return (
|
||||
<>
|
||||
<SubdomainHeader />
|
||||
<ContactForm />
|
||||
<ContactForm>
|
||||
<LineageContactQuestions />
|
||||
</ContactForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("Lineage landing — asset paths", () => {
|
||||
|
||||
describe("Lineage landing — PageHead inputs", () => {
|
||||
it("passes the base title (suffix is appended by PageHead)", () => {
|
||||
expect(PAGE_META.title).toBe("Life and Lineage");
|
||||
expect(PAGE_META.title).toBe("Home");
|
||||
});
|
||||
|
||||
it("does not pre-bake the site suffix into the title", () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ export const SCREENSHOT_ASSETS = {
|
||||
|
||||
/** Base page title (site suffix appended by PageHead). */
|
||||
export const PAGE_META = {
|
||||
title: "Life and Lineage",
|
||||
title: "Home",
|
||||
description:
|
||||
"A dark fantasy adventure mobile game. Download Life and Lineage on the App Store and Google Play."
|
||||
} as const;
|
||||
|
||||
@@ -13,13 +13,13 @@ import { SITE_CONFIG } from "~/lib/site-context";
|
||||
import { NESSA_LANDING_META } from "./meta";
|
||||
|
||||
describe("Nessa landing page — PageHead metadata", () => {
|
||||
it("title composes to 'Nessa | Nessa' (base title + nessa titleSuffix)", () => {
|
||||
it("title composes to 'Home | Nessa' (base title + nessa titleSuffix)", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
NESSA_LANDING_META,
|
||||
SITE_CONFIG.nessa,
|
||||
"/"
|
||||
);
|
||||
expect(meta.title).toBe("Nessa | Nessa");
|
||||
expect(meta.title).toBe("Home | Nessa");
|
||||
});
|
||||
|
||||
it("title contains the substring 'Nessa'", () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { PageHeadProps } from "~/components/page-head-meta";
|
||||
* no-suffix fallback) — this matches the marketing-copy intent of the card.
|
||||
*/
|
||||
export const NESSA_LANDING_META: PageHeadProps = {
|
||||
title: "Nessa",
|
||||
title: "Home",
|
||||
description:
|
||||
"Nessa is the fitness app that puts you first. Track running, cycling, swimming and more; compete on free segment leaderboards; and connect with friends through clubs, community challenges, and a social feed — all while keeping your data on your device.",
|
||||
ogTitle: "Nessa — The fitness app that puts you first",
|
||||
|
||||
46
src/routes/privacy.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Switch, Match } from "solid-js";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import NotFound from "./[...404]";
|
||||
import NessaPrivacyPolicy from "./nessa/privacy";
|
||||
import LineagePrivacyPolicy from "./lineage/privacy";
|
||||
import GazePrivacyPolicy from "./gaze/privacy";
|
||||
import InputHaloPrivacyPolicy from "./inputhalo/privacy";
|
||||
|
||||
/**
|
||||
* Host-aware privacy policy route — `/privacy`.
|
||||
*
|
||||
* The public browser path `/privacy` serves each subdomain's own privacy
|
||||
* policy. This mirrors `src/routes/index.tsx`'s host-aware dispatch: the
|
||||
* server and client both resolve the public path (`/privacy`) and select the
|
||||
* matching subdomain component via `useSite()`, so the two render the same
|
||||
* component for the same path and hydration matches. This replaces the
|
||||
* previous server-only path rewrite (`/privacy` → `/<prefix>/privacy`), which
|
||||
* diverged the SSR path from the browser URL and caused hydration mismatches
|
||||
* (SolidStart's client `Router` matches on `window.location.pathname`, not the
|
||||
* rewritten server path).
|
||||
*
|
||||
* The main site has no `/privacy` page (it uses `/privacy-policy/*`), so
|
||||
* `main` falls back to the 404 handler — preserving its pre-rewrite behavior.
|
||||
*
|
||||
* Legacy `/privacy-policy/<product>` routes 308-redirect to the corresponding
|
||||
* subdomain `/privacy` (see `src/routes/privacy-policy/*.tsx`).
|
||||
*/
|
||||
export default function PrivacyPage() {
|
||||
const site = useSite();
|
||||
return (
|
||||
<Switch fallback={<NotFound />}>
|
||||
<Match when={site().id === "nessa"}>
|
||||
<NessaPrivacyPolicy />
|
||||
</Match>
|
||||
<Match when={site().id === "lineage"}>
|
||||
<LineagePrivacyPolicy />
|
||||
</Match>
|
||||
<Match when={site().id === "gaze"}>
|
||||
<GazePrivacyPolicy />
|
||||
</Match>
|
||||
<Match when={site().id === "inputhalo"}>
|
||||
<InputHaloPrivacyPolicy />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||