diff --git a/src/app/api/og/static/route.tsx b/src/app/api/og/static/route.tsx index 62eefd3..f296b70 100644 --- a/src/app/api/og/static/route.tsx +++ b/src/app/api/og/static/route.tsx @@ -69,6 +69,18 @@ export async function GET(request: NextRequest) { tagline: 'Open source and ready to deploy', showBranding: true, }, + extension: { + title: 'Browser Extension', + subtitle: 'Jump between Atmosphere clients in one click', + tagline: 'Inspect AT URIs, auto-redirect by lexicon, copy universal links.', + showBranding: true, + }, + 'universal-links': { + title: 'Universal Links', + subtitle: 'One link, every Atmosphere client', + tagline: 'Share aturi.to/handle/collection/rkey — recipients pick the app.', + showBranding: true, + }, }; const content = pageContent[page] || pageContent.home; diff --git a/src/app/extension/page.tsx b/src/app/extension/page.tsx new file mode 100644 index 0000000..721c89a --- /dev/null +++ b/src/app/extension/page.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from 'next'; +import Header from '@/components/Header'; +import ExtensionLanding from '@/components/landing/ExtensionLanding'; + +export const metadata: Metadata = { + title: 'Browser Extension · aturi.to', + description: + 'Jump between Atmosphere clients in one click, inspect AT URIs anywhere on the web, and auto-redirect to your preferred app per lexicon.', + openGraph: { + title: 'Browser Extension · aturi.to', + description: + 'Jump between Atmosphere clients in one click, inspect AT URIs anywhere on the web, and auto-redirect to your preferred app per lexicon.', + images: ['/api/og/static?page=extension'], + }, + twitter: { + card: 'summary_large_image', + title: 'Browser Extension · aturi.to', + description: + 'Jump between Atmosphere clients in one click, inspect AT URIs anywhere on the web, and auto-redirect to your preferred app per lexicon.', + images: ['/api/og/static?page=extension'], + }, +}; + +export default function ExtensionPage() { + return ( +
+
+ +
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 49a9255..32a9a84 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1775,3 +1775,59 @@ html { font-size: 1.625rem; } } + +/* ---------- Product landing pages (extension, universal links) ---------- */ + +/* Hero block on landing pages stacks the copy + a hero visual side-by-side + on desktop, and stacks the visual above the copy on mobile. */ +.landing-hero { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important; +} + +.landing-hero > div:last-child { + display: flex; + justify-content: center; +} + +@media (max-width: 820px) { + .landing-hero { + grid-template-columns: minmax(0, 1fr) !important; + } +} + +/* Feature row: copy + visual two-column block, flipped order via + `data-flip`. Mirrors `.home-product-strip` but tuned for the + landing pages' narrower max width and tighter vertical rhythm. */ +.landing-feature { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 2.5rem; + align-items: center; +} + +.landing-feature[data-flip] .landing-feature-visual { + order: 2; +} + +.landing-feature-visual { + display: flex; + justify-content: center; +} + +.landing-feature-body p { + margin: 0 0 0.875rem; +} + +.landing-feature-body p:last-child { + margin-bottom: 0; +} + +@media (max-width: 820px) { + .landing-feature { + grid-template-columns: minmax(0, 1fr); + gap: 1.5rem; + } + .landing-feature[data-flip] .landing-feature-visual { + order: 0; + } +} diff --git a/src/app/universal-links/page.tsx b/src/app/universal-links/page.tsx new file mode 100644 index 0000000..6b60746 --- /dev/null +++ b/src/app/universal-links/page.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from 'next'; +import Header from '@/components/Header'; +import UniversalLinksLanding from '@/components/landing/UniversalLinksLanding'; + +export const metadata: Metadata = { + title: 'Universal Links · aturi.to', + description: + 'Share one aturi.to link and let your audience pick the Atmosphere client they want to open it in — 25+ supported clients, no lock-in, no sign-up.', + openGraph: { + title: 'Universal Links · aturi.to', + description: + 'Share one aturi.to link and let your audience pick the Atmosphere client they want to open it in — 25+ supported clients, no lock-in, no sign-up.', + images: ['/api/og/static?page=universal-links'], + }, + twitter: { + card: 'summary_large_image', + title: 'Universal Links · aturi.to', + description: + 'Share one aturi.to link and let your audience pick the Atmosphere client they want to open it in — 25+ supported clients, no lock-in, no sign-up.', + images: ['/api/og/static?page=universal-links'], + }, +}; + +export default function UniversalLinksPage() { + return ( +
+
+ +
+ ); +} diff --git a/src/components/explore/ExploreLanding.tsx b/src/components/explore/ExploreLanding.tsx index c08ba37..3dcb92a 100644 --- a/src/components/explore/ExploreLanding.tsx +++ b/src/components/explore/ExploreLanding.tsx @@ -1,10 +1,11 @@ 'use client'; import Link from 'next/link'; -import { Compass, Download, Telescope } from 'lucide-react'; +import { Telescope } from 'lucide-react'; import AppearIn from './AppearIn'; import SearchBox from './SearchBox'; import JetstreamFeed from './JetstreamFeed'; +import CrossLinkCards from '@/components/landing/CrossLinkCards'; const SUGGESTIONS = ['aturi.to', 'bsky.app', 'dame.is', 'jay.bsky.team']; @@ -90,77 +91,8 @@ export default function ExploreLanding() { -
- } - title="Universal links" - body="Share aturi.to/handle/collection/rkey with anyone — they pick the Atmosphere app to open it in." - href="/" - /> - } - title="Browser extension" - body="Inspect AT URIs on any page and fast-travel between Atmosphere apps." - href="/extension" - /> -
+
); } - -function CrossLinkCard({ - icon, - title, - body, - href, -}: { - icon: React.ReactNode; - title: string; - body: string; - href: string; -}) { - return ( - { - e.currentTarget.style.borderColor = 'var(--text-accent)'; - e.currentTarget.style.background = 'var(--bg-tertiary)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.borderColor = 'var(--border-medium)'; - e.currentTarget.style.background = 'var(--bg-secondary)'; - }} - > - {icon} -

{title}

-

- {body} -

- - ); -} diff --git a/src/components/landing/AutoRedirectVisual.tsx b/src/components/landing/AutoRedirectVisual.tsx new file mode 100644 index 0000000..3c98cb6 --- /dev/null +++ b/src/components/landing/AutoRedirectVisual.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { Check, ChevronRight } from 'lucide-react'; +import { + BlueskySVG, + DeerSVG, + LeafletSVG, + TangledSVG, + GrainSVG, +} from '@/utils/waypointIcons'; + +const ROWS: { collection: string; label: string; icon: React.ReactNode; client: string }[] = [ + { + collection: 'app.bsky.feed.post', + label: 'Posts', + icon: , + client: 'Deer', + }, + { + collection: 'pub.leaflet.document', + label: 'Documents', + icon: , + client: 'Leaflet', + }, + { + collection: 'sh.tangled.repo', + label: 'Repositories', + icon: , + client: 'Tangled', + }, + { + collection: 'social.grain.gallery', + label: 'Photo galleries', + icon: , + client: 'Grain', + }, + { + collection: 'app.bsky.actor.profile', + label: 'Profiles', + icon: , + client: 'Bluesky', + }, +]; + +/** + * Static visual of the per-lexicon auto-redirect preferences screen. + * Communicates that the extension can be told "every time you land + * on X, send me to Y" — one of the features that doesn't fit in the + * popup mock on its own. + */ +export default function AutoRedirectVisual() { + return ( +
+
+
+ Auto-redirect +
+
+ On +
+
+
+ {ROWS.map((r) => ( +
+
+
+ {r.label} +
+
+ {r.collection} +
+
+ +
+ + {r.icon} + + {r.client} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/landing/ContextMenuVisual.tsx b/src/components/landing/ContextMenuVisual.tsx new file mode 100644 index 0000000..6077f68 --- /dev/null +++ b/src/components/landing/ContextMenuVisual.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { Copy, ExternalLink, Search, Telescope } from 'lucide-react'; +import { Leaf } from 'lucide-react'; + +/** + * Static visual of the right-click context menu the extension adds + * to AT URIs found on any web page. Shows that the extension's + * inspect/jump capabilities are available without ever opening the + * popup — they're folded into the browser's native chrome. + */ +export default function ContextMenuVisual() { + return ( +
+ {/* Snippet of page content showing a selected AT URI */} +
+ Source record:{' '} + + at://did:plc:gq4…23tj/app.bsky.feed.post/3lq9c2fhz3k2v + +
+ + {/* Floating context menu */} +
+ }>Copy + }>Search the web + + } accent> + Open with Aturi + + }>Open in recommended app + }>Inspect on aturi.to +
+
+ ); +} + +function MenuRow({ + icon, + children, + accent, +}: { + icon: React.ReactNode; + children: React.ReactNode; + accent?: boolean; +}) { + return ( +
+ + {icon} + + {children} +
+ ); +} + +function Divider() { + return ( +
+ ); +} diff --git a/src/components/landing/CrossLinkCards.tsx b/src/components/landing/CrossLinkCards.tsx new file mode 100644 index 0000000..3c4e530 --- /dev/null +++ b/src/components/landing/CrossLinkCards.tsx @@ -0,0 +1,111 @@ +'use client'; + +import Link from 'next/link'; +import { Compass, Download, Telescope } from 'lucide-react'; + +type ProductKey = 'universal-links' | 'extension' | 'explore'; + +type Card = { + key: ProductKey; + href: string; + icon: React.ReactNode; + title: string; + body: string; +}; + +const CARDS: Card[] = [ + { + key: 'universal-links', + href: '/universal-links', + icon: , + title: 'Universal links', + body: 'Share aturi.to/handle/collection/rkey with anyone — they pick the Atmosphere app to open it in.', + }, + { + key: 'extension', + href: '/extension', + icon: , + title: 'Browser extension', + body: 'Inspect AT URIs on any page and fast-travel between Atmosphere apps.', + }, + { + key: 'explore', + href: '/explore', + icon: , + title: 'Atmosphere Explorer', + body: "Browse any account's PDS records, identity history, and backlinks across the Atmosphere.", + }, +]; + +/** + * Bottom-of-page "explore the rest of aturi" cards. Pass `current` to + * hide the card for the page you're already on so we don't link a page + * to itself. + */ +export default function CrossLinkCards({ current }: { current?: ProductKey }) { + const cards = CARDS.filter((c) => c.key !== current); + return ( +
+ {cards.map((c) => ( + + ))} +
+ ); +} + +function CrossLinkCard({ + icon, + title, + body, + href, +}: { + icon: React.ReactNode; + title: string; + body: string; + href: string; +}) { + return ( + { + e.currentTarget.style.borderColor = 'var(--text-accent)'; + e.currentTarget.style.background = 'var(--bg-tertiary)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'var(--border-medium)'; + e.currentTarget.style.background = 'var(--bg-secondary)'; + }} + > + {icon} +

{title}

+

+ {body} +

+ + ); +} diff --git a/src/components/landing/ExtensionLanding.tsx b/src/components/landing/ExtensionLanding.tsx new file mode 100644 index 0000000..a874881 --- /dev/null +++ b/src/components/landing/ExtensionLanding.tsx @@ -0,0 +1,283 @@ +'use client'; + +import Link from 'next/link'; +import { + Compass, + Eye, + Link2, + MousePointerClick, + Repeat, + ShieldCheck, + Zap, +} from 'lucide-react'; +import AppearIn from '@/components/explore/AppearIn'; +import BrowserChrome from '@/components/home/BrowserChrome'; +import ExtensionPopupVisual from '@/components/home/ExtensionPopupVisual'; +import DownloadButton, { BrowserFallbackList } from '@/components/home/DownloadButton'; +import { getWaypointCount } from '@/utils/waypoints'; +import CrossLinkCards from './CrossLinkCards'; +import InspectPanelVisual from './InspectPanelVisual'; +import AutoRedirectVisual from './AutoRedirectVisual'; +import ContextMenuVisual from './ContextMenuVisual'; +import FeatureSection from './FeatureSection'; + +export default function ExtensionLanding() { + const waypointCount = getWaypointCount(); + return ( +
+ {/* Hero: badge + headline + copy + CTA on the left, popup demo on the right */} + +
+
+ }>Browser extension +

+ Jump between Atmosphere clients in one click +

+

+ Land on a Bluesky post and want to read it in Deer? Click the leaf + in your toolbar — the popup detects the AT URI on the page and + offers every app that can render it. Pick a favorite once and let + auto-redirect handle the rest. +

+

+ + {waypointCount} curated Atmosphere clients +

+
+ + +
+
+
+ + + +
+
+
+ + + , label: 'One-click jump' }} + title="Every app that renders the record, one click away" + body={ + <> +

+ The popup picks up whatever AT URI is on the page and surfaces a + recommended client at the top, plus every other waypoint that + supports that lexicon — from Bluesky and Deer to Leaflet, + Tangled, Margin, Grain, and the rest of the catalog. +

+

+ Each row links straight to the equivalent page on the other + client, so there’s no copy-pasting, no fiddling with URL + schemes, no re-resolving handles. +

+ + } + visual={ + + + + } + /> +
+ + + , label: 'Inspect mode' }} + title="See the AT URI under everything" + body={ + <> +

+ Switch to the Inspect tab to read the underlying record: the DID + behind the handle, the PDS hosting it, the lexicon collection, + and the count of inbound backlinks pointing at it. +

+

+ Click any field to copy it, or jump straight into the + Atmosphere Explorer to read the raw JSON. The same data + Bluesky’s API serves, surfaced inline as you browse. +

+ + } + visual={} + /> +
+ + + , label: 'Auto-redirect' }} + title="Set it once. Every link opens where you want." + body={ + <> +

+ Pick a preferred client for each kind of record — posts in + Deer, documents in Leaflet, repositories in Tangled — and the + extension transparently sends you there whenever you click an + Atmosphere link from anywhere on the web. +

+

+ Preferences are per-lexicon and per-device. Disable + auto-redirect for one collection without losing the others, or + turn the whole thing off and the extension goes back to a + popup-only tool. +

+ + } + visual={} + /> +
+ + + , label: 'Right-click any AT URI' }} + title="Even raw URIs on random pages become first-class links" + body={ + <> +

+ The extension registers a context menu on any text that looks + like an at://{' '} + URI. Right-click it and you can open it in your recommended + app, inspect it on aturi.to, or copy a clean universal link + pointing at the same record. +

+

+ Useful in DMs, READMEs, JSON viewers, dev tools — anywhere an + AT URI shows up in plain text instead of a styled link. +

+ + } + visual={} + /> +
+ + +
+
+ Local-first +
+

+ No account required. No telemetry. No background network calls. +

+

+ The extension only talks to your PDS when you actively trigger it — + click the popup, hit Inspect, or follow a context-menu jump. + Preferences stay in your browser’s local storage; nothing is + uploaded.{' '} + + Read the privacy policy + + . +

+ +
+
+ + + + +
+ ); +} + +function Badge({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { + return ( + + {icon} + {children} + + ); +} diff --git a/src/components/landing/FeatureSection.tsx b/src/components/landing/FeatureSection.tsx new file mode 100644 index 0000000..57ca5c6 --- /dev/null +++ b/src/components/landing/FeatureSection.tsx @@ -0,0 +1,70 @@ +'use client'; + +import type { ReactNode } from 'react'; + +type Props = { + badge: { icon: ReactNode; label: string }; + title: string; + body: ReactNode; + visual: ReactNode; + /** When true, the visual sits on the left instead of the right. */ + flip?: boolean; +}; + +/** + * Two-column feature row used on the product landing pages. Mirrors + * ProductStrip from the homepage but lives outside the + * `home-product-strip` flow so landing pages can stack many of them + * without forking the homepage styles. + */ +export default function FeatureSection({ badge, title, body, visual, flip }: Props) { + return ( +
+
{visual}
+
+ + {badge.icon} + {badge.label} + +

+ {title} +

+
+ {body} +
+
+
+ ); +} diff --git a/src/components/landing/InspectPanelVisual.tsx b/src/components/landing/InspectPanelVisual.tsx new file mode 100644 index 0000000..21c6668 --- /dev/null +++ b/src/components/landing/InspectPanelVisual.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { Activity, Database, Fingerprint, Hash, Link2 } from 'lucide-react'; + +/** + * Static mock of the extension's "Inspect" tab. Shows the breakdown + * of an AT URI on the current page — authority (DID), collection + * (lexicon), record key — alongside the resolved handle and PDS. + * Used on the /extension landing page to communicate what the + * extension surfaces beyond just app-switching. + */ +export default function InspectPanelVisual() { + return ( +
+ {/* Tab strip */} +
+ Apps + Inspect + Settings +
+ + {/* Source URI strip */} +
+ at:// + did:plc:gq4…23tj + / + app.bsky.feed.post + / + 3lq9c2fhz3k2v +
+ +
+ } label="Authority"> + did:plc:gq4fo3u6tqzzdkjlwzpb23tj + + } label="Handle"> + @aturi.to + + } label="PDS"> + pds.example.host + + } label="Collection"> + app.bsky.feed.post + + } label="Backlinks"> + + 12 records + + +
+
+ ); +} + +function Tab({ children, active }: { children: React.ReactNode; active?: boolean }) { + return ( +
+ {children} +
+ ); +} + +function Field({ + icon, + label, + children, +}: { + icon: React.ReactNode; + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {icon} + {label} + + + {children} + +
+ ); +} diff --git a/src/components/landing/PickerPreviewVisual.tsx b/src/components/landing/PickerPreviewVisual.tsx new file mode 100644 index 0000000..db2ae45 --- /dev/null +++ b/src/components/landing/PickerPreviewVisual.tsx @@ -0,0 +1,205 @@ +'use client'; + +import { ChevronRight, Star } from 'lucide-react'; +import { + BlueskySVG, + DeerSVG, + LeafletSVG, + TangledSVG, + PdslsSVG, +} from '@/utils/waypointIcons'; + +const ROWS: { icon: React.ReactNode; name: string; desc: string; featured?: boolean }[] = [ + { + icon: , + name: 'Deer', + desc: 'View post on deer.social', + featured: true, + }, + { + icon: , + name: 'Bluesky', + desc: 'View post on bsky.app', + }, + { + icon: , + name: 'Leaflet', + desc: 'View document on leaflet.pub', + }, + { + icon: , + name: 'Tangled', + desc: 'View on tangled.org', + }, + { + icon: , + name: 'PDSls', + desc: 'View raw record on pdsls.dev', + }, +]; + +/** + * Static mock of the universal link picker page (the page a visitor + * actually lands on when they follow an aturi.to link). Shown on the + * /universal-links landing page so people who haven't used one yet + * can see what they'll be sharing before they click "see a live + * example". + */ +export default function PickerPreviewVisual() { + return ( +
+ {/* Pretend ProfilePreview header */} +
+
+
+
+ Aturi +
+
+ @aturi.to +
+
+
+ + {/* Recommended */} +
+
+ Recommended for posts +
+ +
+ + {/* Other clients */} +
+
+ Open in another client +
+
+ {ROWS.slice(1).map((r) => ( + + ))} +
+
+
+ ); +} + +function Row({ + icon, + name, + desc, + featured, +}: { + icon: React.ReactNode; + name: string; + desc: string; + featured?: boolean; +}) { + return ( +
+ + {icon} + +
+
+ {name} +
+
+ {desc} +
+
+ +
+ ); +} diff --git a/src/components/landing/RecordTypesGrid.tsx b/src/components/landing/RecordTypesGrid.tsx new file mode 100644 index 0000000..6b9309e --- /dev/null +++ b/src/components/landing/RecordTypesGrid.tsx @@ -0,0 +1,176 @@ +'use client'; + +import Link from 'next/link'; +import { + BookOpen, + Camera, + FileText, + GitBranch, + Highlighter, + List, + MessageSquare, + Rss, + User, +} from 'lucide-react'; + +type RecordType = { + icon: React.ReactNode; + label: string; + collection: string; + example: string; + href: string; +}; + +const TYPES: RecordType[] = [ + { + icon: , + label: 'Posts', + collection: 'app.bsky.feed.post', + example: 'aturi.to/aturi.to/post/3lq…', + href: '/profile/aturi.to', + }, + { + icon: , + label: 'Profiles', + collection: 'app.bsky.actor.profile', + example: 'aturi.to/aturi.to', + href: '/profile/aturi.to', + }, + { + icon: , + label: 'Lists', + collection: 'app.bsky.graph.list', + example: 'aturi.to/aturi.to/lists/…', + href: '/profile/aturi.to', + }, + { + icon: , + label: 'Feeds', + collection: 'app.bsky.feed.generator', + example: 'aturi.to/aturi.to/feed/…', + href: '/profile/aturi.to', + }, + { + icon: , + label: 'Documents', + collection: 'pub.leaflet.document', + example: 'aturi.to/h/pub.leaflet.…', + href: '/profile/aturi.to', + }, + { + icon: , + label: 'Repositories', + collection: 'sh.tangled.repo', + example: 'aturi.to/h/sh.tangled.repo/…', + href: '/profile/aturi.to', + }, + { + icon: , + label: 'Photo galleries', + collection: 'social.grain.gallery', + example: 'aturi.to/h/social.grain.…', + href: '/profile/aturi.to', + }, + { + icon: , + label: 'Annotations', + collection: 'at.margin.annotation', + example: 'aturi.to/h/at.margin.…', + href: '/profile/aturi.to', + }, + { + icon: , + label: 'Any other lexicon', + collection: 'collection.nsid', + example: 'aturi.to/h//', + href: '/explore', + }, +]; + +/** + * Grid of supported record types on the universal links landing page. + * Communicates breadth without forcing the visitor to skim a long + * categorized list — each tile names a record type, its lexicon NSID, + * and what a universal link to one looks like. + */ +export default function RecordTypesGrid() { + return ( +
+ {TYPES.map((t) => ( + { + e.currentTarget.style.borderColor = 'var(--text-accent)'; + e.currentTarget.style.background = 'var(--bg-tertiary)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'var(--border-medium)'; + e.currentTarget.style.background = 'var(--bg-secondary)'; + }} + > +
+ {t.icon} + + {t.label} + +
+
+ {t.collection} +
+
+ {t.example} +
+ + ))} +
+ ); +} diff --git a/src/components/landing/SharingScenariosVisual.tsx b/src/components/landing/SharingScenariosVisual.tsx new file mode 100644 index 0000000..86ef10f --- /dev/null +++ b/src/components/landing/SharingScenariosVisual.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { AtSign, MessageCircle, Quote } from 'lucide-react'; + +/** + * Three side-by-side "where you'd drop an aturi.to link" mocks. Each + * one is a tiny stylized snippet of a different real-world surface + * (a DM, a profile bio, a website footer) with an aturi.to URL inside. + * Communicates that the link is portable in a way that single-app + * deep links aren't. + */ +export default function SharingScenariosVisual() { + return ( +
+ } + label="In a DM" + author="ren" + body={ + <> + check this out —{' '} + aturi.to/aturi.to/post/3lq… + + } + /> + } + label="In a profile bio" + author="@new-account" + body={ + <> + writer · gardener · my work lives at{' '} + aturi.to/new-account.bsky.social + + } + /> + } + label="In a footer" + author="" + body={ + <> + Follow updates →{' '} + aturi.to/handle/app.bsky.feed.generator/news + + } + /> +
+ ); +} + +function Scenario({ + icon, + label, + author, + body, +}: { + icon: React.ReactNode; + label: string; + author: string; + body: React.ReactNode; +}) { + return ( +
+
+ {icon} + {label} +
+ {author && ( +
+ {author} +
+ )} +
+ {body} +
+
+ ); +} + +function Link({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/components/landing/UniversalLinksLanding.tsx b/src/components/landing/UniversalLinksLanding.tsx new file mode 100644 index 0000000..e0603a3 --- /dev/null +++ b/src/components/landing/UniversalLinksLanding.tsx @@ -0,0 +1,357 @@ +'use client'; + +import Link from 'next/link'; +import { ArrowRight, Compass, Layers, Link2, Share2, Sparkles, UserCog } from 'lucide-react'; +import AppearIn from '@/components/explore/AppearIn'; +import WaypointJumpVisual from '@/components/home/WaypointJumpVisual'; +import WaypointCarousel from '@/components/home/WaypointCarousel'; +import CrossLinkCards from './CrossLinkCards'; +import FeatureSection from './FeatureSection'; +import UrlAnatomyVisual from './UrlAnatomyVisual'; +import RecordTypesGrid from './RecordTypesGrid'; +import PickerPreviewVisual from './PickerPreviewVisual'; +import SharingScenariosVisual from './SharingScenariosVisual'; + +const DEMO_HANDLE = 'aturi.to'; +// Aturi's own DID — hardcoded so the carousel can build did-aware URLs +// without a runtime profile fetch. +const DEMO_DID = 'did:plc:gq4fo3u6tqzzdkjlwzpb23tj'; + +export default function UniversalLinksLanding() { + return ( +
+ {/* Hero */} + +
+
+ }>Universal links +

+ One link, every Atmosphere client +

+

+ Drop an{' '} + + aturi.to + {' '} + link anywhere — a DM, a footer, a bio. Your visitors land on a + friendly preview of the record and pick the Atmosphere client + they want to open it in. No client lock-in. No sign-up. Every + record, profile, list, and feed resolves to the right destination + across 25+ curated clients. +

+
+ + See a live universal link + + +
+
+
+ + +
+
+
+ + + , label: 'Anatomy of a link' }} + title="Predictable, hackable URLs" + body={ + <> +

+ Every aturi.to URL follows the same pattern: the host, a handle + or DID, the lexicon collection, and the record key. The same + shape works for every record type — there’s nothing to + memorize beyond what an AT URI already looks like. +

+

+ Drop the collection and the rkey, and you get a profile link. + Drop just the rkey, and you get a collection index. The path + always degrades gracefully. +

+ + } + visual={} + /> +
+ + +
+
+ }>Every record type +

+ Posts, profiles, lists, feeds, documents, repos, galleries… +

+

+ If a lexicon has a public record, aturi.to has a URL for it. The + same{' '} + handle/collection/rkey{' '} + pattern resolves whether the record lives in app.bsky.feed.post, + pub.leaflet.document, sh.tangled.repo, or something brand new the + ecosystem invented last week. +

+
+ +
+
+ + + , label: 'The picker' }} + title="A friendly landing page for every record" + body={ + <> +

+ Recipients land on a clean preview with a recommended client + pinned at the top and every alternative listed below. They can + read what the record is, decide where to open it, and never get + stranded in an app they don’t use. +

+

+ Each link is a real, indexable URL — embeds get OpenGraph + cards, link unfurlers get a real title and description, and + bookmarks survive client churn. +

+ + } + visual={} + /> +
+ + + , label: 'Smart preferences' }} + title="Signed in? Your defaults travel with you." + body={ + <> +

+ Sign in with your atproto handle and aturi.to remembers your + preferred client for each record type. The next time you + follow a universal link, the picker auto-resolves to your + favorite app — no extra clicks, on any device you sign in to. +

+

+ Want to share a link that forces a specific client? Add a{' '} + ?via={' '} + query param. Want to give your recipient the picker back? Drop + it. The default-vs-override decision lives in the URL, not in + the app. +

+ + } + visual={ +
+
+ Signed in as @aturi.to +
+ + + +
+ Synced to your repo · works on every device you sign in to. +
+
+ } + /> +
+ + +
+
+ }>Share anywhere +

+ The link people can actually paste into anything +

+

+ An aturi.to URL is short, readable, and copy-pasteable. It works + in Bluesky posts, group chats, blog footers, business cards, QR + codes, podcast show notes — anywhere a regular link belongs. +

+
+ +
+
+ + + + +
+ ); +} + +function Badge({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { + return ( + + {icon} + {children} + + ); +} + +function PrefRow({ lexicon, client }: { lexicon: string; client: string }) { + return ( +
+ + {lexicon} + + → + + {client} + +
+ ); +} diff --git a/src/components/landing/UrlAnatomyVisual.tsx b/src/components/landing/UrlAnatomyVisual.tsx new file mode 100644 index 0000000..81f3f69 --- /dev/null +++ b/src/components/landing/UrlAnatomyVisual.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { Link2 } from 'lucide-react'; + +type Part = { + text: string; + label: string; + hint: string; + /** Color token used for the label + underline. */ + accent: string; +}; + +const PARTS: Part[] = [ + { + text: 'aturi.to', + label: 'Domain', + hint: 'The universal link host. Aliases for atpota.to are also supported.', + accent: 'var(--text-tertiary)', + }, + { + text: '/aturi.to', + label: 'Handle or DID', + hint: 'Resolved to a PDS via the standard atproto identity chain.', + accent: 'var(--text-accent)', + }, + { + text: '/app.bsky.feed.post', + label: 'Collection', + hint: 'The lexicon NSID of the record being linked.', + accent: 'var(--accent-moss)', + }, + { + text: '/3lq9c2fhz3k2v', + label: 'Record key', + hint: 'The TID assigned by the PDS when the record was written.', + accent: 'var(--text-primary)', + }, +]; + +/** + * Annotated breakdown of an aturi.to URL. Used on the /universal-links + * landing page to teach the structure of the URL pattern at a glance — + * which segments are required, what each one means, and why the same + * pattern works for every lexicon. + */ +export default function UrlAnatomyVisual() { + return ( +
+ {/* The URL itself, with each segment underlined in its own color. */} +
+ + {PARTS.map((p) => ( + + {p.text} + + ))} +
+ + {/* Annotation rows */} +
+ {PARTS.map((p) => ( +
+
+ + {p.label} +
+
+ {p.hint} +
+
+ ))} +
+
+ ); +}