Mooring
-Your professional presence in the Atmosphere, at your own domain.
- -diff --git a/apps/web/src/lib/components/ClaimBanner.svelte b/apps/web/src/lib/components/ClaimBanner.svelte new file mode 100644 index 0000000..0cd8278 --- /dev/null +++ b/apps/web/src/lib/components/ClaimBanner.svelte @@ -0,0 +1,74 @@ + + + + + diff --git a/apps/web/src/lib/handle-input.ts b/apps/web/src/lib/handle-input.ts new file mode 100644 index 0000000..7a4adc1 --- /dev/null +++ b/apps/web/src/lib/handle-input.ts @@ -0,0 +1,4 @@ +/** Typed handle → canonical form: trimmed, lowercased, leading @ dropped. */ +export function normalizeHandleInput(raw: string): string { + return raw.trim().replace(/^@/, '').toLowerCase(); +} diff --git a/apps/web/src/lib/text-display.test.ts b/apps/web/src/lib/text-display.test.ts index 135361b..9148fd3 100644 --- a/apps/web/src/lib/text-display.test.ts +++ b/apps/web/src/lib/text-display.test.ts @@ -3,6 +3,7 @@ import { linkify } from './linkify'; import { formatDate, yearOf, yearSpan } from './date-format'; import { blobProxyPath } from './blob-proxy'; import { clampSummary } from './summary-clamp'; +import { normalizeHandleInput } from './handle-input'; describe('linkify', () => { it('links full URLs and bare domains, leaving other text alone', () => { @@ -117,3 +118,12 @@ describe('yearOf and yearSpan', () => { expect(yearSpan(undefined, '2018')).toBe(''); }); }); + +describe('normalizeHandleInput', () => { + it('trims, lowercases, and drops a leading @', () => { + expect(normalizeHandleInput(' @You.Bsky.Social ')).toBe('you.bsky.social'); + expect(normalizeHandleInput('jzweifel.dev')).toBe('jzweifel.dev'); + expect(normalizeHandleInput('@')).toBe(''); + expect(normalizeHandleInput(' ')).toBe(''); + }); +}); diff --git a/apps/web/src/routes/+page.server.ts b/apps/web/src/routes/+page.server.ts index 43ead83..160c311 100644 --- a/apps/web/src/routes/+page.server.ts +++ b/apps/web/src/routes/+page.server.ts @@ -1,7 +1,9 @@ -import { error } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; +import { error, fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; import { resolveSite } from '$lib/server/render/resolve'; import { loadSiteView } from '$lib/server/render/site'; +import { resolveHandle } from '$lib/server/atmosphere/identity'; +import { normalizeHandleInput } from '$lib/handle-input'; /** On a tenant host the root is the site's home; on the app host, the landing page. */ export const load: PageServerLoad = async ({ locals }) => { @@ -13,3 +15,27 @@ export const load: PageServerLoad = async ({ locals }) => { const site = await loadSiteView(resolved.ctx, resolved.handle, resolved.sources); return { site, base: '' }; }; + +export const actions: Actions = { + /** Handle → its preview. Resolution happens here so a typo fails inline. */ + preview: async ({ request, locals }) => { + if (locals.tenant) error(404, 'Not found.'); + + const form = await request.formData(); + const handle = normalizeHandleInput(String(form.get('handle') ?? '')); + if (!handle) { + return fail(400, { handle, message: 'Enter a handle (ex: you.bsky.social).' }); + } + + try { + await resolveHandle(handle); + } catch { + return fail(400, { + handle, + message: `Nothing in the Atmosphere answers to “${handle}”. Check the spelling and try again.` + }); + } + + redirect(303, `/s/${encodeURIComponent(handle)}`); + } +}; diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index e9aff0f..20ac989 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -3,9 +3,9 @@ import SiteLayout from '$lib/components/theme/SiteLayout.svelte'; import SiteSections from '$lib/components/theme/SiteSections.svelte'; import { blobProxyPath } from '$lib/blob-proxy'; - import type { PageData } from './$types'; + import type { ActionData, PageData } from './$types'; - let { data }: { data: PageData } = $props(); + let { data, form }: { data: PageData; form: ActionData } = $props(); const ogImage = $derived.by(() => { if (!data.site) return undefined; @@ -35,10 +35,10 @@ {/if} {:else} -
Your professional presence in the Atmosphere, at your own domain.
- -+ Mooring gathers what you've made across the Atmosphere — your Bluesky profile, your + writing, your career — into a professional site at your own domain. The records stay + in your PDS, where they've always been yours. +
++ The page is assembled live from what you've already published — Bluesky, standard.site, + sifa. If you've been making things, it's ready. +
++ Your pages and settings are records in your own PDS, not rows in ours. Leave anytime; + everything goes with you. +
++ A free subdomain to start, your own domain when you're ready — and on atproto, your + domain is your handle is your proof. +
+