this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add internationalization scaffolding

Introduce an i18n layer (catalog, Preact context/hook, cookie +
Accept-Language middleware, and a /api/locale endpoint) so the site is
ready to ship additional languages. Only English is registered today;
the <LocaleSwitcher> self-hides until a second locale is added. All
user-facing copy across Nav, Hero, Features, Providers, Cross-pollination,
Moderation, Footer, AppShowcase, and DeveloperResources has been moved
into the typed message catalog so TypeScript enforces translation parity.

Made-with: Cursor

+738 -245
+21 -16
components/AppShowcase.tsx
··· 1 + import { useT } from "../i18n/mod.ts"; 2 + 1 3 export default function AppShowcase() { 4 + const t = useT(); 2 5 const categories = [ 3 6 { 4 - name: "Microblogs", 7 + name: t.appShowcase.categories.microblogs, 5 8 apps: [ 6 9 { name: "Bluesky", url: "https://bsky.app" }, 7 10 { name: "Blacksky", url: "https://blacksky.app" }, 8 11 ], 9 12 }, 10 13 { 11 - name: "Video", 14 + name: t.appShowcase.categories.video, 12 15 apps: [ 13 16 { name: "Spark", url: "https://spark.blue" }, 14 17 { name: "Stream.place", url: "https://stream.place" }, 15 18 ], 16 19 }, 17 20 { 18 - name: "Photos", 21 + name: t.appShowcase.categories.photos, 19 22 apps: [ 20 23 { name: "Flashes", url: "https://flashes.blue" }, 21 24 ], 22 25 }, 23 26 { 24 - name: "Blogging", 27 + name: t.appShowcase.categories.blogging, 25 28 apps: [ 26 29 { name: "Leaflet", url: "https://leaflet.pub" }, 27 30 { name: "Offprint", url: "https://offprint.blog" }, ··· 29 32 ], 30 33 }, 31 34 { 32 - name: "Events", 35 + name: t.appShowcase.categories.events, 33 36 apps: [ 34 37 { name: "Smoke Signal", url: "https://smokesignal.events" }, 35 38 { name: "Dandelion", url: "https://dandelion.events" }, ··· 37 40 ], 38 41 }, 39 42 { 40 - name: "Music & Reviews", 43 + name: t.appShowcase.categories.musicReviews, 41 44 apps: [ 42 45 { name: "teal.fm", url: "https://teal.fm" }, 43 46 { name: "Popfeed", url: "https://popfeed.app" }, 44 47 ], 45 48 }, 46 49 { 47 - name: "Collections", 50 + name: t.appShowcase.categories.collections, 48 51 apps: [ 49 52 { name: "Semble", url: "https://semble.social" }, 50 53 ], 51 54 }, 52 55 { 53 - name: "Clients", 56 + name: t.appShowcase.categories.clients, 54 57 apps: [ 55 58 { name: "Flux", url: "https://flux.blue" }, 56 59 { name: "Skyscraper", url: "#" }, ··· 62 65 <section class="section reveal"> 63 66 <div class="container"> 64 67 <div class="text-center"> 65 - <h2 class="text-section">Explore the Atmosphere.</h2> 68 + <h2 class="text-section">{t.appShowcase.heading}</h2> 66 69 <div class="divider" /> 67 - <p class="text-body mt-2" style={{ maxWidth: "600px", margin: "1rem auto 0" }}> 68 - A growing ecosystem of apps — all accessible with your one 69 - Atmosphere account. 70 + <p 71 + class="text-body mt-2" 72 + style={{ maxWidth: "600px", margin: "1rem auto 0" }} 73 + > 74 + {t.appShowcase.intro} 70 75 </p> 71 76 </div> 72 77 {categories.map((cat) => ( 73 78 <div key={cat.name} style={{ marginTop: "2.5rem" }}> 74 - <h3 class="text-subsection mb-2" style={{ textAlign: "center" }}>{cat.name}</h3> 79 + <h3 class="text-subsection mb-2" style={{ textAlign: "center" }}> 80 + {cat.name} 81 + </h3> 75 82 <div class="app-grid" style={{ marginTop: "1rem" }}> 76 83 {cat.apps.map((app) => ( 77 84 <a ··· 88 95 </div> 89 96 </div> 90 97 ))} 91 - <p class="text-body-sm text-center mt-4"> 92 - And many more being built every day. 93 - </p> 98 + <p class="text-body-sm text-center mt-4">{t.appShowcase.footnote}</p> 94 99 </div> 95 100 </section> 96 101 );
+43 -49
components/BlueskySection.tsx
··· 1 + import { useT } from "../i18n/mod.ts"; 2 + 1 3 export default function BlueskySection() { 4 + const t = useT(); 5 + const blueskyLink = ( 6 + <a 7 + href="https://bsky.app" 8 + target="_blank" 9 + rel="noopener noreferrer" 10 + class="provider-bluesky-icon-link" 11 + aria-label={t.providers.apps.blueskyAriaLabel} 12 + > 13 + <svg 14 + class="provider-bluesky-icon" 15 + width="18" 16 + height="18" 17 + viewBox="0 0 600 530" 18 + fill="currentColor" 19 + xmlns="http://www.w3.org/2000/svg" 20 + aria-hidden="true" 21 + > 22 + <path d="M135.72 44.03C202.216 93.951 273.74 195.17 300 249.49C326.26 195.17 397.784 93.951 464.28 44.03C512.378 8.502 588 -22.418 588 69.85C588 90.97 576.42 192.07 570 213.89C548.2 284.29 472.94 303.23 405.91 292.28C521.44 315.16 549.46 397.65 489.46 480.13C377.23 634.08 316.03 475.75 302.29 436.08C300.83 431.89 300.16 429.94 300 429.94C299.84 429.94 299.17 431.89 297.71 436.08C283.97 475.76 222.77 634.08 110.54 480.13C50.54 397.65 78.56 315.16 194.09 292.28C127.06 303.23 51.8 284.29 30 213.89C23.58 192.07 12 90.97 12 69.85C12 -22.418 87.622 8.502 135.72 44.03Z" /> 23 + </svg> 24 + </a> 25 + ); 26 + 2 27 return ( 3 28 <section class="section reveal"> 4 29 <div class="container"> 5 30 <div class="text-center"> 6 - <h2 class="text-section">Choose your provider.</h2> 31 + <h2 class="text-section">{t.providers.heading}</h2> 7 32 <div class="divider" /> 8 33 <p 9 34 class="text-body mt-2" 10 35 style={{ maxWidth: "640px", margin: "1rem auto 0" }} 11 36 > 12 - Your Atmosphere account lives with a <strong>provider</strong>{" "} 13 - — a service that stores your data in your personal data storage and 14 - keeps it available across every app. That provider might be an app you already use, or a host 15 - that only holds accounts. You pick who hosts your account, and you 16 - can switch any time. 37 + {t.providers.intro()} 17 38 </p> 18 39 </div> 19 40 20 41 <div class="provider-grid"> 21 42 <div class="glass provider-card"> 22 - <div class="provider-card-badge font-mono">Most popular</div> 43 + <div class="provider-card-badge font-mono"> 44 + {t.providers.apps.badge} 45 + </div> 23 46 <div class="provider-logo-row"> 24 47 <svg 25 48 width="28" ··· 35 58 <rect x="2" y="3" width="20" height="18" rx="2" /> 36 59 <path d="M6 8h12M8 12h5M8 16h8" /> 37 60 </svg> 38 - <span class="provider-name font-mono">Apps</span> 61 + <span class="provider-name font-mono"> 62 + {t.providers.apps.name} 63 + </span> 39 64 </div> 40 - <p class="text-body-sm"> 41 - Apps such as Bluesky{" "} 42 - <a 43 - href="https://bsky.app" 44 - target="_blank" 45 - rel="noopener noreferrer" 46 - class="provider-bluesky-icon-link" 47 - aria-label="Bluesky website" 48 - > 49 - <svg 50 - class="provider-bluesky-icon" 51 - width="18" 52 - height="18" 53 - viewBox="0 0 600 530" 54 - fill="currentColor" 55 - xmlns="http://www.w3.org/2000/svg" 56 - aria-hidden="true" 57 - > 58 - <path d="M135.72 44.03C202.216 93.951 273.74 195.17 300 249.49C326.26 195.17 397.784 93.951 464.28 44.03C512.378 8.502 588 -22.418 588 69.85C588 90.97 576.42 192.07 570 213.89C548.2 284.29 472.94 303.23 405.91 292.28C521.44 315.16 549.46 397.65 489.46 480.13C377.23 634.08 316.03 475.75 302.29 436.08C300.83 431.89 300.16 429.94 300 429.94C299.84 429.94 299.17 431.89 297.71 436.08C283.97 475.76 222.77 634.08 110.54 480.13C50.54 397.65 78.56 315.16 194.09 292.28C127.06 303.23 51.8 284.29 30 213.89C23.58 192.07 12 90.97 12 69.85C12 -22.418 87.622 8.502 135.72 44.03Z" /> 59 - </svg> 60 - </a>{" "} 61 - are also account providers. When you sign up, they provide an 62 - account for you and your data is hosted by them. Some apps are not 63 - account providers: they are just apps, and you sign in with an 64 - account hosted somewhere else. 65 - </p> 65 + <p class="text-body-sm">{t.providers.apps.body(blueskyLink)}</p> 66 66 </div> 67 67 68 68 <div class="glass provider-card"> ··· 82 82 <rect x="14" y="14" width="7" height="7" rx="1" /> 83 83 <rect x="3" y="14" width="7" height="7" rx="1" /> 84 84 </svg> 85 - <span class="provider-name font-mono">Independent providers</span> 85 + <span class="provider-name font-mono"> 86 + {t.providers.independent.name} 87 + </span> 86 88 </div> 87 - <p class="text-body-sm"> 88 - Independent providers are account hosts — they are not apps 89 - themselves, they only hold your account and data. A growing number 90 - of them offer Atmosphere accounts: some are community-run, some 91 - focus on privacy or geographic location. 92 - </p> 89 + <p class="text-body-sm">{t.providers.independent.body}</p> 93 90 </div> 94 91 95 92 <div class="glass provider-card"> ··· 108 105 <path d="M2 17l10 5 10-5" /> 109 106 <path d="M2 12l10 5 10-5" /> 110 107 </svg> 111 - <span class="provider-name font-mono">Self-host</span> 108 + <span class="provider-name font-mono"> 109 + {t.providers.selfHost.name} 110 + </span> 112 111 </div> 113 - <p class="text-body-sm"> 114 - Technical users can run their own provider. Full control over your 115 - data, on your own infrastructure. The Atmosphere is open — anyone 116 - can be a provider. 117 - </p> 112 + <p class="text-body-sm">{t.providers.selfHost.body}</p> 118 113 </div> 119 114 </div> 120 115 ··· 126 121 fontStyle: "italic", 127 122 }} 128 123 > 129 - No matter which provider you choose, your account works everywhere and 130 - you can move to a different provider at any time — no data lost. 124 + {t.providers.footnote} 131 125 </p> 132 126 </div> 133 127 </section>
+22 -32
components/CrossPollination.tsx
··· 1 + import { useT } from "../i18n/mod.ts"; 2 + 1 3 export default function CrossPollination() { 2 - const contentTypes = [ 3 - { label: "Blog posts", side: "left" }, 4 - { label: "Photos", side: "left" }, 5 - { label: "Music", side: "left" }, 6 - { label: "Videos", side: "left" }, 7 - { label: "Events", side: "left" }, 8 - { label: "Anything new", side: "left" }, 9 - ]; 10 - 11 - const destinations = [ 12 - { label: "Social feeds", side: "right" }, 13 - { label: "Galleries", side: "right" }, 14 - { label: "Profiles", side: "right" }, 15 - { label: "Players", side: "right" }, 16 - { label: "Calendars", side: "right" }, 17 - { label: "Apps not yet built", side: "right" }, 18 - ]; 4 + const t = useT(); 5 + const contentTypes = t.crossPollination.contentTypes; 6 + const destinations = t.crossPollination.destinations; 19 7 20 8 return ( 21 9 <section class="section reveal"> 22 10 <div class="container"> 23 11 <div class="text-center"> 24 - <h2 class="text-section">Post once, show everywhere.</h2> 12 + <h2 class="text-section">{t.crossPollination.heading}</h2> 25 13 <div class="divider" /> 26 14 <p 27 15 class="text-body mt-2" 28 16 style={{ maxWidth: "640px", margin: "1rem auto 0" }} 29 17 > 30 - Your content flows freely across every app in the Atmosphere. 18 + {t.crossPollination.intro} 31 19 </p> 32 20 </div> 33 21 34 22 {/* Flow diagram */} 35 23 <div class="flow-diagram"> 36 24 <div class="flow-column flow-column-left"> 37 - <div class="flow-column-label font-mono">You create</div> 38 - {contentTypes.map((item, i) => ( 25 + <div class="flow-column-label font-mono"> 26 + {t.crossPollination.youCreate} 27 + </div> 28 + {contentTypes.map((label, i) => ( 39 29 <div 40 - key={item.label} 30 + key={label} 41 31 class={`flow-node flow-node-left glass-subtle ${ 42 32 i === contentTypes.length - 1 ? "flow-node-open" : "" 43 33 }`} 44 34 style={{ animationDelay: `${i * 0.12}s` }} 45 35 > 46 - {item.label} 36 + {label} 47 37 </div> 48 38 ))} 49 39 </div> ··· 52 42 <div class="flow-hub glass"> 53 43 <img 54 44 src="/union.svg" 55 - alt="Atmosphere" 45 + alt={t.crossPollination.hubLogoAlt} 56 46 width="36" 57 47 height="36" 58 48 class="flow-hub-logo" 59 49 /> 60 50 <span class="flow-hub-label font-mono"> 61 - Your Atmosphere Account 51 + {t.crossPollination.hubLabel} 62 52 </span> 63 53 </div> 64 54 ··· 84 74 </div> 85 75 86 76 <div class="flow-column flow-column-right"> 87 - <div class="flow-column-label font-mono">It appears in</div> 88 - {destinations.map((item, i) => ( 77 + <div class="flow-column-label font-mono"> 78 + {t.crossPollination.itAppearsIn} 79 + </div> 80 + {destinations.map((label, i) => ( 89 81 <div 90 - key={item.label} 82 + key={label} 91 83 class={`flow-node flow-node-right glass-subtle ${ 92 84 i === destinations.length - 1 ? "flow-node-open" : "" 93 85 }`} 94 86 style={{ animationDelay: `${i * 0.12 + 0.3}s` }} 95 87 > 96 - {item.label} 88 + {label} 97 89 </div> 98 90 ))} 99 91 </div> ··· 107 99 fontStyle: "italic", 108 100 }} 109 101 > 110 - These are just examples. The Atmosphere is open — any app can create 111 - and surface any kind of content. The possibilities grow with every new 112 - app that joins. 102 + {t.crossPollination.footnote} 113 103 </p> 114 104 </div> 115 105 </section>
+13 -19
components/DeveloperResources.tsx
··· 1 + import { useT } from "../i18n/mod.ts"; 2 + 1 3 export default function DeveloperResources() { 4 + const t = useT(); 2 5 return ( 3 6 <> 4 7 <section class="section-sm reveal"> 5 8 <div class="container-narrow text-center"> 6 - <h1 class="text-section">For developers.</h1> 9 + <h1 class="text-section">{t.developerResources.heading}</h1> 7 10 <div class="divider" /> 8 - <p class="text-body mt-2 mb-4"> 9 - Building an Atmosphere app? Let your users know they can sign in with 10 - their Atmosphere account. 11 - </p> 11 + <p class="text-body mt-2 mb-4">{t.developerResources.intro}</p> 12 12 <div class="badge-display"> 13 13 <img 14 14 src="/sign-in-box.svg" 15 - alt="Sign in with your Atmosphere Account" 15 + alt={t.developerResources.badgeAlt} 16 16 /> 17 17 </div> 18 18 <div class="badge-downloads"> ··· 21 21 download="atmosphere-sign-in-badge.svg" 22 22 class="badge-download-btn font-mono" 23 23 > 24 - Download badge (SVG) 24 + {t.developerResources.downloadBadge} 25 25 </a> 26 26 <a 27 27 href="/union.svg" 28 28 download="atmosphere-logo.svg" 29 29 class="badge-download-btn font-mono" 30 30 > 31 - Download logo (SVG) 31 + {t.developerResources.downloadLogo} 32 32 </a> 33 33 </div> 34 - <p class="text-body-sm mt-3"> 35 - Add this badge to your sign-in page to help users understand the 36 - Atmosphere. 37 - </p> 34 + <p class="text-body-sm mt-3">{t.developerResources.badgeFootnote}</p> 38 35 </div> 39 36 </section> 40 37 41 38 <section class="section-sm reveal"> 42 39 <div class="container-narrow text-center"> 43 - <h2 class="text-subsection">Homepage hero animation</h2> 40 + <h2 class="text-subsection">{t.developerResources.lottieHeading}</h2> 44 41 <div class="divider" /> 45 - <p class="text-body mt-2 mb-3"> 46 - The Lottie animation and the image assets embedded inside it (logos 47 - and artwork used in the sequence). 48 - </p> 42 + <p class="text-body mt-2 mb-3">{t.developerResources.lottieIntro}</p> 49 43 <div class="badge-downloads"> 50 44 <a 51 45 href="/atmosphere.json" 52 46 download="atmosphere-hero.lottie.json" 53 47 class="badge-download-btn font-mono" 54 48 > 55 - Download Lottie (JSON) 49 + {t.developerResources.downloadLottie} 56 50 </a> 57 51 <a 58 52 href="/lottie-icons.zip" 59 53 download="atmosphere-lottie-icons.zip" 60 54 class="badge-download-btn font-mono" 61 55 > 62 - Download icons (ZIP) 56 + {t.developerResources.downloadIcons} 63 57 </a> 64 58 </div> 65 59 </div>
+8 -25
components/Features.tsx
··· 1 + import { useT } from "../i18n/mod.ts"; 2 + 1 3 function Icon({ children }: { children: any }) { 2 4 return ( 3 5 <div class="feature-icon"> ··· 47 49 }; 48 50 49 51 export default function Features() { 52 + const t = useT(); 50 53 const features = [ 51 - { 52 - icon: icons.globe, 53 - title: "Universal identity", 54 - description: 55 - "One login across Atmosphere apps, and the same @handle everywhere — so when someone mentions you, it’s the same you, recognized across the network.", 56 - }, 57 - { 58 - icon: icons.key, 59 - title: "You own your account", 60 - description: 61 - "Your data isn’t trapped in any one app. Unlike traditional social accounts, where your profile and content belong to the platform, an Atmosphere account puts you in charge — you genuinely own your identity and your data.", 62 - }, 63 - { 64 - icon: icons.blocks, 65 - title: "Anyone can build", 66 - description: 67 - "Developers can build new apps on the Atmosphere and tap into an existing network from day one.", 68 - }, 69 - { 70 - icon: icons.domain, 71 - title: "Great for personal websites", 72 - description: 73 - "Your @handle can be your own domain. Your identity, your brand — no handle squatting.", 74 - }, 54 + { icon: icons.globe, ...t.features.items.universalIdentity }, 55 + { icon: icons.key, ...t.features.items.ownAccount }, 56 + { icon: icons.blocks, ...t.features.items.anyoneBuilds }, 57 + { icon: icons.domain, ...t.features.items.personalSites }, 75 58 ]; 76 59 77 60 return ( 78 61 <section class="section reveal"> 79 62 <div class="container"> 80 63 <div class="text-center"> 81 - <h2 class="text-section">Built different.</h2> 64 + <h2 class="text-section">{t.features.heading}</h2> 82 65 <div class="divider" /> 83 66 </div> 84 67 <div class="feature-grid">
+13 -15
components/Footer.tsx
··· 1 + import { useT } from "../i18n/mod.ts"; 2 + import LocaleSwitcher from "./LocaleSwitcher.tsx"; 3 + 1 4 export default function Footer() { 5 + const t = useT(); 2 6 return ( 3 7 <footer class="footer reveal"> 4 8 <div class="container text-center"> 5 9 <img 6 10 src="/union.svg" 7 - alt="Atmosphere" 11 + alt={t.footer.logoAlt} 8 12 width="40" 9 13 height="40" 10 14 class="footer-logo" ··· 15 19 "brightness(0) saturate(100%) invert(12%) sepia(30%) saturate(1500%) hue-rotate(195deg) brightness(95%)", 16 20 }} 17 21 /> 18 - <p class="text-subsection mb-3"> 19 - Building a better internet, owned by the people. 20 - </p> 22 + <p class="text-subsection mb-3">{t.footer.tagline}</p> 21 23 <div class="footer-links"> 22 24 <a 23 25 href="https://atproto.com" 24 26 target="_blank" 25 27 rel="noopener noreferrer" 26 28 > 27 - AT Protocol 29 + {t.footer.links.atProtocol} 28 30 </a> 29 - <span class="footer-coming-soon" title="Coming soon"> 30 - Explore Apps 31 + <span class="footer-coming-soon" title={t.footer.links.exploreAppsTitle}> 32 + {t.footer.links.exploreApps} 31 33 </span> 32 - <a href="/developer-resources">Developer resources</a> 34 + <a href="/developer-resources">{t.footer.links.developerResources}</a> 33 35 </div> 34 - <p class="footer-quote"> 35 - "You never change things by fighting the existing reality. To change 36 - something, build a new model that makes the existing model obsolete." 37 - <br /> 38 - <span style={{ opacity: 0.75 }}>— Buckminster Fuller</span> 39 - </p> 36 + <p class="footer-quote">{t.footer.quote()}</p> 40 37 <a href="#page-top" class="back-to-top mt-4"> 41 38 <svg 42 39 width="18" ··· 51 48 > 52 49 <path d="M18 15l-6-6-6 6" /> 53 50 </svg> 54 - Back to top 51 + {t.footer.backToTop} 55 52 </a> 53 + <LocaleSwitcher /> 56 54 </div> 57 55 </footer> 58 56 );
+6 -7
components/Hero.tsx
··· 1 + import { useT } from "../i18n/mod.ts"; 2 + 1 3 export default function Hero() { 4 + const t = useT(); 2 5 return ( 3 6 <section class="hero"> 4 - <p class="hero-eyebrow font-mono">Atmosphere Account</p> 5 - <h1 class="text-hero"> 6 - The last social account<br />you'll ever need. 7 - </h1> 8 - <p class="text-body hero-subhead"> 9 - One account for all your apps. Yours to keep, wherever you go. 10 - </p> 7 + <p class="hero-eyebrow font-mono">{t.hero.eyebrow}</p> 8 + <h1 class="text-hero">{t.hero.headline()}</h1> 9 + <p class="text-body hero-subhead">{t.hero.subhead}</p> 11 10 <div class="scroll-indicator" aria-hidden="true"> 12 11 <svg 13 12 width="24"
+40
components/LocaleSwitcher.tsx
··· 1 + import { SUPPORTED_LOCALES, useLocale, useT } from "../i18n/mod.ts"; 2 + 3 + /** 4 + * JS-free language switcher. Renders nothing while only one locale is 5 + * registered, so it stays out of the way until more translations land. 6 + * 7 + * The form GETs `/api/locale`, which writes the cookie and redirects back 8 + * to the page that submitted it. 9 + */ 10 + export default function LocaleSwitcher({ returnTo }: { returnTo?: string }) { 11 + if (SUPPORTED_LOCALES.length < 2) return null; 12 + 13 + const t = useT(); 14 + const current = useLocale(); 15 + const names = t.localeSwitcher.languageNames as Record<string, string>; 16 + 17 + return ( 18 + <form 19 + method="get" 20 + action="/api/locale" 21 + class="locale-switcher" 22 + aria-label={t.localeSwitcher.label} 23 + > 24 + <label class="locale-switcher-label"> 25 + <span class="visually-hidden">{t.localeSwitcher.label}</span> 26 + <select name="to" class="locale-switcher-select" aria-label={t.localeSwitcher.label}> 27 + {SUPPORTED_LOCALES.map((loc) => ( 28 + <option key={loc} value={loc} selected={loc === current}> 29 + {names[loc] ?? loc} 30 + </option> 31 + ))} 32 + </select> 33 + </label> 34 + {returnTo ? <input type="hidden" name="return" value={returnTo} /> : null} 35 + <button type="submit" class="locale-switcher-submit"> 36 + {t.localeSwitcher.label} 37 + </button> 38 + </form> 39 + ); 40 + }
+4 -1
components/LottieSection.tsx
··· 1 + import { useT } from "../i18n/mod.ts"; 2 + 1 3 export default function LottieSection() { 4 + const t = useT(); 2 5 return ( 3 6 <div class="lottie-section"> 4 7 <div class="lottie-wrapper"> ··· 11 14 /> 12 15 <img 13 16 src="/union.svg" 14 - alt="Atmosphere logo" 17 + alt={t.lottie.logoAlt} 15 18 class="lottie-logo-overlay" 16 19 width="72" 17 20 height="72"
+9 -14
components/ModerationAndAlgorithms.tsx
··· 1 + import { useT } from "../i18n/mod.ts"; 2 + 1 3 export default function YourChoice() { 4 + const t = useT(); 2 5 const cards = [ 3 6 { 4 7 icon: ( ··· 15 18 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" /> 16 19 </svg> 17 20 ), 18 - title: "Moderation", 19 - body: 20 - "Subscribe to community-built moderation — labels, filters, and block lists made by the people who understand the problem best. Don't like an app's rules? Layer on your own, or switch apps entirely.", 21 + ...t.yourChoice.cards.moderation, 21 22 }, 22 23 { 23 24 icon: ( ··· 42 43 <circle cx="20" cy="14" r="2" /> 43 44 </svg> 44 45 ), 45 - title: "Algorithms", 46 - body: 47 - "Feeds are open — anyone can build one. Switch between them like playlists: friends-only, indie art, slow news, or something deeply niche. No single algorithm quietly decides culture for everyone.", 46 + ...t.yourChoice.cards.algorithms, 48 47 }, 49 48 { 50 49 icon: ( ··· 61 60 <path d="M6 12h12M6 7L3 12L6 17M18 7L21 12L18 17" /> 62 61 </svg> 63 62 ), 64 - title: "Portability", 65 - body: 66 - "Move between apps and providers while keeping your connections, posts, and followers — no more starting over. Creators can leave an app without losing their audience; your followers are yours, not rented from a platform.", 63 + ...t.yourChoice.cards.portability, 67 64 }, 68 65 ]; 69 66 ··· 71 68 <section class="section reveal"> 72 69 <div class="container"> 73 70 <div class="text-center"> 74 - <h2 class="text-section">Your account, your choice.</h2> 71 + <h2 class="text-section">{t.yourChoice.heading}</h2> 75 72 <div class="divider" /> 76 73 <p 77 74 class="text-body mt-2" 78 75 style={{ maxWidth: "640px", margin: "1rem auto 0" }} 79 76 > 80 - No single company decides what you see, who you follow, or where you 81 - go. Everything is yours to control. 77 + {t.yourChoice.intro} 82 78 </p> 83 79 </div> 84 80 ··· 107 103 fontStyle: "italic", 108 104 }} 109 105 > 110 - Account ownership, moderation, and algorithmic choice — the system is 111 - locked open by design. 106 + {t.yourChoice.footnote} 112 107 </p> 113 108 </div> 114 109 </section>
+10 -7
components/Nav.tsx
··· 1 + import { useT } from "../i18n/mod.ts"; 2 + 1 3 export default function Nav() { 4 + const t = useT(); 2 5 return ( 3 6 <> 4 7 <nav class="nav" id="main-nav"> 5 8 <a href="/" class="nav-logo"> 6 - <img src="/union.svg" alt="Atmosphere" width="26" height="26" /> 7 - <span class="nav-logo-text">Atmosphere</span> 9 + <img src="/union.svg" alt={t.nav.logoAlt} width="26" height="26" /> 10 + <span class="nav-logo-text">{t.nav.brand}</span> 8 11 </a> 9 12 <div class="nav-links"> 10 13 <span 11 14 class="nav-btn nav-btn-ghost nav-coming-soon" 12 - title="Coming soon" 15 + title={t.nav.exploreComingSoon} 13 16 > 14 - Explore 17 + {t.nav.explore} 15 18 </span> 16 19 <a 17 20 href="https://atproto.com" ··· 19 22 rel="noopener noreferrer" 20 23 class="nav-btn nav-btn-glass" 21 24 > 22 - Protocol 25 + {t.nav.protocol} 23 26 </a> 24 27 </div> 25 28 </nav> 26 29 <div class="nav-effects-bar" id="nav-effects-bar"> 27 30 <label class="nav-sky-switch-label"> 28 - <span class="nav-sky-switch-text">Effects</span> 31 + <span class="nav-sky-switch-text">{t.nav.effects}</span> 29 32 <span class="nav-sky-switch"> 30 33 <input 31 34 type="checkbox" 32 35 id="sky-effects-toggle" 33 36 class="nav-sky-switch-input" 34 37 defaultChecked 35 - aria-label="Effects on. Turn off to keep colors and clouds fixed like the first screen." 38 + aria-label={t.nav.effectsOn} 36 39 /> 37 40 <span class="nav-sky-switch-track" aria-hidden="true" /> 38 41 </span>
+7 -19
components/OnePlace.tsx
··· 1 1 import LottieSection from "./LottieSection.tsx"; 2 + import { useT } from "../i18n/mod.ts"; 2 3 3 4 export default function OnePlace() { 4 - const items = [ 5 - "Posts", 6 - "Likes", 7 - "Follows", 8 - "Comments", 9 - "Lists", 10 - "Videos", 11 - "Photos", 12 - "Blogs", 13 - ]; 5 + const t = useT(); 14 6 15 7 return ( 16 8 <section class="section-sm reveal"> 17 9 <div class="container-narrow text-center"> 18 10 <LottieSection /> 19 - <h2 class="text-section">Everything in one place.</h2> 11 + <h2 class="text-section">{t.onePlace.heading}</h2> 20 12 <div class="divider" /> 21 - <p class="text-body mt-2"> 22 - All your stuff — from every Atmosphere app you use — lives in your one 23 - Atmosphere account. Sign in anywhere, pick up right where you left off. 24 - </p> 13 + <p class="text-body mt-2">{t.onePlace.body}</p> 25 14 <p class="text-body-sm mt-3 hub-examples-label"> 26 - A few examples — there’s no fixed list. New apps bring new kinds of data, 27 - all in one place. 15 + {t.onePlace.examplesLabel} 28 16 </p> 29 17 <div class="hub-visual"> 30 - {items.map((item) => ( 18 + {t.onePlace.items.map((item) => ( 31 19 <span key={item} class="hub-tag"> 32 20 {item} 33 21 </span> 34 22 ))} 35 - <span class="hub-tag hub-tag-more">…and many more</span> 23 + <span class="hub-tag hub-tag-more">{t.onePlace.moreTag}</span> 36 24 </div> 37 25 </div> 38 26 </section>
+13 -21
components/WhatIsAtmosphere.tsx
··· 1 + import { useT } from "../i18n/mod.ts"; 2 + 1 3 export default function WhatIsAtmosphere() { 4 + const t = useT(); 2 5 return ( 3 6 <section class="section reveal"> 4 7 <div class="container-narrow text-center"> 5 - <h2 class="text-section">What is the Atmosphere?</h2> 8 + <h2 class="text-section">{t.whatIs.heading}</h2> 6 9 <div class="divider" /> 7 10 <div 8 11 class="glass-strong" 9 12 style={{ padding: "2.5rem 2rem", marginTop: "2rem" }} 10 13 > 11 - <p class="text-body"> 12 - The Atmosphere is a new open network of apps and services that all 13 - work together. Instead of every app being its own walled garden, 14 - Atmosphere apps share a common foundation — so you only need{" "} 15 - <strong>one account</strong> to use them all. 16 - </p> 17 - <p class="text-body mt-3"> 18 - Your <strong>Atmosphere Account</strong>{" "} 19 - is your passport to this entire ecosystem. One account unlocks every 20 - app — no more creating new logins, no more losing your stuff when 21 - you switch. Sign in once, and you're home everywhere. 22 - </p> 23 - <p class="text-body mt-3"> 24 - The Atmosphere isn&apos;t owned or controlled by any single big-tech 25 - company. This isn&apos;t just another &quot;Sign in with Google&quot;, 26 - it&apos;s an <strong>open social web</strong>. 27 - </p> 14 + {t.whatIs.paragraphs.map((render, i) => ( 15 + <p 16 + key={i} 17 + class={i === 0 ? "text-body" : "text-body mt-3"} 18 + > 19 + {render()} 20 + </p> 21 + ))} 28 22 </div> 29 23 <p 30 24 class="text-body-sm text-center" 31 25 style={{ marginTop: "1.25rem", fontStyle: "italic", opacity: 0.78 }} 32 26 > 33 - Of course, you can have <strong>multiple accounts</strong>{" "} 34 - if you want — great for keeping different personas separate. Whatever 35 - you choose, every account you own works across the entire Atmosphere. 27 + {t.whatIs.footnote()} 36 28 </p> 37 29 </div> 38 30 </section>
+1
deno.lock
··· 5 5 "jsr:@deno/loader@~0.3.10": "0.3.14", 6 6 "jsr:@deno/loader@~0.3.2": "0.3.14", 7 7 "jsr:@fresh/build-id@1": "1.0.1", 8 + "jsr:@fresh/core@*": "2.2.2", 8 9 "jsr:@fresh/core@2": "2.2.2", 9 10 "jsr:@fresh/core@^2.2.0": "2.2.2", 10 11 "jsr:@fresh/core@^2.2.2": "2.2.2",
+41
i18n/context.tsx
··· 1 + import { createContext, type VNode } from "preact"; 2 + import { useContext } from "preact/hooks"; 3 + import { DEFAULT_LOCALE, type Locale } from "./locales.ts"; 4 + import { getMessages, type Messages } from "./messages/index.ts"; 5 + 6 + interface I18nContextValue { 7 + locale: Locale; 8 + t: Messages; 9 + } 10 + 11 + const I18nContext = createContext<I18nContextValue>({ 12 + locale: DEFAULT_LOCALE, 13 + t: getMessages(DEFAULT_LOCALE), 14 + }); 15 + 16 + export interface I18nProviderProps { 17 + locale: Locale; 18 + children: VNode | VNode[] | string | null; 19 + } 20 + 21 + export function I18nProvider({ locale, children }: I18nProviderProps): VNode { 22 + const value: I18nContextValue = { locale, t: getMessages(locale) }; 23 + return ( 24 + <I18nContext.Provider value={value}> 25 + {children as VNode} 26 + </I18nContext.Provider> 27 + ); 28 + } 29 + 30 + /** 31 + * Retrieve the active locale's message catalog. Components access keys 32 + * directly (e.g. `useT().nav.explore`) so TypeScript guarantees the key 33 + * exists in every locale. 34 + */ 35 + export function useT(): Messages { 36 + return useContext(I18nContext).t; 37 + } 38 + 39 + export function useLocale(): Locale { 40 + return useContext(I18nContext).locale; 41 + }
+75
i18n/locales.ts
··· 1 + /** 2 + * Locale registry. To add a new language: 3 + * 1. Add its tag here. 4 + * 2. Add a matching `i18n/messages/<tag>.ts` that satisfies the `Messages` type 5 + * from `i18n/messages/en.ts`. 6 + * 3. Register it in `i18n/messages/index.ts`. 7 + * 8 + * Tags follow BCP 47 (e.g. "en", "es", "pt-BR"). 9 + */ 10 + export const SUPPORTED_LOCALES = ["en"] as const; 11 + 12 + export type Locale = (typeof SUPPORTED_LOCALES)[number]; 13 + 14 + export const DEFAULT_LOCALE: Locale = "en"; 15 + 16 + /** Cookie name used to persist a user's locale choice. */ 17 + export const LOCALE_COOKIE = "locale"; 18 + 19 + /** One year, in seconds. */ 20 + export const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; 21 + 22 + export function isLocale(value: unknown): value is Locale { 23 + return typeof value === "string" && 24 + (SUPPORTED_LOCALES as readonly string[]).includes(value); 25 + } 26 + 27 + /** 28 + * Negotiate the best supported locale given an explicit preference and the 29 + * request headers. Resolution order: 30 + * 1. Explicit cookie (already validated). 31 + * 2. `Accept-Language` header — best matching base tag wins. 32 + * 3. {@link DEFAULT_LOCALE}. 33 + */ 34 + export function negotiateLocale( 35 + cookieValue: string | undefined, 36 + acceptLanguage: string | null, 37 + ): Locale { 38 + if (cookieValue && isLocale(cookieValue)) return cookieValue; 39 + 40 + if (acceptLanguage) { 41 + const ranked = parseAcceptLanguage(acceptLanguage); 42 + for (const tag of ranked) { 43 + if (isLocale(tag)) return tag; 44 + const base = tag.split("-")[0]; 45 + if (isLocale(base)) return base; 46 + } 47 + } 48 + 49 + return DEFAULT_LOCALE; 50 + } 51 + 52 + /** Parse an Accept-Language header into tags ordered by descending q-value. */ 53 + function parseAcceptLanguage(header: string): string[] { 54 + return header 55 + .split(",") 56 + .map((part) => { 57 + const [tag, ...params] = part.trim().split(";"); 58 + const qParam = params.find((p) => p.trim().startsWith("q=")); 59 + const q = qParam ? Number(qParam.trim().slice(2)) : 1; 60 + return { tag: tag.toLowerCase(), q: Number.isFinite(q) ? q : 0 }; 61 + }) 62 + .filter((entry) => entry.tag && entry.q > 0) 63 + .sort((a, b) => b.q - a.q) 64 + .map((entry) => entry.tag); 65 + } 66 + 67 + /** Read the locale cookie out of a `Cookie` header value. */ 68 + export function readLocaleCookie(cookieHeader: string | null): string | undefined { 69 + if (!cookieHeader) return undefined; 70 + for (const part of cookieHeader.split(";")) { 71 + const [name, ...rest] = part.trim().split("="); 72 + if (name === LOCALE_COOKIE) return rest.join("="); 73 + } 74 + return undefined; 75 + }
+289
i18n/messages/en.tsx
··· 1 + import type { ComponentChildren, VNode } from "preact"; 2 + 3 + /** 4 + * Canonical English catalog. The shape exported here defines the `Messages` 5 + * type that every other locale must satisfy, so adding a key to English 6 + * automatically requires translating it everywhere else (TS enforced). 7 + * 8 + * Conventions: 9 + * - Plain strings for short labels. 10 + * - String arrays for body paragraphs without inline markup. 11 + * - Render functions returning JSX for paragraphs that contain inline 12 + * elements (<strong>, <a>, embedded icons, etc). Translators reproduce 13 + * the function with the same call signature. 14 + */ 15 + const en = { 16 + meta: { 17 + title: "Atmosphere Account — The last social account you'll ever need.", 18 + description: 19 + "Your Atmosphere account is your passport to a growing ecosystem of apps. One account, all your data, your choice.", 20 + ogTitle: "Atmosphere Account", 21 + ogDescription: 22 + "The last social account you'll ever need. One account for all your apps.", 23 + ogImageAlt: 24 + "Atmosphere Account — sky, glass clouds, and hero headline", 25 + }, 26 + 27 + nav: { 28 + logoAlt: "Atmosphere", 29 + brand: "Atmosphere", 30 + explore: "Explore", 31 + exploreComingSoon: "Coming soon", 32 + protocol: "Protocol", 33 + effects: "Effects", 34 + effectsOn: 35 + "Effects on. Turn off to keep colors and clouds fixed like the first screen.", 36 + effectsOff: 37 + "Effects off. Sky matches the first-load colors and cloud positions.", 38 + }, 39 + 40 + hero: { 41 + eyebrow: "Atmosphere Account", 42 + headline: (): VNode => ( 43 + <> 44 + The last social account<br />you'll ever need. 45 + </> 46 + ), 47 + subhead: "One account for all your apps. Yours to keep, wherever you go.", 48 + }, 49 + 50 + whatIs: { 51 + heading: "What is the Atmosphere?", 52 + paragraphs: [ 53 + (): VNode => ( 54 + <> 55 + The Atmosphere is a new open network of apps and services that all 56 + work together. Instead of every app being its own walled garden, 57 + Atmosphere apps share a common foundation — so you only need{" "} 58 + <strong>one account</strong> to use them all. 59 + </> 60 + ), 61 + (): VNode => ( 62 + <> 63 + Your <strong>Atmosphere Account</strong>{" "} 64 + is your passport to this entire ecosystem. One account unlocks every 65 + app — no more creating new logins, no more losing your stuff when you 66 + switch. Sign in once, and you're home everywhere. 67 + </> 68 + ), 69 + (): VNode => ( 70 + <> 71 + The Atmosphere isn't owned or controlled by any single big-tech 72 + company. This isn't just another "Sign in with Google", it's an{" "} 73 + <strong>open social web</strong>. 74 + </> 75 + ), 76 + ], 77 + footnote: (): VNode => ( 78 + <> 79 + Of course, you can have <strong>multiple accounts</strong>{" "} 80 + if you want — great for keeping different personas separate. Whatever 81 + you choose, every account you own works across the entire Atmosphere. 82 + </> 83 + ), 84 + }, 85 + 86 + onePlace: { 87 + heading: "Everything in one place.", 88 + body: 89 + "All your stuff — from every Atmosphere app you use — lives in your one Atmosphere account. Sign in anywhere, pick up right where you left off.", 90 + examplesLabel: 91 + "A few examples — there's no fixed list. New apps bring new kinds of data, all in one place.", 92 + items: [ 93 + "Posts", 94 + "Likes", 95 + "Follows", 96 + "Comments", 97 + "Lists", 98 + "Videos", 99 + "Photos", 100 + "Blogs", 101 + ], 102 + moreTag: "…and many more", 103 + }, 104 + 105 + features: { 106 + heading: "Built different.", 107 + items: { 108 + universalIdentity: { 109 + title: "Universal identity", 110 + description: 111 + "One login across Atmosphere apps, and the same @handle everywhere — so when someone mentions you, it's the same you, recognized across the network.", 112 + }, 113 + ownAccount: { 114 + title: "You own your account", 115 + description: 116 + "Your data isn't trapped in any one app. Unlike traditional social accounts, where your profile and content belong to the platform, an Atmosphere account puts you in charge — you genuinely own your identity and your data.", 117 + }, 118 + anyoneBuilds: { 119 + title: "Anyone can build", 120 + description: 121 + "Developers can build new apps on the Atmosphere and tap into an existing network from day one.", 122 + }, 123 + personalSites: { 124 + title: "Great for personal websites", 125 + description: 126 + "Your @handle can be your own domain. Your identity, your brand — no handle squatting.", 127 + }, 128 + }, 129 + }, 130 + 131 + providers: { 132 + heading: "Choose your provider.", 133 + intro: (): VNode => ( 134 + <> 135 + Your Atmosphere account lives with a <strong>provider</strong>{" "} 136 + — a service that stores your data in your personal data storage and 137 + keeps it available across every app. That provider might be an app 138 + you already use, or a host that only holds accounts. You pick who 139 + hosts your account, and you can switch any time. 140 + </> 141 + ), 142 + apps: { 143 + badge: "Most popular", 144 + name: "Apps", 145 + body: (blueskyLink: ComponentChildren): VNode => ( 146 + <> 147 + Apps such as Bluesky {blueskyLink}{" "} 148 + are also account providers. When you sign up, they provide an 149 + account for you and your data is hosted by them. Some apps are not 150 + account providers: they are just apps, and you sign in with an 151 + account hosted somewhere else. 152 + </> 153 + ), 154 + blueskyAriaLabel: "Bluesky website", 155 + }, 156 + independent: { 157 + name: "Independent providers", 158 + body: 159 + "Independent providers are account hosts — they are not apps themselves, they only hold your account and data. A growing number of them offer Atmosphere accounts: some are community-run, some focus on privacy or geographic location.", 160 + }, 161 + selfHost: { 162 + name: "Self-host", 163 + body: 164 + "Technical users can run their own provider. Full control over your data, on your own infrastructure. The Atmosphere is open — anyone can be a provider.", 165 + }, 166 + footnote: 167 + "No matter which provider you choose, your account works everywhere and you can move to a different provider at any time — no data lost.", 168 + }, 169 + 170 + crossPollination: { 171 + heading: "Post once, show everywhere.", 172 + intro: "Your content flows freely across every app in the Atmosphere.", 173 + youCreate: "You create", 174 + itAppearsIn: "It appears in", 175 + contentTypes: [ 176 + "Blog posts", 177 + "Photos", 178 + "Music", 179 + "Videos", 180 + "Events", 181 + "Anything new", 182 + ], 183 + destinations: [ 184 + "Social feeds", 185 + "Galleries", 186 + "Profiles", 187 + "Players", 188 + "Calendars", 189 + "Apps not yet built", 190 + ], 191 + hubLabel: "Your Atmosphere Account", 192 + hubLogoAlt: "Atmosphere", 193 + footnote: 194 + "These are just examples. The Atmosphere is open — any app can create and surface any kind of content. The possibilities grow with every new app that joins.", 195 + }, 196 + 197 + yourChoice: { 198 + heading: "Your account, your choice.", 199 + intro: 200 + "No single company decides what you see, who you follow, or where you go. Everything is yours to control.", 201 + cards: { 202 + moderation: { 203 + title: "Moderation", 204 + body: 205 + "Subscribe to community-built moderation — labels, filters, and block lists made by the people who understand the problem best. Don't like an app's rules? Layer on your own, or switch apps entirely.", 206 + }, 207 + algorithms: { 208 + title: "Algorithms", 209 + body: 210 + "Feeds are open — anyone can build one. Switch between them like playlists: friends-only, indie art, slow news, or something deeply niche. No single algorithm quietly decides culture for everyone.", 211 + }, 212 + portability: { 213 + title: "Portability", 214 + body: 215 + "Move between apps and providers while keeping your connections, posts, and followers — no more starting over. Creators can leave an app without losing their audience; your followers are yours, not rented from a platform.", 216 + }, 217 + }, 218 + footnote: 219 + "Account ownership, moderation, and algorithmic choice — the system is locked open by design.", 220 + }, 221 + 222 + footer: { 223 + logoAlt: "Atmosphere", 224 + tagline: "Building a better internet, owned by the people.", 225 + links: { 226 + atProtocol: "AT Protocol", 227 + exploreApps: "Explore Apps", 228 + exploreAppsTitle: "Coming soon", 229 + developerResources: "Developer resources", 230 + }, 231 + quote: (): VNode => ( 232 + <> 233 + "You never change things by fighting the existing reality. To change 234 + something, build a new model that makes the existing model obsolete." 235 + <br /> 236 + <span style={{ opacity: 0.75 }}>— Buckminster Fuller</span> 237 + </> 238 + ), 239 + backToTop: "Back to top", 240 + }, 241 + 242 + appShowcase: { 243 + heading: "Explore the Atmosphere.", 244 + intro: 245 + "A growing ecosystem of apps — all accessible with your one Atmosphere account.", 246 + footnote: "And many more being built every day.", 247 + categories: { 248 + microblogs: "Microblogs", 249 + video: "Video", 250 + photos: "Photos", 251 + blogging: "Blogging", 252 + events: "Events", 253 + musicReviews: "Music & Reviews", 254 + collections: "Collections", 255 + clients: "Clients", 256 + }, 257 + }, 258 + 259 + developerResources: { 260 + heading: "For developers.", 261 + intro: 262 + "Building an Atmosphere app? Let your users know they can sign in with their Atmosphere account.", 263 + badgeAlt: "Sign in with your Atmosphere Account", 264 + downloadBadge: "Download badge (SVG)", 265 + downloadLogo: "Download logo (SVG)", 266 + badgeFootnote: 267 + "Add this badge to your sign-in page to help users understand the Atmosphere.", 268 + lottieHeading: "Homepage hero animation", 269 + lottieIntro: 270 + "The Lottie animation and the image assets embedded inside it (logos and artwork used in the sequence).", 271 + downloadLottie: "Download Lottie (JSON)", 272 + downloadIcons: "Download icons (ZIP)", 273 + }, 274 + 275 + lottie: { 276 + logoAlt: "Atmosphere logo", 277 + }, 278 + 279 + localeSwitcher: { 280 + label: "Language", 281 + languageNames: { 282 + en: "English", 283 + }, 284 + }, 285 + } as const; 286 + 287 + export type Messages = typeof en; 288 + 289 + export default en;
+16
i18n/messages/index.ts
··· 1 + import { DEFAULT_LOCALE, type Locale } from "../locales.ts"; 2 + import en, { type Messages } from "./en.tsx"; 3 + 4 + /** 5 + * Map of locale → message catalog. New locales are registered here once 6 + * their `<locale>.ts` file exists and satisfies `Messages`. 7 + */ 8 + const catalogs: Record<Locale, Messages> = { 9 + en, 10 + }; 11 + 12 + export function getMessages(locale: Locale): Messages { 13 + return catalogs[locale] ?? catalogs[DEFAULT_LOCALE]; 14 + } 15 + 16 + export type { Messages };
+18
i18n/middleware.ts
··· 1 + import { define } from "../utils.ts"; 2 + import { 3 + DEFAULT_LOCALE, 4 + negotiateLocale, 5 + readLocaleCookie, 6 + } from "./locales.ts"; 7 + 8 + /** 9 + * Resolves the active locale for every request and stashes it on 10 + * `ctx.state.locale` so layouts, routes, and server-rendered components 11 + * can read it via the I18n context. 12 + */ 13 + export const localeMiddleware = define.middleware((ctx) => { 14 + const cookieLocale = readLocaleCookie(ctx.req.headers.get("cookie")); 15 + const accept = ctx.req.headers.get("accept-language"); 16 + ctx.state.locale = negotiateLocale(cookieLocale, accept) ?? DEFAULT_LOCALE; 17 + return ctx.next(); 18 + });
+16
i18n/mod.ts
··· 1 + export { 2 + DEFAULT_LOCALE, 3 + isLocale, 4 + type Locale, 5 + LOCALE_COOKIE, 6 + LOCALE_COOKIE_MAX_AGE, 7 + negotiateLocale, 8 + readLocaleCookie, 9 + SUPPORTED_LOCALES, 10 + } from "./locales.ts"; 11 + 12 + export { I18nProvider, useLocale, useT } from "./context.tsx"; 13 + 14 + export { getMessages, type Messages } from "./messages/index.ts"; 15 + 16 + export { localeMiddleware } from "./middleware.ts";
+2
main.ts
··· 1 1 import { App, staticFiles } from "fresh"; 2 2 import type { State } from "./utils.ts"; 3 + import { localeMiddleware } from "./i18n/mod.ts"; 3 4 4 5 export const app = new App<State>(); 5 6 6 7 app.use(staticFiles()); 8 + app.use(localeMiddleware); 7 9 8 10 app.fsRoutes();
+27 -20
routes/_app.tsx
··· 1 1 import { define } from "../utils.ts"; 2 + import { getMessages, I18nProvider } from "../i18n/mod.ts"; 2 3 3 4 /** Open Graph / social crawlers prefer absolute image URLs. Set FRESH_PUBLIC_SITE_URL on Deno Deploy (e.g. https://atmosphereaccount.com). */ 4 5 function socialImageUrl(path: string): string { ··· 184 185 } 185 186 186 187 var toggleInput=document.getElementById('sky-effects-toggle'); 188 + var i18n=(window.__ATMOSPHERE_I18N__||{}); 187 189 function syncSkyToggle(){ 188 190 if(!toggleInput)return; 189 191 var on=skyAnimated(); 190 192 toggleInput.checked=on; 191 - toggleInput.setAttribute('aria-label',on?'Effects on. Turn off to keep colors and clouds fixed like the first screen.':'Effects off. Sky matches the first-load colors and cloud positions.'); 193 + toggleInput.setAttribute('aria-label',on?(i18n.effectsOn||''):(i18n.effectsOff||'')); 192 194 } 193 195 if(toggleInput){ 194 196 toggleInput.addEventListener('change',function(){ ··· 235 237 })(); 236 238 `; 237 239 238 - export default define.page(function App({ Component }) { 240 + export default define.page(function App(ctx) { 241 + const { Component, state } = ctx; 242 + const locale = state.locale; 243 + const t = getMessages(locale); 239 244 return ( 240 - <html lang="en"> 245 + <html lang={locale}> 241 246 <head> 242 247 <meta charset="utf-8" /> 243 248 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 244 - <title> 245 - Atmosphere Account — The last social account you'll ever need. 246 - </title> 247 - <meta 248 - name="description" 249 - content="Your Atmosphere account is your passport to a growing ecosystem of apps. One account, all your data, your choice." 250 - /> 251 - <meta property="og:title" content="Atmosphere Account" /> 252 - <meta 253 - property="og:description" 254 - content="The last social account you'll ever need. One account for all your apps." 255 - /> 249 + <title>{t.meta.title}</title> 250 + <meta name="description" content={t.meta.description} /> 251 + <meta property="og:title" content={t.meta.ogTitle} /> 252 + <meta property="og:description" content={t.meta.ogDescription} /> 253 + <meta property="og:locale" content={locale} /> 256 254 <meta property="og:type" content="website" /> 257 255 <meta property="og:image" content={socialImageUrl("/og-hero.png")} /> 258 256 <meta property="og:image:type" content="image/png" /> 259 257 <meta property="og:image:width" content="1200" /> 260 258 <meta property="og:image:height" content="630" /> 261 - <meta 262 - property="og:image:alt" 263 - content="Atmosphere Account — sky, glass clouds, and hero headline" 264 - /> 259 + <meta property="og:image:alt" content={t.meta.ogImageAlt} /> 265 260 <meta name="twitter:card" content="summary_large_image" /> 266 261 <meta name="twitter:image" content={socialImageUrl("/og-hero.png")} /> 267 262 <link rel="icon" href="/favicon.ico" sizes="any" /> ··· 279 274 /> 280 275 </head> 281 276 <body class="sky-bg"> 282 - <Component /> 277 + <I18nProvider locale={locale}> 278 + <Component /> 279 + </I18nProvider> 280 + <script 281 + dangerouslySetInnerHTML={{ 282 + __html: `window.__ATMOSPHERE_I18N__=${ 283 + JSON.stringify({ 284 + effectsOn: t.nav.effectsOn, 285 + effectsOff: t.nav.effectsOff, 286 + }) 287 + };`, 288 + }} 289 + /> 283 290 <script dangerouslySetInnerHTML={{ __html: inlineScript }} /> 284 291 </body> 285 292 </html>
+41
routes/api/locale.ts
··· 1 + import { define } from "../../utils.ts"; 2 + import { 3 + isLocale, 4 + LOCALE_COOKIE, 5 + LOCALE_COOKIE_MAX_AGE, 6 + } from "../../i18n/mod.ts"; 7 + 8 + /** 9 + * Persist a locale choice as a cookie and bounce the user back to where 10 + * they came from. Accepts either GET (no-JS form submit) or POST (fetch). 11 + * 12 + * Query params: 13 + * - `to`: target locale tag. Must be a supported locale. 14 + * - `return`: relative path to redirect back to (defaults to `/`). 15 + */ 16 + function handle(ctx: { url: URL; req: Request }): Response { 17 + const to = ctx.url.searchParams.get("to"); 18 + if (!isLocale(to)) { 19 + return new Response("Unsupported locale", { status: 400 }); 20 + } 21 + 22 + const requested = ctx.url.searchParams.get("return") ?? "/"; 23 + const safeReturn = isSafeRedirect(requested) ? requested : "/"; 24 + 25 + const headers = new Headers({ 26 + location: safeReturn, 27 + "set-cookie": 28 + `${LOCALE_COOKIE}=${to}; Path=/; Max-Age=${LOCALE_COOKIE_MAX_AGE}; SameSite=Lax`, 29 + }); 30 + return new Response(null, { status: 303, headers }); 31 + } 32 + 33 + /** Only allow same-origin relative paths to avoid open-redirects. */ 34 + function isSafeRedirect(value: string): boolean { 35 + return value.startsWith("/") && !value.startsWith("//"); 36 + } 37 + 38 + export const handler = define.handlers({ 39 + GET: handle, 40 + POST: handle, 41 + });
+3
utils.ts
··· 1 1 import { createDefine } from "fresh"; 2 + import type { Locale } from "./i18n/locales.ts"; 2 3 3 4 export interface State { 5 + /** Active locale for this request. Set by the locale middleware. */ 6 + locale: Locale; 4 7 // deno-lint-ignore no-explicit-any 5 8 [key: string]: any; 6 9 }