diff --git a/package-lock.json b/package-lock.json
index 35d4fc2..9047e2e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,9 @@
"dependencies": {
"@atproto/api": "^0.20.5",
"@atproto/oauth-client-browser": "^0.4.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/modifiers": "^9.0.0",
+ "@dnd-kit/sortable": "^10.0.0",
"@vercel/analytics": "^1.6.1",
"@vercel/og": "^0.8.6",
"framer-motion": "^12.23.26",
@@ -689,6 +692,73 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/modifiers": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
+ "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
diff --git a/package.json b/package.json
index 526e924..6171eeb 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,9 @@
"dependencies": {
"@atproto/api": "^0.20.5",
"@atproto/oauth-client-browser": "^0.4.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/modifiers": "^9.0.0",
+ "@dnd-kit/sortable": "^10.0.0",
"@vercel/analytics": "^1.6.1",
"@vercel/og": "^0.8.6",
"framer-motion": "^12.23.26",
diff --git a/src/app/account/layout.tsx b/src/app/account/layout.tsx
new file mode 100644
index 0000000..f47e1ae
--- /dev/null
+++ b/src/app/account/layout.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from 'react';
+import Header from '@/components/Header';
+
+// AtprotoSessionProvider lives in the root layout, so we don't re-wrap.
+// Just the page chrome.
+export default function AccountLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx
new file mode 100644
index 0000000..36744f4
--- /dev/null
+++ b/src/app/account/page.tsx
@@ -0,0 +1,13 @@
+import type { Metadata } from 'next';
+import AccountPage from '@/components/account/AccountPage';
+
+export const metadata: Metadata = {
+ title: 'Account · aturi.to',
+ description: 'Customize your Aturi waypoints and preferences.',
+ // Don't index the account page — it's gated by sign-in and personalized.
+ robots: { index: false, follow: false },
+};
+
+export default function Account() {
+ return ;
+}
diff --git a/src/app/explore/layout.tsx b/src/app/explore/layout.tsx
index 481c529..c9b7d89 100644
--- a/src/app/explore/layout.tsx
+++ b/src/app/explore/layout.tsx
@@ -1,45 +1,21 @@
-'use client';
-
import type { ReactNode } from 'react';
-import { AtprotoSessionProvider } from '@/components/explore/AtprotoSessionProvider';
import Header from '@/components/Header';
-import SessionBadge from '@/components/explore/SessionBadge';
+// AtprotoSessionProvider now lives in the root layout, so the session and
+// the Header's sign-in menu are available on every page. This layout just
+// adds the explore-specific page chrome and pins min-height so route
+// transitions don't shrink the viewport.
export default function ExploreLayout({ children }: { children: ReactNode }) {
- return (
-
-
-
-
- {children}
-
-
- );
-}
-
-/**
- * Thin one-line strip beneath the compact header. Only renders when the
- * user is signed in (SessionBadge handles the empty case internally).
- */
-function SessionStrip() {
return (
-
+
+ {children}
);
}
diff --git a/src/app/globals.css b/src/app/globals.css
index 5630f5b..78d3809 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1181,6 +1181,22 @@ html {
}
}
+/* Spinner used by the account-page PDS sync status indicator. */
+.explore-spin {
+ animation: explore-spin 0.9s linear infinite;
+}
+
+@keyframes explore-spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .explore-spin {
+ animation: none;
+ }
+}
+
@media (prefers-reduced-motion: reduce) {
.explore-jetstream-row[data-fresh] {
animation: none;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 66ab48f..cb49041 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,7 @@
import type { Metadata } from "next";
import { Analytics } from "@vercel/analytics/next";
+import { AtprotoSessionProvider } from "@/components/AtprotoSessionProvider";
+import { PreferencesProvider } from "@/components/PreferencesProvider";
import Footer from "@/components/Footer";
import PageTransition from "@/components/PageTransition";
import ThemeSync from "@/components/ThemeSync";
@@ -45,9 +47,13 @@ export default function RootLayout({
-
- {children}
-
+
+
+
+ {children}
+
+
+
diff --git a/src/components/explore/AtprotoSessionProvider.tsx b/src/components/AtprotoSessionProvider.tsx
similarity index 100%
rename from src/components/explore/AtprotoSessionProvider.tsx
rename to src/components/AtprotoSessionProvider.tsx
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 5d8e9ac..b5fd1e5 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { Home, Link2, Leaf, UserPlus, Telescope } from 'lucide-react';
import { motion } from 'framer-motion';
import ThemeToggle from './ThemeToggle';
+import SessionMenu from './SessionMenu';
interface HeaderProps {
simple?: boolean; // If true, shows a smaller version without the tagline
@@ -109,25 +110,28 @@ export default function Header({ simple = false, compact = false }: HeaderProps)
- {/* Expandable menu button */}
- setIsExpanded(!isExpanded)}
- style={{
- padding: '0.5rem',
- background: 'var(--bg-tertiary)',
- border: '1px solid var(--border-medium)',
- color: 'var(--text-accent)',
- transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
- transform: isExpanded ? 'rotate(90deg) scale(1.1)' : 'rotate(0deg)',
- }}
- aria-label="Toggle menu"
- aria-expanded={isExpanded}
- >
-
-
+ {/* Session pill + expandable menu button */}
+
+
+ setIsExpanded(!isExpanded)}
+ style={{
+ padding: '0.5rem',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-medium)',
+ color: 'var(--text-accent)',
+ transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
+ transform: isExpanded ? 'rotate(90deg) scale(1.1)' : 'rotate(0deg)',
+ }}
+ aria-label="Toggle menu"
+ aria-expanded={isExpanded}
+ >
+
+
+
{/* Expanding organic nav panel */}
@@ -271,6 +275,8 @@ export default function Header({ simple = false, compact = false }: HeaderProps)
·
+ ·
+
);
@@ -376,6 +382,8 @@ export default function Header({ simple = false, compact = false }: HeaderProps)
·
+ ·
+
);
diff --git a/src/components/PreferencesProvider.tsx b/src/components/PreferencesProvider.tsx
new file mode 100644
index 0000000..10e03ce
--- /dev/null
+++ b/src/components/PreferencesProvider.tsx
@@ -0,0 +1,194 @@
+'use client';
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react';
+import {
+ DEFAULT_PREFERENCES,
+ preferencesAreEqual,
+ readLocalPreferences,
+ writeLocalPreferences,
+ type Preferences,
+} from '@/utils/preferences';
+import {
+ pickNewer,
+ readPreferencesFromPds,
+ writePreferencesToPds,
+} from '@/utils/atproto/preferencesPds';
+import { useAtprotoSession } from './AtprotoSessionProvider';
+
+type PreferencesContextValue = {
+ prefs: Preferences;
+ /**
+ * Replace the prefs with a new value. Always writes localStorage
+ * synchronously; debounces a PDS write when signed in.
+ */
+ update: (updater: (prev: Preferences) => Preferences) => void;
+ /** Drop user prefs back to defaults (local only — does NOT delete the PDS record). */
+ reset: () => void;
+ /** True until the first read (local + PDS if signed in) has settled. */
+ loading: boolean;
+ /** PDS sync state. `null` for anonymous users. */
+ pdsSync: 'idle' | 'syncing' | 'error' | null;
+};
+
+const Ctx = createContext(null);
+
+const PDS_WRITE_DEBOUNCE_MS = 1500;
+
+/**
+ * Local-first preferences with PDS mirror when signed in.
+ *
+ * Lifecycle:
+ *
+ * - Mount: read localStorage into state. `loading = true` only during a
+ * PDS read (anonymous users settle in one tick).
+ * - Sign in: read PDS prefs. Merge with local (newer wins). If local was
+ * newer (or PDS was missing), write back to PDS so the cross-device
+ * copy catches up.
+ * - Sign out: drop the agent — keep local prefs in place so anonymous
+ * customization persists.
+ * - Every update(): write local immediately, schedule a debounced PDS
+ * write if signed in.
+ */
+export function PreferencesProvider({ children }: { children: ReactNode }) {
+ const { agent, did, loading: sessionLoading } = useAtprotoSession();
+ const [prefs, setPrefs] = useState(DEFAULT_PREFERENCES);
+ const [loading, setLoading] = useState(true);
+ const [pdsSync, setPdsSync] = useState(null);
+ const pdsWriteTimer = useRef | null>(null);
+ const lastSyncedDid = useRef(null);
+
+ // Step 1: hydrate from localStorage on mount.
+ useEffect(() => {
+ setPrefs(readLocalPreferences());
+ setLoading(false);
+ }, []);
+
+ // Step 2: when the session resolves, reconcile with PDS prefs.
+ useEffect(() => {
+ if (sessionLoading) return;
+ if (!agent || !did) {
+ // Anonymous (or signed out) — no PDS sync, local prefs stand.
+ setPdsSync(null);
+ lastSyncedDid.current = null;
+ return;
+ }
+ // Avoid re-running for the same DID (session reload shouldn't reset prefs).
+ if (lastSyncedDid.current === did) return;
+ lastSyncedDid.current = did;
+
+ let cancelled = false;
+ setPdsSync('syncing');
+ setLoading(true);
+
+ (async () => {
+ const local = readLocalPreferences();
+ const result = await readPreferencesFromPds(agent, did);
+ if (cancelled) return;
+
+ if (result.status === 'error') {
+ setPdsSync('error');
+ // Local prefs stand for now; we'll retry on the next session change.
+ } else if (result.status === 'missing') {
+ // Nothing in the PDS yet — push the local copy up so the user's
+ // anonymous customization carries over to other devices.
+ if (
+ local.customWaypoints.length > 0 ||
+ local.hiddenWaypoints.length > 0 ||
+ local.waypointOrder.length > 0
+ ) {
+ try {
+ await writePreferencesToPds(agent, did, local);
+ if (!cancelled) setPdsSync('idle');
+ } catch {
+ if (!cancelled) setPdsSync('error');
+ }
+ } else {
+ setPdsSync('idle');
+ }
+ // Local already matches state, so no state update needed.
+ } else {
+ // Both exist — pick the newer copy.
+ const winning = pickNewer(local, result.prefs);
+ if (!preferencesAreEqual(prefs, winning)) {
+ setPrefs(winning);
+ writeLocalPreferences(winning);
+ }
+ // If local won, push back up to PDS.
+ if (winning === local && !preferencesAreEqual(local, result.prefs)) {
+ try {
+ await writePreferencesToPds(agent, did, local);
+ } catch {
+ if (!cancelled) setPdsSync('error');
+ return;
+ }
+ }
+ if (!cancelled) setPdsSync('idle');
+ }
+ if (!cancelled) setLoading(false);
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ // We don't depend on `prefs` here — that would re-sync on every local edit.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [agent, did, sessionLoading]);
+
+ const update = useCallback(
+ (updater: (prev: Preferences) => Preferences) => {
+ setPrefs((prev) => {
+ const next = { ...updater(prev), updatedAt: new Date().toISOString() };
+ writeLocalPreferences(next);
+ // Debounce PDS write — burst edits (DnD reorder, typing) collapse
+ // into a single network call.
+ if (agent && did) {
+ if (pdsWriteTimer.current) clearTimeout(pdsWriteTimer.current);
+ pdsWriteTimer.current = setTimeout(async () => {
+ setPdsSync('syncing');
+ try {
+ await writePreferencesToPds(agent, did, next);
+ setPdsSync('idle');
+ } catch {
+ setPdsSync('error');
+ }
+ }, PDS_WRITE_DEBOUNCE_MS);
+ }
+ return next;
+ });
+ },
+ [agent, did],
+ );
+
+ const reset = useCallback(() => {
+ update(() => ({ ...DEFAULT_PREFERENCES, updatedAt: new Date().toISOString() }));
+ }, [update]);
+
+ // Flush pending PDS write on unmount.
+ useEffect(() => {
+ return () => {
+ if (pdsWriteTimer.current) clearTimeout(pdsWriteTimer.current);
+ };
+ }, []);
+
+ const value = useMemo(
+ () => ({ prefs, update, reset, loading, pdsSync }),
+ [prefs, update, reset, loading, pdsSync],
+ );
+
+ return {children} ;
+}
+
+export function usePreferences(): PreferencesContextValue {
+ const v = useContext(Ctx);
+ if (!v) throw new Error('usePreferences must be used inside ');
+ return v;
+}
diff --git a/src/components/SessionMenu.tsx b/src/components/SessionMenu.tsx
new file mode 100644
index 0000000..28142e3
--- /dev/null
+++ b/src/components/SessionMenu.tsx
@@ -0,0 +1,370 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import Link from 'next/link';
+import { ChevronDown, LogIn, LogOut, Settings, Telescope, User } from 'lucide-react';
+import { useAtprotoSession } from './AtprotoSessionProvider';
+import { getProfile, type AppViewProfile } from '@/utils/atproto/appview';
+import { encodeRepo } from '@/utils/atproto/urls';
+
+type Variant = 'compact' | 'inline' | 'pill';
+
+/**
+ * Session control for the nav. Renders a "Sign in" button when signed out
+ * and an avatar pill (with a small menu) when signed in.
+ *
+ * Three visual variants to fit the different Header modes:
+ * - 'compact' — fits the universal-link compact header card
+ * - 'inline' — fits the simple / default header's centered nav row
+ * - 'pill' — standalone (used in account-page header context)
+ */
+export default function SessionMenu({ variant = 'inline' }: { variant?: Variant }) {
+ const { session, did, signIn, signOut, loading } = useAtprotoSession();
+ const [open, setOpen] = useState(false);
+ const [signInInput, setSignInInput] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [profile, setProfile] = useState(null);
+ const rootRef = useRef(null);
+
+ // Close on outside click.
+ useEffect(() => {
+ if (!open) return;
+ function onClick(e: MouseEvent) {
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener('mousedown', onClick);
+ return () => document.removeEventListener('mousedown', onClick);
+ }, [open]);
+
+ // Lazy-load profile so the menu can show avatar + display name.
+ useEffect(() => {
+ if (!did) {
+ setProfile(null);
+ return undefined;
+ }
+ let cancelled = false;
+ getProfile(did).then((p) => {
+ if (!cancelled) setProfile(p);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [did]);
+
+ if (loading) return null;
+
+ // ─── Signed out ─────────────────────────────────────────────────────────
+ if (!session) {
+ return (
+
+ setOpen((o) => !o)}
+ aria-expanded={open}
+ aria-haspopup="dialog"
+ style={triggerButtonStyle(variant)}
+ >
+
+ Sign in
+
+ {open && (
+ {
+ const v = signInInput.trim();
+ if (!v) return;
+ setBusy(true);
+ setError(null);
+ try {
+ await signIn(v);
+ } catch (err) {
+ setBusy(false);
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ }}
+ />
+ )}
+
+ );
+ }
+
+ // ─── Signed in ──────────────────────────────────────────────────────────
+ const handle = profile?.handle || did?.slice(0, 24);
+ const displayName = profile?.displayName?.trim() || handle;
+ const avatar = profile?.avatar;
+
+ return (
+
+
setOpen((o) => !o)}
+ aria-expanded={open}
+ aria-haspopup="menu"
+ title={handle || ''}
+ style={pillStyle(variant)}
+ >
+ {avatar ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+
+
+ )}
+
+ {displayName}
+
+
+
+
+ {open && did && (
+
+
+
+ {displayName}
+
+
+ @{handle}
+
+
+
}
+ label="My repo in Explorer"
+ onClick={() => setOpen(false)}
+ />
+
}
+ label="Account settings"
+ onClick={() => setOpen(false)}
+ />
+
{
+ setOpen(false);
+ await signOut();
+ }}
+ style={menuItemStyle({ danger: true })}
+ >
+
+ Sign out
+
+
+ )}
+
+ );
+}
+
+// ─── Sub-components ────────────────────────────────────────────────────────
+
+function SignInPopover({
+ value,
+ onChange,
+ busy,
+ error,
+ onSubmit,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ busy: boolean;
+ error: string | null;
+ onSubmit: () => void;
+}) {
+ return (
+
+
+
+ Sign in with your atproto handle
+
+
+ We’ll redirect you to your PDS to authorize Aturi.
+
+
+
+
+ );
+}
+
+function MenuLink({
+ href,
+ icon,
+ label,
+ onClick,
+}: {
+ href: string;
+ icon: React.ReactNode;
+ label: string;
+ onClick: () => void;
+}) {
+ return (
+
+ {icon}
+ {label}
+
+ );
+}
+
+// ─── Style helpers ─────────────────────────────────────────────────────────
+
+function triggerButtonStyle(variant: Variant): React.CSSProperties {
+ const base: React.CSSProperties = {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.375rem',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-subtle)',
+ color: 'var(--text-secondary)',
+ fontFamily: 'var(--font-serif)',
+ cursor: 'pointer',
+ transition: 'border-color 0.2s ease, color 0.2s ease',
+ };
+ if (variant === 'inline') {
+ return { ...base, padding: '0.5rem 0.875rem', fontSize: '0.95rem' };
+ }
+ return { ...base, padding: '0.35rem 0.625rem', fontSize: '0.8125rem' };
+}
+
+function pillStyle(variant: Variant): React.CSSProperties {
+ const base: React.CSSProperties = {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.4rem',
+ padding: variant === 'inline' ? '0.4rem 0.625rem' : '0.3rem 0.5rem',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-subtle)',
+ color: 'var(--text-secondary)',
+ fontFamily: 'var(--font-serif)',
+ fontSize: variant === 'inline' ? '0.9rem' : '0.8125rem',
+ cursor: 'pointer',
+ transition: 'border-color 0.2s ease, color 0.2s ease',
+ };
+ return base;
+}
+
+function menuStyle(extra?: React.CSSProperties): React.CSSProperties {
+ return {
+ position: 'absolute',
+ top: 'calc(100% + 0.5rem)',
+ right: 0,
+ minWidth: '14rem',
+ background: 'var(--bg-secondary)',
+ border: '1px solid var(--border-medium)',
+ boxShadow: 'var(--shadow-overlay)',
+ zIndex: 60,
+ display: 'flex',
+ flexDirection: 'column',
+ ...extra,
+ };
+}
+
+function menuHeaderStyle(): React.CSSProperties {
+ return {
+ padding: '0.625rem 0.75rem',
+ borderBottom: '1px solid var(--border-subtle)',
+ };
+}
+
+function menuItemStyle({ danger }: { danger?: boolean } = {}): React.CSSProperties {
+ return {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ padding: '0.5rem 0.75rem',
+ background: 'transparent',
+ border: 0,
+ color: danger ? 'var(--danger)' : 'var(--text-secondary)',
+ fontFamily: 'var(--font-serif)',
+ fontSize: '0.8125rem',
+ cursor: 'pointer',
+ textDecoration: 'none',
+ textAlign: 'left',
+ };
+}
diff --git a/src/components/WaypointPicker.tsx b/src/components/WaypointPicker.tsx
index dc51fc2..95a7825 100644
--- a/src/components/WaypointPicker.tsx
+++ b/src/components/WaypointPicker.tsx
@@ -2,12 +2,18 @@
import { useState, useMemo, useEffect } from 'react';
import { ExternalLink, Copy, Check } from 'lucide-react';
-import {
- getCategorizedWaypoints,
+import {
+ getCategorizedWaypoints,
getRecommendedWaypoints,
getWaypointsForType,
- type WaypointType
+ type WaypointType
} from '@/utils/waypoints';
+import {
+ personalizeCategorized,
+ personalizeRecommended,
+ customToWaypoint,
+} from '@/utils/personalizeWaypoints';
+import { usePreferences } from './PreferencesProvider';
import ShareButton from './ShareButton';
import CategoryCard from './CategoryCard';
@@ -30,22 +36,35 @@ export default function WaypointPicker({
}: WaypointPickerProps) {
const display = displayName || `@${handle}`;
const [copiedId, setCopiedId] = useState(null);
+ const { prefs } = usePreferences();
- // Get categorized waypoints and featured waypoint
- const categorizedWaypoints = useMemo(() => getCategorizedWaypoints(type), [type]);
+ // Get categorized waypoints and featured waypoint, with user prefs applied
+ // (hidden built-ins removed, custom waypoints added as their own group,
+ // user-defined ordering respected within each category).
+ const categorizedWaypoints = useMemo(
+ () => personalizeCategorized(getCategorizedWaypoints(type), prefs, type),
+ [type, prefs],
+ );
const recommendedData = useMemo(
- () => getRecommendedWaypoints(type, collection),
+ () => getRecommendedWaypoints(type, collection),
[type, collection]
);
const recommendedWaypoints = useMemo(
- () => recommendedData?.waypoints || [],
- [recommendedData]
+ () => personalizeRecommended(recommendedData?.waypoints || [], prefs),
+ [recommendedData, prefs]
);
const recommendedLabel = useMemo(
() => recommendedData?.label || '',
[recommendedData]
);
- const availableWaypoints = useMemo(() => getWaypointsForType(type), [type]);
+ const availableWaypoints = useMemo(() => {
+ const builtins = getWaypointsForType(type);
+ const customs = prefs.customWaypoints
+ .filter((c) => c.supportedTypes.includes(type))
+ .map(customToWaypoint);
+ const hidden = new Set(prefs.hiddenWaypoints);
+ return [...customs, ...builtins.filter((w) => !hidden.has(w.id))];
+ }, [type, prefs]);
// Smart expansion: Compute initial expanded categories based on compatible waypoints
const initialExpandedCategories = useMemo(() => {
diff --git a/src/components/account/AccountPage.tsx b/src/components/account/AccountPage.tsx
new file mode 100644
index 0000000..8aafebd
--- /dev/null
+++ b/src/components/account/AccountPage.tsx
@@ -0,0 +1,272 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import Link from 'next/link';
+import { CheckCircle2, CircleAlert, Loader2, LogOut, Telescope, User } from 'lucide-react';
+import { useAtprotoSession } from '@/components/AtprotoSessionProvider';
+import { usePreferences } from '@/components/PreferencesProvider';
+import { getProfile, type AppViewProfile } from '@/utils/atproto/appview';
+import { encodeRepo } from '@/utils/atproto/urls';
+import WaypointsManager from './WaypointsManager';
+
+export default function AccountPage() {
+ const { session, did, signIn, signOut, loading } = useAtprotoSession();
+ const { pdsSync } = usePreferences();
+ const [input, setInput] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [profile, setProfile] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!did) {
+ setProfile(null);
+ return undefined;
+ }
+ let cancelled = false;
+ getProfile(did).then((p) => {
+ if (!cancelled) setProfile(p);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [did]);
+
+ if (loading) {
+ return Loading account…
;
+ }
+
+ if (!session) {
+ return (
+
+
+ Sign in to customize Aturi
+
+
+ Reorder waypoints, hide ones you don’t use, or add your own. Your
+ preferences sync to your PDS so they follow you across devices.
+
+
+
+ You’ll be redirected to your PDS to authorize Aturi. We only request the
+ permissions needed to read and write your preferences record.
+
+
+ );
+ }
+
+ const handle = profile?.handle;
+ const displayName = profile?.displayName?.trim() || handle || did;
+ const avatar = profile?.avatar;
+
+ return (
+
+ {/* Account header */}
+
+ {avatar ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+
+
+ )}
+
+
+ {displayName}
+
+ {handle && (
+
+ @{handle}
+
+ )}
+
+
+ {did && (
+
+ My repo
+
+ )}
+ void signOut()} style={ghostLinkStyle({ danger: true })}>
+ Sign out
+
+
+
+
+ {/* PDS sync status */}
+
+
+ {/* The actual preference editor */}
+
+
+ );
+}
+
+function SyncStatus({
+ pdsSync,
+}: {
+ pdsSync: ReturnType['pdsSync'];
+}) {
+ if (pdsSync === null) return null;
+ let icon: React.ReactNode;
+ let label: string;
+ let color: string;
+ if (pdsSync === 'syncing') {
+ icon = ;
+ label = 'Syncing preferences to your PDS…';
+ color = 'var(--text-tertiary)';
+ } else if (pdsSync === 'idle') {
+ icon = ;
+ label = 'Preferences synced to your PDS.';
+ color = 'var(--text-accent)';
+ } else {
+ icon = ;
+ label = 'Preference sync to PDS failed — local changes are saved.';
+ color = 'var(--danger)';
+ }
+ return (
+
+ {icon}
+ {label}
+
+ );
+}
+
+function ghostLinkStyle({ danger }: { danger?: boolean } = {}): React.CSSProperties {
+ return {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.375rem',
+ padding: '0.4rem 0.75rem',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-medium)',
+ color: danger ? 'var(--danger)' : 'var(--text-secondary)',
+ fontFamily: 'var(--font-serif)',
+ fontSize: '0.8125rem',
+ cursor: 'pointer',
+ textDecoration: 'none',
+ };
+}
diff --git a/src/components/account/CustomWaypointForm.tsx b/src/components/account/CustomWaypointForm.tsx
new file mode 100644
index 0000000..0851bd9
--- /dev/null
+++ b/src/components/account/CustomWaypointForm.tsx
@@ -0,0 +1,303 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import { Save, X } from 'lucide-react';
+import {
+ newCustomWaypointId,
+ type CustomWaypoint,
+} from '@/utils/preferences';
+import type { WaypointType } from '@/utils/waypoints.data';
+
+const TYPE_OPTIONS: { id: WaypointType; label: string; hint: string }[] = [
+ { id: 'profile', label: 'Profile', hint: 'Opens the user’s profile page' },
+ { id: 'post', label: 'Post', hint: 'Opens an app.bsky.feed.post record' },
+ { id: 'list', label: 'List', hint: 'Opens an app.bsky.graph.list record' },
+ { id: 'record', label: 'Any record', hint: 'Opens any AT-URI record (fallback)' },
+];
+
+type Props = {
+ initial?: CustomWaypoint;
+ onSave: (waypoint: CustomWaypoint) => void;
+ onCancel: () => void;
+};
+
+const EXAMPLE_TEMPLATES: Record = {
+ profile: 'https://example.com/{handle}',
+ post: 'https://example.com/{handle}/post/{rkey}',
+ list: 'https://example.com/{handle}/lists/{rkey}',
+ record: 'https://example.com/{actor}/{collection}/{rkey}',
+ unknown: '',
+};
+
+/**
+ * Inline form for creating or editing a custom waypoint. Templates support
+ * `{handle}`, `{did}`, `{actor}` (DID with handle fallback), `{collection}`,
+ * and `{rkey}` placeholders.
+ */
+export default function CustomWaypointForm({ initial, onSave, onCancel }: Props) {
+ const isEditing = Boolean(initial);
+ const [name, setName] = useState(initial?.name || '');
+ const [domain, setDomain] = useState(initial?.domain || '');
+ const [description, setDescription] = useState(initial?.description || '');
+ const [types, setTypes] = useState>(
+ new Set(initial?.supportedTypes || ['profile', 'post']),
+ );
+ const [templates, setTemplates] = useState>>(
+ initial?.templates || {},
+ );
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ // Make sure each enabled type has a template field present (even if
+ // empty) so users can fill them in. Don't clobber existing values.
+ setTemplates((prev) => {
+ const next = { ...prev };
+ for (const t of types) {
+ if (!(t in next)) next[t] = '';
+ }
+ return next;
+ });
+ }, [types]);
+
+ const canSubmit = useMemo(() => {
+ if (!name.trim()) return false;
+ if (types.size === 0) return false;
+ // At least one template must be filled.
+ for (const t of types) {
+ if ((templates[t] || '').trim()) return true;
+ }
+ return false;
+ }, [name, types, templates]);
+
+ function toggleType(t: WaypointType) {
+ setTypes((prev) => {
+ const next = new Set(prev);
+ if (next.has(t)) next.delete(t);
+ else next.add(t);
+ return next;
+ });
+ }
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ setError('Give the waypoint a name.');
+ return;
+ }
+
+ const enabledTypes = Array.from(types);
+ if (enabledTypes.length === 0) {
+ setError('Pick at least one type of content this waypoint handles.');
+ return;
+ }
+
+ const filledTemplates: Partial> = {};
+ for (const t of enabledTypes) {
+ const tpl = (templates[t] || '').trim();
+ if (tpl) filledTemplates[t] = tpl;
+ }
+ if (Object.keys(filledTemplates).length === 0) {
+ setError('Fill in at least one URL template.');
+ return;
+ }
+
+ const waypoint: CustomWaypoint = {
+ id: initial?.id || newCustomWaypointId(),
+ name: trimmedName,
+ domain: domain.trim() || undefined,
+ description: description.trim() || undefined,
+ supportedTypes: enabledTypes,
+ templates: filledTemplates,
+ };
+ onSave(waypoint);
+ }
+
+ return (
+
+ );
+}
+
+function Field({
+ label,
+ hint,
+ required,
+ children,
+}: {
+ label: string;
+ hint?: string;
+ required?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+ {required && * }
+
+ {children}
+ {hint && (
+
{hint}
+ )}
+
+ );
+}
diff --git a/src/components/account/SortableWaypointRow.tsx b/src/components/account/SortableWaypointRow.tsx
new file mode 100644
index 0000000..07bf55d
--- /dev/null
+++ b/src/components/account/SortableWaypointRow.tsx
@@ -0,0 +1,202 @@
+'use client';
+
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { Eye, EyeOff, GripVertical, Pencil, Trash2 } from 'lucide-react';
+import { WAYPOINT_ICONS } from '@/utils/waypointIcons';
+
+type Props = {
+ id: string;
+ name: string;
+ description?: string;
+ hidden: boolean;
+ isCustom: boolean;
+ onToggleHidden: () => void;
+ onEdit?: () => void;
+ onDelete?: () => void;
+};
+
+/**
+ * Single draggable row in the WaypointsManager list. Renders the icon,
+ * name, hide/show toggle, and (for customs) edit/delete buttons.
+ */
+export default function SortableWaypointRow({
+ id,
+ name,
+ description,
+ hidden,
+ isCustom,
+ onToggleHidden,
+ onEdit,
+ onDelete,
+}: Props) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id });
+
+ const style: React.CSSProperties = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.625rem',
+ padding: '0.625rem 0.75rem',
+ background: isDragging ? 'var(--bg-elevated)' : 'var(--bg-secondary)',
+ border: '1px solid var(--border-medium)',
+ opacity: hidden ? 0.5 : 1,
+ boxShadow: isDragging ? '0 6px 22px rgba(0, 0, 0, 0.25)' : undefined,
+ zIndex: isDragging ? 5 : 'auto',
+ };
+
+ return (
+
+
+
+
+
+
+ {WAYPOINT_ICONS[id] || (
+
+ {name.charAt(0).toUpperCase()}
+
+ )}
+
+
+
+
+ {name}
+ {isCustom && (
+
+ custom
+
+ )}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+ {hidden ? : }
+
+
+ {isCustom && onEdit && (
+
+
+
+ )}
+
+ {isCustom && onDelete && (
+
+
+
+ )}
+
+ );
+}
+
+// Local style helper. WaypointIcon needs a wrapper element type — use a span.
+function iconBtnStyle({ danger }: { danger?: boolean } = {}): React.CSSProperties {
+ return {
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: '0.35rem',
+ background: 'transparent',
+ border: '1px solid var(--border-subtle)',
+ color: danger ? 'var(--danger)' : 'var(--text-secondary)',
+ cursor: 'pointer',
+ transition: 'border-color 0.2s ease, color 0.2s ease',
+ };
+}
diff --git a/src/components/account/WaypointsManager.tsx b/src/components/account/WaypointsManager.tsx
new file mode 100644
index 0000000..863a584
--- /dev/null
+++ b/src/components/account/WaypointsManager.tsx
@@ -0,0 +1,257 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import {
+ DndContext,
+ KeyboardSensor,
+ PointerSensor,
+ closestCenter,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from '@dnd-kit/core';
+import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers';
+import {
+ SortableContext,
+ arrayMove,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { Plus, RotateCcw } from 'lucide-react';
+import { usePreferences } from '@/components/PreferencesProvider';
+import { type CustomWaypoint } from '@/utils/preferences';
+import {
+ WAYPOINT_DESTINATIONS_DATA,
+ WAYPOINT_ORDER,
+} from '@/utils/waypoints.data';
+import SortableWaypointRow from './SortableWaypointRow';
+import CustomWaypointForm from './CustomWaypointForm';
+
+/**
+ * Account-page UI for personalizing the waypoint catalog. Three operations:
+ *
+ * 1. Reorder built-in + custom waypoints (drag-and-drop).
+ * 2. Hide built-in waypoints from the picker (eye toggle).
+ * 3. Add / edit / delete custom waypoints (templates with placeholders).
+ *
+ * State lives in PreferencesProvider — local-first, mirrored to PDS when
+ * signed in.
+ */
+export default function WaypointsManager() {
+ const { prefs, update } = usePreferences();
+ const [editingId, setEditingId] = useState(null);
+ const [addingNew, setAddingNew] = useState(false);
+
+ // Compose the visible list: ordered union of custom + built-in ids.
+ const orderedIds = useMemo(() => {
+ const customIds = prefs.customWaypoints.map((c) => c.id);
+ const builtinIds = [...WAYPOINT_ORDER];
+ const allIds = [...customIds, ...builtinIds];
+ const userOrder = prefs.waypointOrder.length > 0 ? prefs.waypointOrder : [];
+ if (userOrder.length === 0) return allIds;
+ const seen = new Set();
+ const out: string[] = [];
+ for (const id of userOrder) {
+ if (allIds.includes(id) && !seen.has(id)) {
+ out.push(id);
+ seen.add(id);
+ }
+ }
+ for (const id of allIds) {
+ if (!seen.has(id)) out.push(id);
+ }
+ return out;
+ }, [prefs.customWaypoints, prefs.waypointOrder]);
+
+ const customById = useMemo(
+ () => new Map(prefs.customWaypoints.map((c) => [c.id, c])),
+ [prefs.customWaypoints],
+ );
+
+ const hiddenSet = useMemo(() => new Set(prefs.hiddenWaypoints), [prefs.hiddenWaypoints]);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 4 },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ const oldIndex = orderedIds.indexOf(String(active.id));
+ const newIndex = orderedIds.indexOf(String(over.id));
+ if (oldIndex < 0 || newIndex < 0) return;
+ const nextOrder = arrayMove(orderedIds, oldIndex, newIndex);
+ update((prev) => ({ ...prev, waypointOrder: nextOrder }));
+ }
+
+ function toggleHidden(id: string) {
+ update((prev) => {
+ const next = new Set(prev.hiddenWaypoints);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return { ...prev, hiddenWaypoints: Array.from(next) };
+ });
+ }
+
+ function saveCustom(w: CustomWaypoint) {
+ update((prev) => {
+ const exists = prev.customWaypoints.some((c) => c.id === w.id);
+ const customWaypoints = exists
+ ? prev.customWaypoints.map((c) => (c.id === w.id ? w : c))
+ : [...prev.customWaypoints, w];
+ return { ...prev, customWaypoints };
+ });
+ setEditingId(null);
+ setAddingNew(false);
+ }
+
+ function deleteCustom(id: string) {
+ update((prev) => ({
+ ...prev,
+ customWaypoints: prev.customWaypoints.filter((c) => c.id !== id),
+ hiddenWaypoints: prev.hiddenWaypoints.filter((h) => h !== id),
+ waypointOrder: prev.waypointOrder.filter((o) => o !== id),
+ }));
+ }
+
+ function resetOrder() {
+ update((prev) => ({ ...prev, waypointOrder: [] }));
+ }
+
+ const visibleCount = orderedIds.length - prefs.hiddenWaypoints.length;
+ const total = orderedIds.length;
+ const editingWaypoint = editingId ? customById.get(editingId) : undefined;
+
+ return (
+
+
+
+
+ Waypoints
+
+
+ Reorder, hide, or add your own. Affects every universal link page on the
+ site.
+
+
+
+
+ {visibleCount} of {total} visible
+
+ {prefs.waypointOrder.length > 0 && (
+
+ Reset order
+
+ )}
+
+
+
+ {(addingNew || editingWaypoint) && (
+ {
+ setAddingNew(false);
+ setEditingId(null);
+ }}
+ />
+ )}
+
+ {!addingNew && !editingWaypoint && (
+ setAddingNew(true)}
+ style={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.4rem',
+ alignSelf: 'flex-start',
+ padding: '0.55rem 0.875rem',
+ background: 'var(--accent-moss)',
+ color: 'var(--text-on-accent)',
+ border: '1px solid var(--accent-moss)',
+ fontFamily: 'var(--font-serif)',
+ fontSize: '0.875rem',
+ cursor: 'pointer',
+ }}
+ >
+ Add custom waypoint
+
+ )}
+
+
+
+
+ {orderedIds.map((id) => {
+ const custom = customById.get(id);
+ const builtin = !custom ? WAYPOINT_DESTINATIONS_DATA[id] : undefined;
+ if (!custom && !builtin) return null;
+ const name = custom?.name || builtin?.name || id;
+ const description = custom?.description
+ || (builtin && typeof builtin.description === 'string' ? builtin.description : undefined);
+ return (
+ toggleHidden(id)}
+ onEdit={custom ? () => setEditingId(id) : undefined}
+ onDelete={custom ? () => deleteCustom(id) : undefined}
+ />
+ );
+ })}
+
+
+
+
+ );
+}
+
+function ghostBtnStyle(): React.CSSProperties {
+ return {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.35rem',
+ padding: '0.3rem 0.625rem',
+ background: 'transparent',
+ border: '1px solid var(--border-medium)',
+ color: 'var(--text-tertiary)',
+ fontFamily: 'var(--font-serif)',
+ fontSize: '0.75rem',
+ cursor: 'pointer',
+ };
+}
diff --git a/src/components/explore/RecordExplorer.tsx b/src/components/explore/RecordExplorer.tsx
index 9e7c187..e160fee 100644
--- a/src/components/explore/RecordExplorer.tsx
+++ b/src/components/explore/RecordExplorer.tsx
@@ -14,7 +14,7 @@ import EngagementSidecar from './EngagementSidecar';
import BacklinksTab from './tabs/BacklinksTab';
import RecordEditor from './RecordEditor';
import SignInPanel from './SignInPanel';
-import { useAtprotoSession } from './AtprotoSessionProvider';
+import { useAtprotoSession } from '@/components/AtprotoSessionProvider';
type Props = {
repo: string;
diff --git a/src/components/explore/SessionBadge.tsx b/src/components/explore/SessionBadge.tsx
index 2a4c912..4a65c11 100644
--- a/src/components/explore/SessionBadge.tsx
+++ b/src/components/explore/SessionBadge.tsx
@@ -2,7 +2,7 @@
import Link from 'next/link';
import { LogOut, User } from 'lucide-react';
-import { useAtprotoSession } from './AtprotoSessionProvider';
+import { useAtprotoSession } from '@/components/AtprotoSessionProvider';
import { encodeRepo } from '@/utils/atproto/urls';
/**
diff --git a/src/components/explore/SignInPanel.tsx b/src/components/explore/SignInPanel.tsx
index 8b2d7c4..05efc94 100644
--- a/src/components/explore/SignInPanel.tsx
+++ b/src/components/explore/SignInPanel.tsx
@@ -2,7 +2,7 @@
import { useState } from 'react';
import { LogIn } from 'lucide-react';
-import { useAtprotoSession } from './AtprotoSessionProvider';
+import { useAtprotoSession } from '@/components/AtprotoSessionProvider';
/**
* Compact sign-in form used inside the record view's action row. Accepts a
diff --git a/src/utils/atproto/preferencesPds.ts b/src/utils/atproto/preferencesPds.ts
new file mode 100644
index 0000000..b1d137a
--- /dev/null
+++ b/src/utils/atproto/preferencesPds.ts
@@ -0,0 +1,98 @@
+/**
+ * Read / write the user's Aturi preferences to their PDS as a
+ * `to.aturi.actor.preferences/self` record.
+ *
+ * Lexicon (informal):
+ *
+ * $type: to.aturi.actor.preferences
+ * hiddenWaypoints?: string[]
+ * waypointOrder?: string[]
+ * customWaypoints?: Array<{
+ * id, name, domain?, description?,
+ * supportedTypes: string[],
+ * templates: { post?, profile?, list?, record?: string }
+ * }>
+ * updatedAt: datetime
+ */
+
+import type { Agent } from '@atproto/api';
+import {
+ DEFAULT_PREFERENCES,
+ mergeWithDefaults,
+ type Preferences,
+} from '../preferences';
+
+export const PREFERENCES_NSID = 'to.aturi.actor.preferences';
+export const PREFERENCES_RKEY = 'self';
+
+export type PdsReadResult =
+ | { status: 'ok'; prefs: Preferences }
+ | { status: 'missing' }
+ | { status: 'error'; error: string };
+
+/**
+ * Fetch the user's preferences record from their PDS. Returns
+ * `{ status: 'missing' }` when the record doesn't exist (common case for
+ * first-time sign-in), `{ status: 'error' }` for transport failures.
+ */
+export async function readPreferencesFromPds(
+ agent: Agent,
+ did: string,
+): Promise {
+ try {
+ const res = await agent.com.atproto.repo.getRecord({
+ repo: did,
+ collection: PREFERENCES_NSID,
+ rkey: PREFERENCES_RKEY,
+ });
+ const value = (res?.data || res) as { value?: unknown };
+ const prefs = mergeWithDefaults(value?.value as Partial | null);
+ return { status: 'ok', prefs };
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ // The PDS responds with `RecordNotFound` (HTTP 400 / NotFound) when the
+ // record simply hasn't been created yet. Treat that as "no prefs in
+ // PDS" rather than an error worth surfacing.
+ if (/RecordNotFound|Could not locate record/i.test(msg) || /\b400\b/.test(msg)) {
+ return { status: 'missing' };
+ }
+ return { status: 'error', error: msg };
+ }
+}
+
+/**
+ * Persist preferences to the user's PDS. Always writes a fresh `updatedAt`
+ * so concurrent edits from another device are detectable on next load.
+ */
+export async function writePreferencesToPds(
+ agent: Agent,
+ did: string,
+ prefs: Preferences,
+): Promise {
+ const record = {
+ $type: PREFERENCES_NSID,
+ hiddenWaypoints: prefs.hiddenWaypoints,
+ waypointOrder: prefs.waypointOrder,
+ customWaypoints: prefs.customWaypoints,
+ updatedAt: new Date().toISOString(),
+ };
+ await agent.com.atproto.repo.putRecord({
+ repo: did,
+ collection: PREFERENCES_NSID,
+ rkey: PREFERENCES_RKEY,
+ record,
+ });
+}
+
+/**
+ * Pick the "newer" of two preferences blobs by `updatedAt`. When the
+ * timestamps are equal, prefer `b` (typically the PDS copy on first
+ * sign-in, since cross-device sync is the higher-value direction).
+ */
+export function pickNewer(a: Preferences, b: Preferences): Preferences {
+ if (!a) return b;
+ if (!b) return a;
+ return new Date(b.updatedAt).getTime() >= new Date(a.updatedAt).getTime() ? b : a;
+}
+
+export { DEFAULT_PREFERENCES };
diff --git a/src/utils/personalizeWaypoints.tsx b/src/utils/personalizeWaypoints.tsx
new file mode 100644
index 0000000..7c8481d
--- /dev/null
+++ b/src/utils/personalizeWaypoints.tsx
@@ -0,0 +1,139 @@
+/**
+ * Apply user preferences (hide, reorder, custom additions) to the built-in
+ * waypoint catalog. Consumed by WaypointPicker and the account page.
+ *
+ * Keeps the data layer (`waypoints.data.ts` / `waypoints.tsx`) pure — those
+ * exports stay as the default unmodified catalog. Personalization happens
+ * at the call site.
+ */
+
+import { Globe } from 'lucide-react';
+import {
+ expandTemplate,
+ type CustomWaypoint,
+ type Preferences,
+} from './preferences';
+import type {
+ Waypoint,
+ WaypointCategory,
+ CategorizedWaypoints,
+} from './waypoints';
+import type { WaypointType, RedirectCompatFamily } from './waypoints.data';
+
+export const CUSTOM_CATEGORY: WaypointCategory = {
+ id: 'custom',
+ name: 'My Waypoints',
+ description: 'Personal waypoints you added on the account page',
+ defaultWaypointId: '',
+};
+
+/**
+ * Promote a CustomWaypoint into a Waypoint-shaped object that the
+ * WaypointPicker / catalog UIs can render directly. The synthetic `getUrl`
+ * expands the template; returns null when the inputs don't satisfy the
+ * template's placeholders.
+ */
+export function customToWaypoint(c: CustomWaypoint): Waypoint {
+ return {
+ id: c.id,
+ name: c.name,
+ description: c.description || (c.domain ? `Open on ${c.domain}` : 'Custom waypoint'),
+ supportedTypes: c.supportedTypes,
+ category: CUSTOM_CATEGORY.id,
+ redirectCompat: [] as RedirectCompatFamily[],
+ getUrl: (handle, collection, rkey, did) => {
+ const tplKey: WaypointType =
+ collection && rkey
+ ? c.supportedTypes.includes('post' as WaypointType) && collection === 'app.bsky.feed.post'
+ ? ('post' as WaypointType)
+ : c.supportedTypes.includes('list' as WaypointType) && collection === 'app.bsky.graph.list'
+ ? ('list' as WaypointType)
+ : c.supportedTypes.includes('record' as WaypointType)
+ ? ('record' as WaypointType)
+ : ('post' as WaypointType)
+ : ('profile' as WaypointType);
+ const template = c.templates[tplKey] || c.templates.record || c.templates.profile;
+ if (!template) return null;
+ return expandTemplate(template, { handle, did, collection, rkey });
+ },
+ icon: (
+
+ ),
+ };
+}
+
+/**
+ * Filter a flat waypoint array against user prefs (hide built-ins).
+ * Custom waypoints are NOT injected here — callers do that explicitly
+ * because the injection point differs (recommended vs categorized).
+ */
+export function applyHidden(waypoints: Waypoint[], prefs: Preferences): Waypoint[] {
+ if (prefs.hiddenWaypoints.length === 0) return waypoints;
+ const hidden = new Set(prefs.hiddenWaypoints);
+ return waypoints.filter((w) => !hidden.has(w.id));
+}
+
+/**
+ * Apply a user-defined ordering to a waypoint list. Items in `order` come
+ * first in that order; everything else preserves the catalog's default
+ * sequence at the end.
+ */
+export function applyOrder(waypoints: Waypoint[], prefs: Preferences): Waypoint[] {
+ if (prefs.waypointOrder.length === 0) return waypoints;
+ const byId = new Map(waypoints.map((w) => [w.id, w]));
+ const used = new Set();
+ const ordered: Waypoint[] = [];
+ for (const id of prefs.waypointOrder) {
+ const w = byId.get(id);
+ if (w) {
+ ordered.push(w);
+ used.add(id);
+ }
+ }
+ for (const w of waypoints) {
+ if (!used.has(w.id)) ordered.push(w);
+ }
+ return ordered;
+}
+
+/**
+ * Personalize a categorized list. Built-ins respecting hidden/order;
+ * customs added as a top-of-list group when present.
+ */
+export function personalizeCategorized(
+ categorized: CategorizedWaypoints[],
+ prefs: Preferences,
+ type: WaypointType,
+): CategorizedWaypoints[] {
+ // First, filter + reorder built-in groups.
+ const cleaned = categorized
+ .map(({ category, waypoints }) => ({
+ category,
+ waypoints: applyOrder(applyHidden(waypoints, prefs), prefs),
+ }))
+ .filter((g) => g.waypoints.length > 0);
+
+ // Then, prepend the user's custom waypoints (if any apply to this type).
+ const customs = prefs.customWaypoints
+ .filter((c) => c.supportedTypes.includes(type))
+ .map(customToWaypoint);
+
+ if (customs.length === 0) return cleaned;
+ return [{ category: CUSTOM_CATEGORY, waypoints: customs }, ...cleaned];
+}
+
+/**
+ * Personalize a recommended waypoints bundle — drop hidden ids, preserve
+ * the recommendation's original order otherwise. Customs are NOT recommended
+ * (we have no signal for that yet).
+ */
+export function personalizeRecommended(
+ waypoints: Waypoint[],
+ prefs: Preferences,
+): Waypoint[] {
+ return applyHidden(waypoints, prefs);
+}
diff --git a/src/utils/preferences.ts b/src/utils/preferences.ts
new file mode 100644
index 0000000..59c879a
--- /dev/null
+++ b/src/utils/preferences.ts
@@ -0,0 +1,177 @@
+/**
+ * User preference schema and localStorage helpers.
+ *
+ * Preferences are persisted in two places, in priority order:
+ *
+ * 1. The signed-in user's PDS as a `to.aturi.actor.preferences/self`
+ * record. This is the cross-device source of truth — when the user
+ * signs in on a new browser, this is what gets loaded.
+ * 2. localStorage (`aturi.prefs.v1`). Used for anonymous customization
+ * and as a fast-path / fallback when the PDS isn't reachable.
+ *
+ * On sign-in: if the PDS record exists, it overwrites local. If the PDS
+ * record is missing but local has prefs, local is pushed to the PDS so
+ * the user's existing customization carries over.
+ *
+ * Writes are local-first and instant; PDS writes are debounced (handled
+ * by the PreferencesProvider).
+ */
+
+import type { WaypointType } from './waypoints.data';
+
+const LS_KEY = 'aturi.prefs.v1';
+
+export type CustomWaypoint = {
+ id: string; // 'custom:'
+ name: string;
+ domain?: string; // display hint, not used for routing
+ description?: string;
+ supportedTypes: WaypointType[];
+ /** URL templates with `{handle}`, `{did}`, `{collection}`, `{rkey}` placeholders. */
+ templates: Partial>;
+};
+
+export type Preferences = {
+ /** Built-in waypoint ids the user has explicitly hidden. */
+ hiddenWaypoints: string[];
+ /**
+ * Explicit ordering for built-in + custom waypoints. ids not in this list
+ * sort to the end in their default order. Empty array = use defaults.
+ */
+ waypointOrder: string[];
+ /** User-defined waypoints. */
+ customWaypoints: CustomWaypoint[];
+ /**
+ * ISO timestamp of last local change. Used to break ties when local and
+ * PDS prefs both exist on sign-in.
+ */
+ updatedAt: string;
+};
+
+export const DEFAULT_PREFERENCES: Preferences = {
+ hiddenWaypoints: [],
+ waypointOrder: [],
+ customWaypoints: [],
+ updatedAt: new Date(0).toISOString(),
+};
+
+/**
+ * Cheap UUID for custom waypoint ids. Doesn't need crypto-strength
+ * uniqueness — collisions are O(local prefs size).
+ */
+export function newCustomWaypointId(): string {
+ const rand = Math.random().toString(36).slice(2, 10);
+ const ts = Date.now().toString(36);
+ return `custom:${ts}${rand}`;
+}
+
+/**
+ * Read preferences from localStorage. Returns DEFAULT_PREFERENCES if no
+ * stored prefs exist or parsing fails.
+ */
+export function readLocalPreferences(): Preferences {
+ if (typeof window === 'undefined') return DEFAULT_PREFERENCES;
+ try {
+ const raw = window.localStorage.getItem(LS_KEY);
+ if (!raw) return DEFAULT_PREFERENCES;
+ const parsed = JSON.parse(raw) as Partial;
+ return mergeWithDefaults(parsed);
+ } catch {
+ return DEFAULT_PREFERENCES;
+ }
+}
+
+/**
+ * Persist preferences to localStorage. Auto-bumps `updatedAt` if the
+ * caller didn't.
+ */
+export function writeLocalPreferences(prefs: Preferences): void {
+ if (typeof window === 'undefined') return;
+ try {
+ const stamped: Preferences = {
+ ...prefs,
+ updatedAt: prefs.updatedAt || new Date().toISOString(),
+ };
+ window.localStorage.setItem(LS_KEY, JSON.stringify(stamped));
+ } catch {
+ // Quota exceeded or storage disabled — non-fatal.
+ }
+}
+
+export function clearLocalPreferences(): void {
+ if (typeof window === 'undefined') return;
+ try {
+ window.localStorage.removeItem(LS_KEY);
+ } catch {
+ // ignore
+ }
+}
+
+/**
+ * Fill missing fields on a possibly-stale stored object with defaults.
+ * Keeps the schema forward-compatible.
+ */
+export function mergeWithDefaults(input: Partial | null | undefined): Preferences {
+ if (!input || typeof input !== 'object') return DEFAULT_PREFERENCES;
+ return {
+ hiddenWaypoints: Array.isArray(input.hiddenWaypoints) ? input.hiddenWaypoints : [],
+ waypointOrder: Array.isArray(input.waypointOrder) ? input.waypointOrder : [],
+ customWaypoints: Array.isArray(input.customWaypoints)
+ ? input.customWaypoints.filter(isValidCustomWaypoint)
+ : [],
+ updatedAt:
+ typeof input.updatedAt === 'string' ? input.updatedAt : new Date(0).toISOString(),
+ };
+}
+
+function isValidCustomWaypoint(w: unknown): w is CustomWaypoint {
+ if (!w || typeof w !== 'object') return false;
+ const c = w as Record;
+ return (
+ typeof c.id === 'string' &&
+ typeof c.name === 'string' &&
+ Array.isArray(c.supportedTypes) &&
+ c.templates !== null &&
+ typeof c.templates === 'object'
+ );
+}
+
+/**
+ * Expand a custom waypoint template into a URL, substituting placeholders.
+ * Returns null if required values for the placeholders are missing.
+ */
+export function expandTemplate(
+ template: string,
+ ctx: { handle?: string; did?: string; collection?: string; rkey?: string },
+): string | null {
+ let out = template;
+ // Replace identifier placeholders first so they don't get mangled when
+ // the same template references both handle and DID. `{actor}` prefers DID,
+ // falling back to handle, mirroring the built-in waypoint convention.
+ const actor = ctx.did || ctx.handle;
+ const replacements: Record = {
+ '{handle}': ctx.handle,
+ '{did}': ctx.did,
+ '{actor}': actor,
+ '{collection}': ctx.collection,
+ '{rkey}': ctx.rkey,
+ };
+ for (const [token, value] of Object.entries(replacements)) {
+ if (out.includes(token)) {
+ if (!value) return null;
+ out = out.split(token).join(encodeURIComponent(value));
+ }
+ }
+ // Undo the over-eager encoding of colons in DIDs — they're URL-safe.
+ out = out.replace(/did%3A/g, 'did:');
+ return out;
+}
+
+export function preferencesAreEqual(a: Preferences, b: Preferences): boolean {
+ return (
+ a.updatedAt === b.updatedAt &&
+ JSON.stringify(a.hiddenWaypoints) === JSON.stringify(b.hiddenWaypoints) &&
+ JSON.stringify(a.waypointOrder) === JSON.stringify(b.waypointOrder) &&
+ JSON.stringify(a.customWaypoints) === JSON.stringify(b.customWaypoints)
+ );
+}