Host Open Notebook at /opennotebook with wildcard subdomain support

Add /opennotebook route embedding Open Notebook via iframe with space-aware
nav breadcrumb. Middleware detects subdomain (e.g. cca.rnotes.online) and sets
rnotes-space cookie. /ai redirects to /opennotebook. Traefik wildcard router
at priority 100 catches *.rnotes.online subdomains.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-23 09:45:31 +00:00
parent 3a1366f422
commit c4141802cc
5 changed files with 127 additions and 45 deletions

View File

@ -21,6 +21,11 @@ services:
- "traefik.http.routers.rnotes.entrypoints=web"
- "traefik.http.routers.rnotes.priority=130"
- "traefik.http.services.rnotes.loadbalancer.server.port=3000"
# Wildcard subdomain routing (e.g. cca.rnotes.online)
- "traefik.http.routers.rnotes-wildcard.rule=HostRegexp(`{sub:[a-z0-9-]+}.rnotes.online`)"
- "traefik.http.routers.rnotes-wildcard.entrypoints=web"
- "traefik.http.routers.rnotes-wildcard.priority=100"
- "traefik.http.routers.rnotes-wildcard.service=rnotes"
networks:
- traefik-public
- rnotes-internal

View File

@ -1,48 +1,5 @@
'use client';
import Link from 'next/link';
import { OpenNotebookEmbed } from '@/components/OpenNotebookEmbed';
import { UserMenu } from '@/components/UserMenu';
import { SearchBar } from '@/components/SearchBar';
import { redirect } from 'next/navigation';
export default function AIPage() {
return (
<div className="h-screen flex flex-col bg-[#0a0a0a]">
<nav className="border-b border-slate-800 px-4 md:px-6 py-4 flex-shrink-0">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/" className="flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-sm font-bold text-black">
rN
</div>
</Link>
<span className="text-slate-600 hidden sm:inline">/</span>
<span className="text-white font-semibold">AI Notebook</span>
</div>
<div className="flex items-center gap-2 md:gap-4">
<div className="hidden md:block w-64">
<SearchBar />
</div>
<Link
href="/notebooks"
className="text-sm text-slate-400 hover:text-white transition-colors hidden sm:inline"
>
Notebooks
</Link>
<Link
href="/demo"
className="text-sm text-slate-400 hover:text-white transition-colors hidden sm:inline"
>
Demo
</Link>
<UserMenu />
</div>
</div>
</nav>
<main className="flex-1 min-h-0">
<OpenNotebookEmbed className="h-full rounded-none border-0" />
</main>
</div>
);
redirect('/opennotebook');
}

View File

@ -0,0 +1,57 @@
'use client';
import Link from 'next/link';
import { OpenNotebookEmbed } from '@/components/OpenNotebookEmbed';
import { UserMenu } from '@/components/UserMenu';
import { SearchBar } from '@/components/SearchBar';
import { useSpaceContext } from '@/lib/space-context';
export default function OpenNotebookPage() {
const space = useSpaceContext();
return (
<div className="h-screen flex flex-col bg-[#0a0a0a]">
<nav className="border-b border-slate-800 px-4 md:px-6 py-4 flex-shrink-0">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/" className="flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-sm font-bold text-black">
rN
</div>
</Link>
<span className="text-slate-600 hidden sm:inline">/</span>
{space && (
<>
<span className="text-amber-400 font-medium">{space}</span>
<span className="text-slate-600 hidden sm:inline">/</span>
</>
)}
<span className="text-white font-semibold">Open Notebook</span>
</div>
<div className="flex items-center gap-2 md:gap-4">
<div className="hidden md:block w-64">
<SearchBar />
</div>
<Link
href="/notebooks"
className="text-sm text-slate-400 hover:text-white transition-colors hidden sm:inline"
>
Notebooks
</Link>
<Link
href="/demo"
className="text-sm text-slate-400 hover:text-white transition-colors hidden sm:inline"
>
Demo
</Link>
<UserMenu />
</div>
</div>
</nav>
<main className="flex-1 min-h-0">
<OpenNotebookEmbed className="h-full rounded-none border-0" />
</main>
</div>
);
}

36
src/lib/space-context.ts Normal file
View File

@ -0,0 +1,36 @@
'use client';
import { useSyncExternalStore } from 'react';
function getCookie(name: string): string | null {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
function subscribe(callback: () => void) {
// Re-check on visibility change (covers navigation)
document.addEventListener('visibilitychange', callback);
return () => document.removeEventListener('visibilitychange', callback);
}
function getSnapshot(): string | null {
return getCookie('rnotes-space');
}
function getServerSnapshot(): string | null {
return null;
}
export function useSpaceContext(): string | null {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
export function getSpaceFromHeaders(headers: Headers): string | null {
const host = headers.get('host') || '';
const match = host.match(/^([a-z0-9-]+)\.rnotes\.online$/);
if (match && !['www', 'opennotebook', 'api'].includes(match[1])) {
return match[1];
}
return null;
}

27
src/middleware.ts Normal file
View File

@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const RESERVED_SUBDOMAINS = new Set(['www', 'opennotebook', 'api']);
export function middleware(request: NextRequest) {
const host = request.headers.get('host') || '';
const match = host.match(/^([a-z0-9-]+)\.rnotes\.online$/);
if (match && !RESERVED_SUBDOMAINS.has(match[1])) {
const space = match[1];
const response = NextResponse.next();
response.cookies.set('rnotes-space', space, {
path: '/',
httpOnly: false,
sameSite: 'lax',
secure: true,
});
return response;
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|icon.svg).*)'],
};