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:
parent
3a1366f422
commit
c4141802cc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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).*)'],
|
||||
};
|
||||
Loading…
Reference in New Issue