From c12240adfcb5091f252dfdff9394932e90a461c6 Mon Sep 17 00:00:00 2001 From: Guido Jansen Date: Wed, 26 Aug 2026 13:54:42 +0200 Subject: [PATCH] fix: avatar aspect + post embeds; add events mockup, footer/join links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - constrain member/post avatars (object-fit cover, fixed 48/32) — fixes vertical stretch - render post embeds: images, external cards, quoted posts (client + PostCard) - mockup Upcoming events section (design event cards, EN + NL, marked illustrative) - footer: link source to Tangled repo + 'open to PRs'; Join: concrete Bluesky CTA --- src/components/EventCard.astro | 27 +++++++++++++++++ src/components/PostCard.astro | 55 ++++++++++++++++++++++++++++++++-- src/data/events.ts | 53 ++++++++++++++++++++++++++++++++ src/i18n/ui.ts | 6 ++++ src/layouts/Base.astro | 2 +- src/lib/atproto/client.ts | 46 +++++++++++++++++++++++++++- src/lib/atproto/types.ts | 27 +++++++++++++++++ src/pages/index.astro | 12 ++++++++ src/pages/nl/index.astro | 12 ++++++++ src/styles/global.css | 43 ++++++++++++++++++++++++-- 10 files changed, 276 insertions(+), 7 deletions(-) create mode 100644 src/components/EventCard.astro create mode 100644 src/data/events.ts diff --git a/src/components/EventCard.astro b/src/components/EventCard.astro new file mode 100644 index 0000000..c953537 --- /dev/null +++ b/src/components/EventCard.astro @@ -0,0 +1,27 @@ +--- +import type { CommunityEvent } from '../data/events'; + +interface Props { + event: CommunityEvent; +} + +const { event } = Astro.props; +--- + +
+
+ {event.kicker} + {event.date} +
+

{event.title}

+

{event.desc}

+ { + event.tags.length > 0 && ( +
+ {event.tags.map((tag) => ( + {tag} + ))} +
+ ) + } +
diff --git a/src/components/PostCard.astro b/src/components/PostCard.astro index 3a88e50..0c45af7 100644 --- a/src/components/PostCard.astro +++ b/src/components/PostCard.astro @@ -6,9 +6,20 @@ interface Props { } const { post } = Astro.props; -const rkey = post.uri.split('/').pop(); -const postUrl = `https://bsky.app/profile/${post.author.handle}/post/${rkey}`; +const bskyPost = (handle: string, uri: string) => + `https://bsky.app/profile/${handle}/post/${uri.split('/').pop()}`; +const postUrl = bskyPost(post.author.handle, post.uri); const date = post.createdAt.slice(0, 10); + +const ext = post.embed?.external; +let extHost = ''; +try { + if (ext) extHost = new URL(ext.uri).hostname.replace(/^www\./, ''); +} catch { + extHost = ''; +} +const quote = post.embed?.quote; +const quoteUrl = quote ? bskyPost(quote.handle, quote.uri) : undefined; ---
@@ -21,6 +32,44 @@ const date = post.createdAt.slice(0, 10); {post.author.displayName ?? post.author.handle} @{post.author.handle} -

{post.text}

+ + {post.text &&

{post.text}

} + + { + post.embed?.images?.length && ( +
+ {post.embed.images.map((im) => ( + {im.alt} + ))} +
+ ) + } + + { + ext && ( + + {ext.thumb && } + + {ext.title || ext.uri} + {ext.description && {ext.description}} + {extHost && {extHost}} + + + ) + } + + { + quote && ( + + + {quote.avatar && } + {quote.displayName ?? quote.handle} + @{quote.handle} + + {quote.text && {quote.text}} + + ) + } +
diff --git a/src/data/events.ts b/src/data/events.ts new file mode 100644 index 0000000..0472c42 --- /dev/null +++ b/src/data/events.ts @@ -0,0 +1,53 @@ +import type { Lang } from '../i18n/ui'; + +export interface CommunityEvent { + kicker: string; + date: string; + title: string; + desc: string; + tags: string[]; + next?: boolean; +} + +// Illustrative placeholders — names, dates and venues are examples. Replaced when events +// come from the standard `community.lexicon.calendar` records (see the events task). +const events: Record = { + en: [ + { + kicker: 'Next up', + date: 'Thu 15 Oct', + title: 'Lexicon workshop', + desc: 'Utrecht · venue to be confirmed. Bring a laptop; we write and publish one record type together.', + tags: ['lexicon', 'PDS'], + next: true, + }, + { + kicker: 'Amsterdam', + date: 'Wed 12 Nov', + title: 'Firehose evening', + desc: 'Two talks on consuming the firehose, then open floor. Suits anyone who has read the docs once.', + tags: ['feeds', 'moderation'], + }, + ], + nl: [ + { + kicker: 'Binnenkort', + date: 'do 15 okt', + title: 'Lexicon-workshop', + desc: 'Utrecht · locatie volgt. Neem een laptop mee; we schrijven en publiceren samen één recordtype.', + tags: ['lexicon', 'PDS'], + next: true, + }, + { + kicker: 'Amsterdam', + date: 'wo 12 nov', + title: 'Firehose-avond', + desc: 'Twee talks over het consumeren van de firehose, daarna open floor. Voor wie de docs één keer gelezen heeft.', + tags: ['feeds', 'moderatie'], + }, + ], +}; + +export function exampleEvents(lang: Lang): CommunityEvent[] { + return events[lang] ?? events.en; +} diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index 386e854..11eff61 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -21,6 +21,9 @@ export const ui = { 'feed.heading': 'From the community', 'feed.empty': 'No recent posts.', 'card.viewProfile': 'View profile', + 'events.heading': 'Upcoming events', + 'events.note': 'Example programme — real events will appear here from the community calendar.', + 'join.cta': 'Say hi on Bluesky', }, nl: { 'site.title': 'atproto.nl', @@ -37,6 +40,9 @@ export const ui = { 'feed.heading': 'Uit de community', 'feed.empty': 'Nog geen recente berichten.', 'card.viewProfile': 'Bekijk profiel', + 'events.heading': 'Aankomende events', + 'events.note': 'Voorbeeldprogramma — echte events verschijnen hier vanuit de community-agenda.', + 'join.cta': 'Zeg hoi op Bluesky', }, } as const; diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index c9daaaf..510cf0e 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -67,7 +67,7 @@ const site = Astro.site?.toString().replace(/\/$/, '') ?? 'https://atproto.nl';
diff --git a/src/lib/atproto/client.ts b/src/lib/atproto/client.ts index 30f2d18..4aca3b3 100644 --- a/src/lib/atproto/client.ts +++ b/src/lib/atproto/client.ts @@ -1,7 +1,7 @@ // Minimal read-only AT Protocol service layer. Hits the public Bluesky AppView // (no auth). Components must go through this module, never fetch XRPC directly. -import type { FeedPost, Profile } from './types'; +import type { FeedPost, PostEmbed, Profile } from './types'; const APPVIEW = 'https://public.api.bsky.app'; @@ -75,6 +75,49 @@ export async function getListMembers(listUri: string, max = 200): Promise): PostEmbed | undefined { + if (!embed || typeof embed !== 'object') return undefined; + const type = String(embed.$type ?? ''); + const readImages = (v: any) => + (v?.images ?? []).map((im: any) => ({ thumb: im.thumb as string, alt: (im.alt ?? '') as string })); + const readExternal = (v: any) => + v?.external + ? { + uri: v.external.uri as string, + title: (v.external.title ?? '') as string, + description: (v.external.description ?? '') as string, + thumb: v.external.thumb as string | undefined, + } + : undefined; + const readQuote = (rec: any) => { + // app.bsky.embed.record#viewRecord — skip notFound/blocked/detached + if (!rec || !rec.author || !rec.value || String(rec.$type ?? '').includes('viewRecord') === false) { + return undefined; + } + return { + handle: rec.author.handle as string, + displayName: rec.author.displayName as string | undefined, + avatar: rec.author.avatar as string | undefined, + text: (rec.value.text ?? '') as string, + uri: rec.uri as string, + }; + }; + + const out: PostEmbed = {}; + if (type.startsWith('app.bsky.embed.images')) out.images = readImages(embed); + else if (type.startsWith('app.bsky.embed.external')) out.external = readExternal(embed); + else if (type.startsWith('app.bsky.embed.record#')) out.quote = readQuote(embed.record); + else if (type.startsWith('app.bsky.embed.recordWithMedia')) { + out.quote = readQuote(embed.record?.record); + const media = embed.media; + const mtype = String(media?.$type ?? ''); + if (mtype.startsWith('app.bsky.embed.images')) out.images = readImages(media); + else if (mtype.startsWith('app.bsky.embed.external')) out.external = readExternal(media); + } + return out.images?.length || out.external || out.quote ? out : undefined; +} + /** Recent top-level posts (no replies, no reposts) for one actor. Empty on failure. */ export async function getAuthorFeed(actor: string, limit = 10): Promise { const params = new URLSearchParams({ @@ -104,6 +147,7 @@ export async function getAuthorFeed(actor: string, limit = 10): Promise @@ -17,6 +20,12 @@ const { members, posts } = await getCommunity();

{t('hero.body')}

+
+

{t('events.heading')}

+

{t('events.note')}

+
{events.map((e) => )}
+
+

{t('members.heading')}

{ @@ -44,5 +53,8 @@ const { members, posts } = await getCommunity();

{t('join.heading')}

{t('join.body')}

+

+ {t('join.cta')} +

diff --git a/src/pages/nl/index.astro b/src/pages/nl/index.astro index 781079c..112e46e 100644 --- a/src/pages/nl/index.astro +++ b/src/pages/nl/index.astro @@ -3,12 +3,15 @@ import Base from '../../layouts/Base.astro'; import Wordmark from '../../components/Wordmark.astro'; import MemberCard from '../../components/MemberCard.astro'; import PostCard from '../../components/PostCard.astro'; +import EventCard from '../../components/EventCard.astro'; import { useTranslations } from '../../i18n/ui'; import { getCommunity } from '../../lib/atproto/home'; +import { exampleEvents } from '../../data/events'; const lang = 'nl' as const; const t = useTranslations(lang); const { members, posts } = await getCommunity(); +const events = exampleEvents(lang); --- @@ -17,6 +20,12 @@ const { members, posts } = await getCommunity();

{t('hero.body')}

+
+

{t('events.heading')}

+

{t('events.note')}

+
{events.map((e) => )}
+
+

{t('members.heading')}

{ @@ -44,5 +53,8 @@ const { members, posts } = await getCommunity();

{t('join.heading')}

{t('join.body')}

+

+ {t('join.cta')} +

diff --git a/src/styles/global.css b/src/styles/global.css index b713fd8..8d0e7c0 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -169,8 +169,10 @@ main { display: block; } /* --- members --- */ .member-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: var(--space-5); } -.member-card { display: flex; gap: var(--space-3); background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius-surface); padding: var(--space-6); } -.member-avatar, .post-avatar { border-radius: 50%; object-fit: cover; flex: none; } +.member-card { display: flex; align-items: flex-start; gap: var(--space-3); background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius-surface); padding: var(--space-6); } +.member-avatar, .post-avatar { border-radius: 50%; object-fit: cover; flex: none; aspect-ratio: 1 / 1; } +.member-avatar { width: 48px; height: 48px; } +.post-avatar { width: 32px; height: 32px; } .member-name, .post-name { font-family: var(--mono); font-weight: 700; letter-spacing: -0.01em; color: var(--inkstrong); } .member-handle, .post-handle { font-family: var(--mono); font-size: 12px; letter-spacing: 0.04em; color: var(--muted); } .member-bio { font-size: 14px; line-height: 1.65; margin: var(--space-2) 0; } @@ -182,6 +184,43 @@ main { display: block; } .post-head { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3); flex-wrap: wrap; } .post-text { margin: var(--space-2) 0; white-space: pre-wrap; } +/* --- post embeds (images / external card / quote) --- */ +.post-images { display: grid; gap: 8px; margin: var(--space-3) 0; grid-template-columns: 1fr; } +.post-images[data-count='2'], .post-images[data-count='3'], .post-images[data-count='4'] { grid-template-columns: 1fr 1fr; } +.post-images img { width: 100%; max-height: 340px; object-fit: cover; display: block; border: 1px solid var(--rule); border-radius: var(--radius-control); } +.post-external { display: flex; gap: var(--space-3); margin: var(--space-3) 0; text-decoration: none; color: inherit; border: 1px solid var(--rule); border-radius: var(--radius-control); overflow: hidden; } +.post-external:hover { border-color: var(--brand); } +.post-external img { width: 96px; height: 96px; object-fit: cover; flex: none; } +.ext-body { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; min-width: 0; } +.ext-title { font-family: var(--mono); font-weight: 500; font-size: 13px; color: var(--inkstrong); } +.ext-desc { font-size: 13px; line-height: 1.5; color: var(--muted); } +.ext-host { font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em; color: var(--muted); } +.post-quote { display: block; margin: var(--space-3) 0; padding: var(--space-3); text-decoration: none; color: inherit; border: 1px solid var(--rule); border-radius: var(--radius-control); } +.post-quote:hover { border-color: var(--brand); } +.quote-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } +.quote-head img { border-radius: 50%; flex: none; } +.quote-name { font-family: var(--mono); font-weight: 700; font-size: 13px; color: var(--inkstrong); } +.quote-handle { font-family: var(--mono); font-size: 11px; color: var(--muted); } +.quote-text { display: block; font-size: 14px; line-height: 1.55; white-space: pre-wrap; } + +/* --- events --- */ +.events-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-5); } +@media (max-width: 720px) { .events-grid { grid-template-columns: 1fr; } } +.events-note { font-family: var(--mono); font-size: 12px; letter-spacing: 0.04em; color: var(--muted); margin-bottom: var(--space-5); } +.event-card { background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius-surface); padding: 28px; } +.event-card.next { border: 2px solid var(--accent); } +.event-top { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-3); } +.event-kicker { font-family: var(--mono); font-weight: 500; font-size: 12px; letter-spacing: 0.26em; text-transform: uppercase; color: var(--muted); } +.event-kicker.next { color: var(--accent-text); } +.event-date { font-family: var(--mono); font-weight: 500; font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--muted); } +.event-title { font-family: var(--mono); font-weight: 700; font-size: 26px; letter-spacing: 0.02em; text-transform: uppercase; color: var(--inkstrong); margin: 22px 0 0; } +@media (max-width: 720px) { .event-title { font-size: 22px; } } +.event-desc { font-size: 14px; line-height: 1.65; margin: 12px 0 0; } +.event-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; } + +/* --- join --- */ +.join-cta { margin-top: var(--space-6); } + /* --- footer --- */ .site-footer { border-top: 1px solid var(--rule); padding: var(--space-8) 0; margin-top: var(--space-22); } .site-footer .cols { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-6); align-items: flex-end; } -- 2.54.0