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.entrypoints=web"
|
||||||
- "traefik.http.routers.rnotes.priority=130"
|
- "traefik.http.routers.rnotes.priority=130"
|
||||||
- "traefik.http.services.rnotes.loadbalancer.server.port=3000"
|
- "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:
|
networks:
|
||||||
- traefik-public
|
- traefik-public
|
||||||
- rnotes-internal
|
- rnotes-internal
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,5 @@
|
||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { OpenNotebookEmbed } from '@/components/OpenNotebookEmbed';
|
|
||||||
import { UserMenu } from '@/components/UserMenu';
|
|
||||||
import { SearchBar } from '@/components/SearchBar';
|
|
||||||
|
|
||||||
export default function AIPage() {
|
export default function AIPage() {
|
||||||
return (
|
redirect('/opennotebook');
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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