diff --git a/docker-compose.yml b/docker-compose.yml
index 4e8de75..4d40d41 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/src/app/ai/page.tsx b/src/app/ai/page.tsx
index 8907483..a4fb24c 100644
--- a/src/app/ai/page.tsx
+++ b/src/app/ai/page.tsx
@@ -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 (
-
-
-
-
-
-
-
- );
+ redirect('/opennotebook');
}
diff --git a/src/app/opennotebook/page.tsx b/src/app/opennotebook/page.tsx
new file mode 100644
index 0000000..79062fc
--- /dev/null
+++ b/src/app/opennotebook/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/lib/space-context.ts b/src/lib/space-context.ts
new file mode 100644
index 0000000..316f0b3
--- /dev/null
+++ b/src/lib/space-context.ts
@@ -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;
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..fe6ea76
--- /dev/null
+++ b/src/middleware.ts
@@ -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).*)'],
+};