Something went wrong. Try again.
Central platform for European atproto.<cc> country community websites atproto.eu
community atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562---import { getScope, siteName, resolveScopeId, socialUrl, uiAccentPack, navFor } from '../config/scopes';import { localeHref, localeAlternates, OG_LOCALE, DEFAULT_LOCALE, LOCALES } from '../i18n/paths';import { existingCommunities } from '../data/communities';import '@fontsource-variable/atkinson-hyperlegible-next';import '@fontsource-variable/jetbrains-mono';import '../styles/global.css';import '../styles/country-packs.css';import Wordmark from '../components/Wordmark.astro';import ThemeToggle from '../components/ThemeToggle.astro';import Auth from '../components/Auth.astro';import LikeActions from '../components/LikeActions.astro';import RepostActions from '../components/RepostActions.astro';import FollowActions from '../components/FollowActions.astro';import SubscribeActions from '../components/SubscribeActions.astro';import RsvpActions from '../components/RsvpActions.astro';import MembershipActions from '../components/MembershipActions.astro';import { useTranslations, type Lang } from '../i18n/ui';
interface Props { title?: string; lang?: Lang; /** Per-page meta/OG description. Falls back to the site tagline. */ description?: string; /** Keep the page out of search engines (still reachable by URL). For share-only pages. */ noindex?: boolean; /** Client-side language auto-routing sends a default-locale page to the visitor's preferred * built locale. Turn OFF on pages that exist only in the default locale (event guides, 404, * offline, go-eu): they have no localized counterpart, so redirecting a non-English visitor * lands on a 404, and the 404 page re-running the script is what stacked /cs/cs/cs forever. */ autoRoute?: boolean;}
const { title, lang = 'en', description: pageDescription, noindex = false, autoRoute = true,} = Astro.props;const t = useTranslations(lang);// Every in-site link goes through localeHref: a two-way `lang === 'en' ? … : '/nl/…'` sent// every non-English visitor to Dutch, which made /fr/ a dead end on .be and put a Dutch// switcher on .eu, a build with no Dutch at all.const home = localeHref(lang, '/');const membersHref = localeHref(lang, '/members');const eventsHref = localeHref(lang, '/events');const toolsHref = localeHref(lang, '/tools');// Stats destination differs by scope: every national site has its own /community-size page, the// .eu aggregate has the /stats directory. Always present now (each scope has one).const scopeIdForStats = resolveScopeId(import.meta.env.PUBLIC_SITE_SCOPE);const scopeForNav = getScope(scopeIdForStats);const statsHref = scopeForNav.aggregate ? localeHref(lang, '/stats') : localeHref(lang, '/community-size');const buildersHref = localeHref(lang, '/builders');// Only verifier scopes link /verify; a non-verifier build (e.g. .hu) has no such page.const verifyHref = scopeForNav.verify ? localeHref(lang, '/verify') : null;const aboutHref = localeHref(lang, '/about');const privacyHref = localeHref(lang, '/privacy');const termsHref = localeHref(lang, '/terms');// This build's own community account (Say hi / footer Follow + mu.social link), per scope — never// hardcode .nl's, or every national site would send people to the Dutch account.const followUrl = socialUrl(scopeForNav);const followDid = scopeForNav.social.did;const site = Astro.site?.toString().replace(/\/$/, '') ?? 'https://atproto.nl';// Per-scope social card, so a shared .be/.eu/.no link does not show the Dutch OG image.const ogImage = `${site}/brand/${scopeIdForStats === 'nl' ? 'og.png' : `og-${scopeIdForStats}.png`}`;// Host of this build's own domain, to mark the current site in the communities list (never hide// it: a three-item list with one marked reads as a map; removing the current one reads as a toggle).const siteHost = (() => { try { return new URL(site).host; } catch { return ''; }})();// The footer lists national communities only. City communities are a separate tier: a city is// never a country and never stands in for one, so they deliberately do not appear in the footer// or the per-country statistics.const nationalCommunities = existingCommunities.filter((c) => c.scope === 'national');const isCurrentCommunity = (url: string) => { try { return new URL(url).host === siteHost; } catch { return false; }};
// Language switch keeps you on the same page in the other language, instead of// always going home. Guides live in English only, so their NL link falls back to /nl/events/.const path = Astro.url.pathname;// Built from the scope, so a build can never advertise a language it does not serve.let alternates = localeAlternates(path);// Event guides are English-only: point other locales at the events index rather than a 404.if (/^\/(?:[a-z]{2}\/)?events\/[^/]+\/?$/.test(path)) { alternates = alternates.map((a) => a.lang === DEFAULT_LOCALE ? a : { ...a, href: localeHref(a.lang, '/events/') }, );}const selfHref = alternates.find((a) => a.lang === lang)?.href ?? path;const defaultHref = alternates.find((a) => a.lang === DEFAULT_LOCALE)?.href ?? '/';
const canonical = `${site}${path}`;// The site name comes from the scope, not the catalogue: it does not change per language, and// the catalogue's copy said "atproto.nl" on every build.const scope = getScope(import.meta.env.PUBLIC_SITE_SCOPE);// Per-country header menu visibility (defaults all shown); `about` is structural, `verify` is// governed separately by `scope.verify`. See scopes.ts `nav` / navFor().const nav = navFor(scope);const SITE_NAME = siteName(scope);const pageTitle = title ? `${title} · ${SITE_NAME}` : SITE_NAME;const description = pageDescription ?? t('site.tagline');const orgLd = { '@context': 'https://schema.org', '@type': 'Organization', name: SITE_NAME, url: `${site}/`, logo: `${site}/brand/avatar-${scope.cc}-512.png`, description: t('site.tagline'), sameAs: [followUrl, 'https://tangled.org/atcommons.eu/website'],};---
<!doctype html><html lang={lang} data-accent={scope.accent} data-accent2={scope.accent2} data-ui={uiAccentPack(scope)}> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <script is:inline> // www -> apex. Codeberg Pages serves www.atproto.<cc> from the same repo but cannot redirect, // and OAuth breaks there: the client_id is `${origin}/client-metadata.json`, while the served // metadata names the apex, so the auth server rejects the mismatch. Run first, before paint. if (location.hostname.startsWith('www.')) { location.replace( location.protocol + '//' + location.hostname.slice(4) + location.pathname + location.search + location.hash, ); } </script> <script is:inline> // FOUC guard: apply a pinned theme before first paint. No pin -> CSS uses system. { const t = localStorage.getItem('theme'); if (t === 'dark' || t === 'light') { document.documentElement.dataset.theme = t; document.documentElement.style.colorScheme = t; } } </script> {LOCALES.length > 1 && autoRoute && ( <script define:vars={{ locales: LOCALES, defaultLocale: DEFAULT_LOCALE, currentLang: lang, base: import.meta.env.BASE_URL, }} > {/* Language auto-routing. Static build (no server), so it runs client-side before paint: the default locale is served at '/', every other locale under '/<lang>/'. On a default-locale page we send the visitor to their preferred BUILT language, unless they've made an explicit choice (persisted by the switcher below), so an explicit English pick is never re-bounced. Invisible to crawlers; hreflang already declares the alternates, so there is no SEO cost. Never runs on an already-prefixed page (no loop). */} // Never on an OAuth sign-in callback. The auth server returns the browser to the registered // redirect_uri (`/`) with `#state=…&code=…`, and the OAuth client only accepts that callback // when the path is exactly `/`. Bouncing it to `/nl/#state=…` made the client ignore the // callback, so the visitor came back silently signed out (from 2026-09-19 until this fix, // for anyone whose browser language or saved choice differs from the site default). const oauthCallback = /(^|[#?&])state=/.test(location.hash + location.search) && /(^|[#?&])(code|error)=/.test(location.hash + location.search); if (currentLang === defaultLocale && !oauthCallback) { try { // Browser macrolanguage tags -> the locale this scope actually builds. const ALIAS = { no: 'nb', nn: 'nb' }; const has = (l) => locales.indexOf(l) !== -1; let target = null; const stored = localStorage.getItem('lang'); if (stored) { if (stored !== defaultLocale && has(stored)) target = stored; } else { const prefs = navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || '']; for (const p of prefs) { let b = String(p).toLowerCase().split('-')[0]; b = ALIAS[b] || b; if (b === defaultLocale) break; // English ranked first -> stay in English if (has(b)) { target = b; break; } } } if (target) { const root = base.replace(/\/$/, ''); let rel = location.pathname; if (root && rel.indexOf(root) === 0) rel = rel.slice(root.length) || '/'; // Idempotency guard: never prepend a locale onto a path that already starts with a // built locale. A default-locale page can be SERVED under /<lang>/x (the 404 page // answers /cs/missing as lang=en), so without this the script would re-fire there // and stack /cs/cs/cs into an infinite redirect loop. const first = rel.split('/')[1] || ''; if (locales.indexOf(first) === -1) { location.replace( root + '/' + target + (rel === '/' ? '/' : rel) + location.search + location.hash, ); } } } catch (e) {} } </script> )} <meta name="color-scheme" content="light dark" /> <meta name="theme-color" content="#fbf1c7" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#282828" media="(prefers-color-scheme: dark)" /> <link rel="icon" href={`/brand/favicon-${scope.cc}.ico`} sizes="16x16 32x32 48x48" /> <link rel="icon" type="image/svg+xml" href={`/brand/favicon-${scope.cc}.svg`} /> <link rel="apple-touch-icon" href={`/brand/avatar-${scope.cc}-180.png`} /> <link rel="manifest" href="/site.webmanifest" /> <title>{pageTitle}</title> <meta name="description" content={description} /> {noindex && <meta name="robots" content="noindex, nofollow" />} <link rel="canonical" href={canonical} /> {alternates.map((a) => ( <link rel="alternate" hreflang={a.lang} href={`${site}${a.href}`} /> ))} <link rel="alternate" hreflang="x-default" href={`${site}${defaultHref}`} /> <meta property="og:title" content={SITE_NAME} /> <meta property="og:description" content={description} /> <meta property="og:type" content="website" /> <meta property="og:url" content={canonical} /> <meta property="og:locale" content={OG_LOCALE[lang] ?? 'en_GB'} /> <meta property="og:image" content={ogImage} /> <meta property="og:image:width" content="1200" /> <meta property="og:image:height" content="630" /> <meta property="og:image:alt" content={t('site.tagline')} /> <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:image" content={ogImage} /> <script type="application/ld+json" set:html={JSON.stringify(orgLd)} /> <!-- Umami: privacy-friendly, cookieless, self-hosted (EU) analytics. No PII, no cross-site tracking. Loaded by injecting the tag AFTER `load` (on idle), not as a parser-inserted <script>. Why: u.a11y.nl is behind a Cloudflare Tunnel and healthy for real visitors, but the request can stall for some (privacy tooling / ad-blockers, and our own Firewalla LAN where split-DNS points it at a private IP). A parser-inserted `defer` script stalling blocked every `type="module"` — that broke the sign-in typeahead. `async` fixed that, but an async script still holds the `load` event, so a stall left the tab spinner running. Injecting after `load` keeps analytics off BOTH the module queue and the load event: nothing waits on it, ever. Umami still records the pageview (it self-inits and reads data-website-id from currentScript). --> <script is:inline> (function () { function loadUmami() { var s = document.createElement('script'); s.src = 'https://u.a11y.nl/script.js'; s.async = true; s.setAttribute('data-website-id', 'b35b7f27-ba9c-4ae2-9604-9c4a442f40f8'); document.head.appendChild(s); } function schedule() { if ('requestIdleCallback' in window) requestIdleCallback(loadUmami, { timeout: 3000 }); else setTimeout(loadUmami, 1); } if (document.readyState === 'complete') schedule(); else window.addEventListener('load', schedule, { once: true }); })(); </script> </head> <body> <a class="skip-link" href="#main">{t('a11y.skip')}</a> <header class="site-header"> <div class="wrap bar"> <a href={home} aria-label={SITE_NAME}><Wordmark class="site-mark" /></a> <button class="nav-toggle" type="button" aria-label="Menu" aria-controls="site-nav" aria-expanded="false"> <span class="nav-toggle-bar"></span> <span class="nav-toggle-bar"></span> <span class="nav-toggle-bar"></span> </button> <div class="nav" id="site-nav"> {nav.members && <a class="nav-link" href={membersHref}>{t('nav.members')}</a>} {nav.stats && <a class="nav-link" href={statsHref}>{t('nav.stats')}</a>} {nav.events && <a class="nav-link" href={eventsHref}>{t('nav.events')}</a>} {nav.tools && <a class="nav-link" href={toolsHref}>{t('nav.tools')}</a>} {nav.builders && <a class="nav-link" href={buildersHref}>{t('nav.builders')}</a>} <a class="nav-link" href={aboutHref}>{t('nav.about')}</a> {alternates.length > 1 && ( <details class="lang" data-lang-switcher> <summary class="lang-summary" aria-label={t('a11y.language')} title={t('a11y.language')}> <svg class="lang-globe" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <circle cx="12" cy="12" r="9"></circle> <path d="M3 12h18"></path> <path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18"></path> </svg> <span class="lang-caret" aria-hidden="true"></span> </summary> <ul class="lang-menu"> {alternates.map((a) => ( <li> <a href={a.href} aria-current={a.lang === lang ? 'true' : 'false'} lang={a.lang}> {a.label} </a> </li> ))} </ul> </details> )} <Auth lang={lang} /> <ThemeToggle lang={lang} /> </div> </div> </header>
<main id="main" class="wrap" tabindex="-1"> <slot /> </main>
<footer class="site-footer"> <div class="wrap cols"> <div> <Wordmark class="footer-mark" /> {!scope.aggregate && ( <a class="footer-affil" href="https://atproto.eu" data-accent="blue" data-accent2="yellow" aria-label={`${t('footer.partOf')} atproto.eu — ${t('nav.stats')}`} > <span class="footer-affil-label">{t('footer.partOf')}</span> <Wordmark cc="eu" class="footer-affil-mark" /> <span class="footer-affil-arrow" aria-hidden="true">→</span> </a> )} <div class="footer-meta"> <p class="footer-disclaimer">{t('footer.disclaimer')}</p> </div> </div> <div class="footer-links"> <button class="follow-btn footer-follow-btn" type="button" data-did={followDid} data-profile-url={followUrl} data-follow-label={t('action.follow')} aria-pressed="false" hidden>{t('action.follow')}</button> <a class="nav-link" href={followUrl} rel="noopener">{t('footer.follow')}</a> <a class="nav-link" href={aboutHref}>{t('nav.about')}</a> {verifyHref && <a class="nav-link" href={verifyHref}>{t('nav.verify')}</a>} <a class="nav-link" href={localeHref(lang, '/go-eu')}>{t('footer.goEu')}</a> <a class="nav-link" href={privacyHref}>{t('footer.privacy')}</a> <a class="nav-link" href={termsHref}>{t('footer.terms')}</a> </div> </div> <nav class="wrap footer-communities" aria-label={t('footer.communities')}> <div class="footer-communities-group"> <h2 class="footer-communities-h">{t('footer.communities')}</h2> <ul class="footer-communities-list"> {nationalCommunities.map((c) => ( <li> {isCurrentCommunity(c.url) ? ( <span class="footer-community is-current" aria-current="page">{c.name}</span> ) : ( <a class="footer-community" href={c.url} rel="noopener">{c.name}</a> )} </li> ))} </ul> </div> </nav> <div class="wrap footer-bottom"> <a class="footer-feedback" href="https://tangled.org/atcommons.eu/website/issues" rel="noopener">{t('footer.feedback')}</a> <a class="tangled-link" href="https://tangled.org/atcommons.eu/website" rel="noopener" aria-label="Source on Tangled, open to pull requests"> <img class="logo-light" src="/brand/tangled.svg" alt="Tangled" width="22" height="22" loading="lazy" /> <img class="logo-dark" src="/brand/tangled-dark.svg" alt="Tangled" width="22" height="22" loading="lazy" /> </a> </div> </footer>
<LikeActions /> <RepostActions /> <FollowActions /> <SubscribeActions /> <RsvpActions /> <MembershipActions />
<script is:inline> // Mobile nav toggle + responsive collapse. { const header = document.querySelector('.site-header'); const toggle = header && header.querySelector('.nav-toggle'); const bar = header && header.querySelector('.bar'); if (header && toggle) { toggle.addEventListener('click', () => { const open = header.classList.toggle('open'); toggle.setAttribute('aria-expanded', String(open)); }); header.querySelectorAll('.nav a').forEach((a) => a.addEventListener('click', () => { header.classList.remove('open'); toggle.setAttribute('aria-expanded', 'false'); }), ); } // Collapse to the hamburger exactly when the inline nav would overflow the bar. Measuring // with .is-collapsed removed lets the nav lay out inline; if it overflows, re-collapse. // Adapts to viewport AND auth-widget width (which grows once signed in). if (header && bar) { const fitNav = () => { header.classList.remove('is-collapsed'); if (bar.scrollWidth > bar.clientWidth + 1) header.classList.add('is-collapsed'); else { header.classList.remove('open'); toggle && toggle.setAttribute('aria-expanded', 'false'); } }; let raf; const onResize = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(fitNav); }; window.addEventListener('resize', onResize); // A font-size change resizes the bar's contents without a viewport resize // event, so observe the bar itself as well. if ('ResizeObserver' in window) new ResizeObserver(onResize).observe(bar); document.addEventListener('atn:signedin', fitNav); document.addEventListener('atn:signedout', fitNav); fitNav(); } } </script>
<script is:inline> // Relative post age (e.g. "2h ago"), computed client-side so it's accurate at view // time, not frozen at build time. The absolute date stays as the no-JS fallback + title. { const rel = (ts) => { const s = Math.round((Date.now() - ts) / 1000); if (s < 45) return 'just now'; const m = Math.round(s / 60); if (m < 60) return m + 'm ago'; const h = Math.round(m / 60); if (h < 24) return h + 'h ago'; const d = Math.round(h / 24); if (d < 7) return d + 'd ago'; const w = Math.round(d / 7); if (w < 5) return w + 'w ago'; const mo = Math.round(d / 30); if (mo < 12) return mo + 'mo ago'; return Math.round(d / 365) + 'y ago'; }; for (const el of document.querySelectorAll('.post-date[data-ts]')) { const ts = Date.parse(el.dataset.ts); if (Number.isFinite(ts)) el.textContent = rel(ts); } } </script>
<script is:inline> // Language switcher (<details>): close on Escape, on an outside click, or after a choice. // <details> gives the disclosure + keyboard toggle for free; these add the dismiss behaviours // it lacks natively. { const sw = document.querySelector('.lang[data-lang-switcher]'); if (sw) { const summary = sw.querySelector('summary'); const close = () => sw.removeAttribute('open'); document.addEventListener('click', (e) => { if (sw.hasAttribute('open') && !sw.contains(e.target)) close(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && sw.hasAttribute('open')) { close(); if (summary) summary.focus(); } }); for (const a of sw.querySelectorAll('.lang-menu a')) a.addEventListener('click', close); } } </script>
<script is:inline> // Persist an explicit language choice so the auto-router never overrides it: once you pick // a language in the switcher, that pick sticks (an explicit English pick included). for (const a of document.querySelectorAll('.lang a[lang]')) { a.addEventListener('click', () => { try { localStorage.setItem('lang', a.getAttribute('lang')); } catch (e) {} }); } </script>
<script is:inline> // Open external links in a new tab. for (const a of document.querySelectorAll('a[href^="http"]')) { try { if (new URL(a.href).hostname !== location.hostname) { a.target = '_blank'; if (!a.rel) a.rel = 'noopener noreferrer'; } } catch {} } </script>
<script is:inline> // Register the service worker (offline fallback + static-asset caching). if ('serviceWorker' in navigator) { window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(() => {})); } </script>
<style> .footer-mark { font-size: 1.125rem; } /* "part of //atproto.eu" — affiliation lockup under the footer wordmark (national sites only). The link carries data-accent="blue"/data-accent2="yellow", so the .eu wordmark renders in its own colours regardless of the host site's accent. */ .footer-affil { display: flex; width: fit-content; align-items: baseline; gap: 0.45em; margin-top: var(--space-2); text-decoration: none; font-size: 0.95rem; } .footer-affil-label { color: var(--muted); } .footer-affil-mark { font-size: 1.05rem; } .footer-affil-arrow { color: var(--muted); transition: transform 0.12s ease; } .footer-affil:hover .footer-affil-mark { opacity: 0.8; } .footer-affil:hover .footer-affil-arrow { transform: translateX(3px); } .footer-affil:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; } .lang a[aria-current='true'] { color: var(--brand); } /* Communities directory in the footer. Bare domains, labelled axis, current site marked (not hidden), no flags — placeholder styling until the design system lands. */ .footer-communities { margin-top: var(--space-6, 1.5rem); display: flex; flex-wrap: wrap; gap: var(--space-4, 1rem) var(--space-8, 2rem); } .footer-communities-group { min-width: 12rem; } .footer-communities-h { font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.8rem; margin: 0 0 var(--space-2, 0.5rem); } .footer-communities-list { list-style: none; display: flex; flex-wrap: wrap; gap: var(--space-2, 0.5rem) var(--space-4, 1rem); padding: 0; margin: 0; } .footer-community.is-current { color: var(--brand); font-weight: 600; } </style> </body></html>