diff --git a/AGENTS.md b/AGENTS.md
index 7ff02d14..a3638fd5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,8 +2,8 @@
## Project Structure & Module Organization
-- `src/routes` contains SvelteKit routes, including dynamic handle pages in `src/routes/[handle]/[[page]]`, edit flows in `src/routes/[handle]/[[page]]/edit` and `src/routes/edit`, and API endpoints under `src/routes/api`.
-- `src/lib` holds reusable modules: card implementations in `src/lib/cards`, shared UI in `src/lib/components`, OAuth helpers in `src/lib/oauth`, and site data/loading in `src/lib/website`.
+- `src/routes` contains SvelteKit routes. User-facing pages are under `src/routes/[[actor=actor]]/(pages)/` with an optional actor param (double brackets). When omitted, the actor is resolved from custom domain KV or `PUBLIC_HANDLE`. API endpoints live under `src/routes/api` and `src/routes/[[actor=actor]]/api`.
+- `src/lib` holds reusable modules: card implementations in `src/lib/cards`, shared UI in `src/lib/components`, ATProto/OAuth helpers in `src/lib/atproto`, and site data/loading in `src/lib/website`.
- Root app setup lives in `src/app.html` and `src/app.css`.
- `static` is for public assets served as-is.
- `docs` includes contributor-facing docs like custom cards and self-hosting.
diff --git a/CLAUDE.md b/CLAUDE.md
index 94ea2492..3ece8fbd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -61,12 +61,13 @@ Grid margins: 16px desktop, 12px mobile.
- See e.g. `src/lib/cards/EmbedCard/` and `src/lib/cards/LivestreamCard/` for examples of implementation.
- Cards should be styled to work in light and dark mode (with `dark:` class modifier) as well as when cards are colorful (= bg-color-500 for the card background) (with `accent:` modifier).
-**ATProto Integration (`src/lib/oauth/`):**
+**ATProto Integration (`src/lib/atproto/`):**
- `auth.svelte.ts` - OAuth client state and login/logout flow using `@atcute/oauth-browser-client`
- `atproto.ts` - ATProto API helpers: `resolveHandle`, `listRecords`, `getRecord`, `putRecord`, `deleteRecord`, `uploadImage`
- Data is stored in user's PDS under collection `app.blento.card`
- **Important**: ATProto does not allow floating point numbers in records. All numeric values must be integers.
+- Login redirect: before OAuth redirect, the current path is saved to `localStorage` (`login-redirect`) and restored after callback
**Caching (`src/lib/cache.ts`):**
@@ -88,13 +89,18 @@ Grid margins: 16px desktop, 12px mobile.
### Routes
-- `/` - Landing page
-- `/[handle]/[[page]]` - View a user's bento site (loads from their PDS)
-- `/[handle]/[[page]]/edit` - Edit mode for a user's site page
-- `/edit` - Self-hosted edit mode
-- `/api/links` - Link preview API
+All user-facing pages live under `src/routes/[[actor=actor]]/(pages)/` using an optional `[[actor=actor]]` param. When the actor param is omitted, the layout resolves the actor from a custom domain (via KV lookup) or the `PUBLIC_HANDLE` env var.
+
+- `/` - Landing page (or a user's site when on a custom domain)
+- `/[actor]` - View a user's bento site (loads from their PDS)
+- `/[actor]/edit` - Edit mode for a user's main page
+- `/[actor]/p/[page]` - View a named sub-page
+- `/[actor]/p/[page]/edit` - Edit mode for a sub-page
+- `/[actor]/p/[page]/copy` - Copy a page to your own site
+- `/[actor]/og.png` - Dynamic OG image generation
+- `/[actor]/api/refresh` - Cache refresh endpoint
+- `/[actor]/.well-known/site.standard.publication` - Site publication metadata
- `/api/geocoding` - Geocoding API for map cards
-- `/api/reloadRecent`, `/api/update` - Additional data endpoints
### Item Type
diff --git a/src/lib/atproto/auth.svelte.ts b/src/lib/atproto/auth.svelte.ts
index b9dd5e29..1715862e 100644
--- a/src/lib/atproto/auth.svelte.ts
+++ b/src/lib/atproto/auth.svelte.ts
@@ -126,6 +126,8 @@ async function startAuthorization(identity?: ActorIdentifier) {
scope: metadata.scope
});
+ localStorage.setItem('login-redirect', location.pathname + location.search);
+
// let browser persist local storage
await new Promise((resolve) => setTimeout(resolve, 200));
diff --git a/src/lib/website/EditBar.svelte b/src/lib/website/EditBar.svelte
index 7ed8dfd6..1fd53a91 100644
--- a/src/lib/website/EditBar.svelte
+++ b/src/lib/website/EditBar.svelte
@@ -63,7 +63,7 @@
function getShareUrl() {
const base = typeof window !== 'undefined' ? window.location.origin : '';
const pagePath =
- data.page && data.page !== 'blento.self' ? `/${data.page.replace('blento.', '')}` : '';
+ data.page && data.page !== 'blento.self' ? `/p/${data.page.replace('blento.', '')}` : '';
return `${base}/${data.handle}${pagePath}`;
}
diff --git a/src/lib/website/SaveModal.svelte b/src/lib/website/SaveModal.svelte
index 0be64218..dd8d42a9 100644
--- a/src/lib/website/SaveModal.svelte
+++ b/src/lib/website/SaveModal.svelte
@@ -15,7 +15,7 @@
function getShareUrl() {
const base = typeof window !== 'undefined' ? window.location.origin : '';
- const pagePath = page && page !== 'blento.self' ? `/${page.replace('blento.', '')}` : '';
+ const pagePath = page && page !== 'blento.self' ? `/p/${page.replace('blento.', '')}` : '';
return `${base}/${handle}${pagePath}`;
}
diff --git a/src/routes/(auth)/oauth/callback/+page.svelte b/src/routes/(auth)/oauth/callback/+page.svelte
index 097a332a..99b8899c 100644
--- a/src/routes/(auth)/oauth/callback/+page.svelte
+++ b/src/routes/(auth)/oauth/callback/+page.svelte
@@ -7,9 +7,18 @@
let startedErrorTimer = $state();
+ let hasRedirected = $state(false);
+
$effect(() => {
if (user.profile) {
- goto('/' + getHandleOrDid(user.profile) + '/edit', {});
+ if (hasRedirected) return;
+
+ const redirect = localStorage.getItem('login-redirect');
+ localStorage.removeItem('login-redirect');
+ console.log('redirect', redirect);
+ goto(redirect || '/' + getHandleOrDid(user.profile) + '/edit', {});
+
+ hasRedirected = true;
}
if (!user.isInitializing && !startedErrorTimer) {
@@ -17,14 +26,14 @@
setTimeout(() => {
showError = true;
- }, 3000);
+ }, 5000);
}
});
-{#if user.isInitializing || !showError}
+{#if !showError}
Loading...
-{:else if showError}
+{:else}
There was an error signing you in, please go back to the
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 5a9eff70..4f99a9de 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -18,13 +18,6 @@
onMount(() => {
initClient({ customDomain: data.customDomain });
-
- const error = page.url.searchParams.get('error');
- if (error) {
- const msg = errorMessages[error]?.(page.url.searchParams) ?? error;
- toast.error(msg);
- goto(page.url.pathname, { replaceState: true });
- }
});
@@ -33,6 +26,7 @@
+
{#if videoPlayer.id}
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts
deleted file mode 100644
index c5c69968..00000000
--- a/src/routes/+page.server.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { loadData } from '$lib/website/load';
-import { env } from '$env/dynamic/public';
-import { env as privateEnv } from '$env/dynamic/private';
-import { createCache } from '$lib/cache';
-import type { ActorIdentifier } from '@atcute/lexicons';
-
-export async function load({ platform, request }) {
- const handle = env.PUBLIC_HANDLE;
-
- const kv = platform?.env?.CUSTOM_DOMAINS;
-
- const cache = createCache(platform);
- const customDomain = request.headers.get('X-Custom-Domain')?.toLocaleLowerCase();
-
- if (kv && customDomain) {
- try {
- const did = await kv.get(customDomain);
-
- if (did) return await loadData(did as ActorIdentifier, cache, false, 'self', privateEnv);
- } catch (error) {
- console.error('failed to get custom domain kv', error);
- }
- }
-
- return await loadData(handle as ActorIdentifier, cache, false, 'self', privateEnv);
-}
diff --git a/src/routes/[[actor=actor]]/(pages)/+layout.server.ts b/src/routes/[[actor=actor]]/(pages)/+layout.server.ts
new file mode 100644
index 00000000..69e6cb2e
--- /dev/null
+++ b/src/routes/[[actor=actor]]/(pages)/+layout.server.ts
@@ -0,0 +1,40 @@
+import { loadData } from '$lib/website/load';
+import { env } from '$env/dynamic/private';
+import { error } from '@sveltejs/kit';
+import { createCache } from '$lib/cache';
+import type { ActorIdentifier } from '@atcute/lexicons';
+import { env as publicEnv } from '$env/dynamic/public';
+
+export async function load({ params, platform, request }) {
+ if (env.PUBLIC_IS_SELFHOSTED) error(404);
+
+ const cache = createCache(platform);
+
+ const customDomain = request.headers.get('X-Custom-Domain')?.toLowerCase();
+
+ let actor: ActorIdentifier | undefined = params.actor;
+
+ if (!actor) {
+ const kv = platform?.env?.CUSTOM_DOMAINS;
+
+ if (kv && customDomain) {
+ try {
+ const did = await kv.get(customDomain);
+
+ if (did) actor = did as ActorIdentifier;
+ } catch (error) {
+ console.error('failed to get custom domain kv', error);
+ }
+ } else {
+ actor = publicEnv.PUBLIC_HANDLE as ActorIdentifier;
+ }
+ } else if (customDomain && params.actor) {
+ actor = undefined;
+ }
+
+ if (!actor) {
+ throw error(404, 'Page not found');
+ }
+
+ return await loadData(actor, cache, false, params.page, env);
+}
diff --git a/src/routes/+page.svelte b/src/routes/[[actor=actor]]/(pages)/+page.svelte
similarity index 100%
rename from src/routes/+page.svelte
rename to src/routes/[[actor=actor]]/(pages)/+page.svelte
diff --git a/src/routes/[actor=actor]/(pages)/edit/+page.svelte b/src/routes/[[actor=actor]]/(pages)/edit/+page.svelte
similarity index 100%
rename from src/routes/[actor=actor]/(pages)/edit/+page.svelte
rename to src/routes/[[actor=actor]]/(pages)/edit/+page.svelte
diff --git a/src/routes/[actor=actor]/(pages)/+page.svelte b/src/routes/[[actor=actor]]/(pages)/p/[[page]]/+page.svelte
similarity index 100%
rename from src/routes/[actor=actor]/(pages)/+page.svelte
rename to src/routes/[[actor=actor]]/(pages)/p/[[page]]/+page.svelte
diff --git a/src/routes/p/[[page]]/copy/+page.svelte b/src/routes/[[actor=actor]]/(pages)/p/[[page]]/copy/+page.svelte
similarity index 98%
rename from src/routes/p/[[page]]/copy/+page.svelte
rename to src/routes/[[actor=actor]]/(pages)/p/[[page]]/copy/+page.svelte
index b456d151..cfcfb41a 100644
--- a/src/routes/p/[[page]]/copy/+page.svelte
+++ b/src/routes/[[actor=actor]]/(pages)/p/[[page]]/copy/+page.svelte
@@ -159,7 +159,7 @@
success = true;
// Redirect to the logged-in user's destination page edit
- const destPath = destinationPage.trim() === '' ? '' : `/${destinationPage.trim()}`;
+ const destPath = destinationPage.trim() === '' ? '' : `/p/${destinationPage.trim()}`;
setTimeout(() => {
goto(`/${userHandle}${destPath}/edit`);
}, 1000);
diff --git a/src/routes/[actor=actor]/(pages)/p/[[page]]/edit/+page.svelte b/src/routes/[[actor=actor]]/(pages)/p/[[page]]/edit/+page.svelte
similarity index 100%
rename from src/routes/[actor=actor]/(pages)/p/[[page]]/edit/+page.svelte
rename to src/routes/[[actor=actor]]/(pages)/p/[[page]]/edit/+page.svelte
diff --git a/src/routes/[[actor=actor]]/.well-known/site.standard.publication/+server.ts b/src/routes/[[actor=actor]]/.well-known/site.standard.publication/+server.ts
new file mode 100644
index 00000000..faccbef3
--- /dev/null
+++ b/src/routes/[[actor=actor]]/.well-known/site.standard.publication/+server.ts
@@ -0,0 +1,44 @@
+import { loadData } from '$lib/website/load';
+import { createCache } from '$lib/cache';
+import { env } from '$env/dynamic/private';
+import { env as publicEnv } from '$env/dynamic/public';
+import type { ActorIdentifier } from '@atcute/lexicons';
+
+import { error } from '@sveltejs/kit';
+import { text } from '@sveltejs/kit';
+
+export async function GET({ params, platform, request }) {
+ const cache = createCache(platform);
+
+ const customDomain = request.headers.get('X-Custom-Domain')?.toLowerCase();
+
+ let actor: ActorIdentifier | undefined = params.actor;
+
+ if (!actor) {
+ const kv = platform?.env?.CUSTOM_DOMAINS;
+
+ if (kv && customDomain) {
+ try {
+ const did = await kv.get(customDomain);
+
+ if (did) actor = did as ActorIdentifier;
+ } catch (error) {
+ console.error('failed to get custom domain kv', error);
+ }
+ } else {
+ actor = publicEnv.PUBLIC_HANDLE as ActorIdentifier;
+ }
+ } else if (customDomain && params.actor) {
+ actor = undefined;
+ }
+
+ if (!actor) {
+ throw error(404, 'Page not found');
+ }
+
+ const data = await loadData(actor, cache, false, params.page, env);
+
+ if (!data.publication) throw error(300);
+
+ return text(data.did + '/site.standard.publication/blento.self');
+}
diff --git a/src/routes/[[actor=actor]]/api/refresh/+server.ts b/src/routes/[[actor=actor]]/api/refresh/+server.ts
new file mode 100644
index 00000000..66105123
--- /dev/null
+++ b/src/routes/[[actor=actor]]/api/refresh/+server.ts
@@ -0,0 +1,37 @@
+import { createCache } from '$lib/cache';
+import { loadData } from '$lib/website/load.js';
+import { env } from '$env/dynamic/private';
+import { env as publicEnv } from '$env/dynamic/public';
+import type { ActorIdentifier } from '@atcute/lexicons';
+import { error, json } from '@sveltejs/kit';
+
+export async function GET({ params, platform, request }) {
+ const cache = createCache(platform);
+ if (!cache) return json('no cache');
+
+ const customDomain = request.headers.get('X-Custom-Domain')?.toLowerCase();
+
+ let actor: ActorIdentifier | undefined = params.actor;
+
+ if (!actor) {
+ const kv = platform?.env?.CUSTOM_DOMAINS;
+
+ if (kv && customDomain) {
+ try {
+ const did = await kv.get(customDomain);
+
+ if (did) actor = did as ActorIdentifier;
+ } catch (error) {
+ console.error('failed to get custom domain kv', error);
+ }
+ } else {
+ actor = publicEnv.PUBLIC_HANDLE as ActorIdentifier;
+ }
+ }
+
+ if (!actor) {
+ throw error(404, 'Page not found');
+ }
+
+ return json(await loadData(actor, cache, true, 'self', env));
+}
diff --git a/src/routes/[actor=actor]/og.png/+server.ts b/src/routes/[[actor=actor]]/og.png/+server.ts
similarity index 71%
rename from src/routes/[actor=actor]/og.png/+server.ts
rename to src/routes/[[actor=actor]]/og.png/+server.ts
index c469fb32..1fbc59a4 100644
--- a/src/routes/[actor=actor]/og.png/+server.ts
+++ b/src/routes/[[actor=actor]]/og.png/+server.ts
@@ -2,8 +2,11 @@ import { getCDNImageBlobUrl } from '$lib/atproto/methods.js';
import { createCache } from '$lib/cache';
import { loadData } from '$lib/website/load';
import { env } from '$env/dynamic/private';
-import type { Handle } from '@atcute/lexicons';
+import { env as publicEnv } from '$env/dynamic/public';
+
+import type { ActorIdentifier } from '@atcute/lexicons';
import { ImageResponse } from '@ethercorps/sveltekit-og';
+import { error } from '@sveltejs/kit';
function escapeHtml(str: string): string {
return str
@@ -14,10 +17,34 @@ function escapeHtml(str: string): string {
.replace(/'/g, ''');
}
-export async function GET({ params, platform }) {
+export async function GET({ params, platform, request }) {
const cache = createCache(platform);
- const data = await loadData(params.actor, cache, false, 'self', env);
+ const customDomain = request.headers.get('X-Custom-Domain')?.toLowerCase();
+
+ let actor: ActorIdentifier | undefined = params.actor;
+
+ if (!actor) {
+ const kv = platform?.env?.CUSTOM_DOMAINS;
+
+ if (kv && customDomain) {
+ try {
+ const did = await kv.get(customDomain);
+
+ if (did) actor = did as ActorIdentifier;
+ } catch (error) {
+ console.error('failed to get custom domain kv', error);
+ }
+ } else {
+ actor = publicEnv.PUBLIC_HANDLE as ActorIdentifier;
+ }
+ }
+
+ if (!actor) {
+ throw error(404, 'Page not found');
+ }
+
+ const data = await loadData(actor, cache, false, 'self', env);
let image: string | undefined = data.profile.avatar;
diff --git a/src/routes/[actor=actor]/(pages)/+layout.server.ts b/src/routes/[actor=actor]/(pages)/+layout.server.ts
deleted file mode 100644
index 4150b37c..00000000
--- a/src/routes/[actor=actor]/(pages)/+layout.server.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { loadData } from '$lib/website/load';
-import { env } from '$env/dynamic/private';
-import { error } from '@sveltejs/kit';
-import { createCache } from '$lib/cache';
-
-export async function load({ params, platform, request }) {
- if (env.PUBLIC_IS_SELFHOSTED) error(404);
-
- const cache = createCache(platform);
-
- const customDomain = request.headers.get('X-Custom-Domain');
-
- if (customDomain) {
- throw error(404, 'Page not found!');
- }
-
- return await loadData(params.actor, cache, false, params.page, env);
-}
diff --git a/src/routes/[actor=actor]/(pages)/p/[[page]]/+page.svelte b/src/routes/[actor=actor]/(pages)/p/[[page]]/+page.svelte
deleted file mode 100644
index 3419d88c..00000000
--- a/src/routes/[actor=actor]/(pages)/p/[[page]]/+page.svelte
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
diff --git a/src/routes/[actor=actor]/(pages)/p/[[page]]/copy/+page.svelte b/src/routes/[actor=actor]/(pages)/p/[[page]]/copy/+page.svelte
deleted file mode 100644
index b456d151..00000000
--- a/src/routes/[actor=actor]/(pages)/p/[[page]]/copy/+page.svelte
+++ /dev/null
@@ -1,252 +0,0 @@
-
-
-