Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 13 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):**

Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/lib/atproto/auth.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
2 changes: 1 addition & 1 deletion src/lib/website/EditBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}

Expand Down
2 changes: 1 addition & 1 deletion src/lib/website/SaveModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}

Expand Down
17 changes: 13 additions & 4 deletions src/routes/(auth)/oauth/callback/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,33 @@

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) {
startedErrorTimer = true;

setTimeout(() => {
showError = true;
}, 3000);
}, 5000);
}
});
</script>

{#if user.isInitializing || !showError}
{#if !showError}
<div class="flex min-h-screen w-full items-center justify-center text-3xl">Loading...</div>
{:else if showError}
{:else}
<div class="flex min-h-screen w-full items-center justify-center text-3xl">
<span class="max-w-xl text-center font-medium"
>There was an error signing you in, please go back to the
Expand Down
8 changes: 1 addition & 7 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
</script>

Expand All @@ -33,6 +26,7 @@
</Tooltip.Provider>

<ThemeToggle class="fixed top-2 left-2 z-10" />

<Toaster />

{#if videoPlayer.id}
Expand Down
26 changes: 0 additions & 26 deletions src/routes/+page.server.ts

This file was deleted.

40 changes: 40 additions & 0 deletions src/routes/[[actor=actor]]/(pages)/+layout.server.ts
Original file line number Diff line number Diff line change
@@ -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);
}
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
}
37 changes: 37 additions & 0 deletions src/routes/[[actor=actor]]/api/refresh/+server.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,10 +17,34 @@ function escapeHtml(str: string): string {
.replace(/'/g, '&#39;');
}

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;

Expand Down
18 changes: 0 additions & 18 deletions src/routes/[actor=actor]/(pages)/+layout.server.ts

This file was deleted.

13 changes: 0 additions & 13 deletions src/routes/[actor=actor]/(pages)/p/[[page]]/+page.svelte

This file was deleted.

Loading