Skip to content

Commit df34f41

Browse files
committed
feat(boards): clickable vendor link and sibling boards carousel
1 parent dc9720c commit df34f41

21 files changed

Lines changed: 301 additions & 34 deletions

File tree

apps/www/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@payloadcms/richtext-lexical": "^3.81.0",
2323
"@tailwindcss/typography": "^0.5.19",
2424
"drizzle-kit": "^0.31.10",
25+
"embla-carousel-react": "^8.6.0",
2526
"jose": "^6.0.0",
2627
"lucide-react": "^1.7.0",
2728
"motion": "^12.38.0",

apps/www/src/app/(frontend)/[locale]/boards/[slug]/page.tsx

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import { DonationBanner } from '@/components/board/donation-banner';
1111
import { BoardJsonLd } from '@/components/board/board-jsonld';
1212
import { BoardImage as BoardImageWithFallback } from '@/components/board/board-image';
1313
import { Link } from '@/i18n/navigation';
14-
import type { Image as BoardImage } from '@armbian/schemas';
14+
import type { BoardSummary, Image as BoardImage } from '@armbian/schemas';
1515
import { Download, BookOpen, Code, ArrowRight, ArrowLeft } from 'lucide-react';
1616
import { SiGithub } from '@icons-pack/react-simple-icons';
1717
import type { Metadata } from 'next';
1818
import { BoardPageDownloads } from '@/components/board/board-page-downloads';
1919
import { FlashGuideModal } from '@/components/board/flash-guide-modal';
20+
import { SiblingBoardsCarousel } from '@/components/board/sibling-boards-carousel';
2021
import { getPayload } from 'payload';
2122
import config from '@payload-config';
2223
import { sanitizeCmsHtml } from '@/lib/sanitize';
@@ -170,19 +171,39 @@ export default async function BoardPage({ params }: Props) {
170171
throw err;
171172
}
172173

173-
// Fetch flash guide from Payload CMS. Payload native localization
174-
// handles the EN fallback via `fallbackLocale` — no manual two-step.
174+
// Sibling boards (for the "More from X" carousel) and the Payload flash
175+
// guide both depend only on `board` and are independent — fire them in
176+
// parallel to save a round trip on every page render.
177+
const [siblingResult, flashGuideResult] = await Promise.allSettled([
178+
api.getBoards({
179+
vendor: board.vendor_slug,
180+
sort: 'popularity',
181+
// 13 = 12 siblings the carousel can show + room to filter out the
182+
// current board if it appears in the popularity ordering.
183+
limit: 13,
184+
}),
185+
(async () => {
186+
const payload = await getPayload({ config });
187+
return payload.find({
188+
collection: 'flash-guides',
189+
where: { boardSlug: { equals: slug } },
190+
locale: locale as 'en' | 'it',
191+
fallbackLocale: 'en',
192+
limit: 1,
193+
});
194+
})(),
195+
]);
196+
197+
let siblingBoards: BoardSummary[] = [];
198+
let vendorBoardCount = 0;
199+
if (siblingResult.status === 'fulfilled') {
200+
vendorBoardCount = siblingResult.value.meta.total ?? 0;
201+
siblingBoards = siblingResult.value.data.filter((b) => b.slug !== board.slug).slice(0, 12);
202+
}
203+
175204
let flashGuide: { title: string; content: string; prerequisites: string[] } | null = null;
176-
try {
177-
const payload = await getPayload({ config });
178-
const result = await payload.find({
179-
collection: 'flash-guides',
180-
where: { boardSlug: { equals: slug } },
181-
locale: locale as 'en' | 'it',
182-
fallbackLocale: 'en',
183-
limit: 1,
184-
});
185-
const doc = result.docs[0];
205+
if (flashGuideResult.status === 'fulfilled') {
206+
const doc = flashGuideResult.value.docs[0];
186207
if (doc) {
187208
const prereqs = Array.isArray(doc.prerequisites)
188209
? doc.prerequisites.map((p: { item?: string }) => p.item ?? '')
@@ -202,8 +223,6 @@ export default async function BoardPage({ params }: Props) {
202223
prerequisites: prereqs.filter(Boolean),
203224
};
204225
}
205-
} catch {
206-
/* Payload not available — skip flash guide */
207226
}
208227

209228
const isTrunk = (img: BoardImage) => img.release.toLowerCase().includes('trunk');
@@ -284,9 +303,12 @@ export default async function BoardPage({ params }: Props) {
284303
</div>
285304

286305
<div className="flex-1 min-w-0">
287-
<span className="text-[11px] text-[rgb(var(--brand))] font-mono font-bold uppercase tracking-widest mb-2 block">
306+
<Link
307+
href={`/vendors/${board.vendor_slug}`}
308+
className="text-[11px] text-[rgb(var(--brand))] font-mono font-bold uppercase tracking-widest mb-2 inline-block hover:underline"
309+
>
288310
{board.vendor_name}
289-
</span>
311+
</Link>
290312
<div className="flex flex-wrap items-center gap-3 mb-3">
291313
<h1 className="text-fluid-2xl font-black tracking-tight">{board.name}</h1>
292314
<SupportBadge tier={board.support_tier} />
@@ -426,6 +448,19 @@ export default async function BoardPage({ params }: Props) {
426448
</section>
427449
</ScrollReveal>
428450

451+
{siblingBoards.length > 0 && (
452+
<ScrollReveal>
453+
<section className="mb-20">
454+
<SiblingBoardsCarousel
455+
boards={siblingBoards}
456+
vendorName={board.vendor_name}
457+
vendorSlug={board.vendor_slug}
458+
vendorBoardCount={vendorBoardCount}
459+
/>
460+
</section>
461+
</ScrollReveal>
462+
)}
463+
429464
<DonationBanner />
430465
</div>
431466
</div>
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useState } from 'react';
4+
import useEmblaCarousel from 'embla-carousel-react';
5+
import { useTranslations } from 'next-intl';
6+
import { ArrowRight, ChevronLeft, ChevronRight } from 'lucide-react';
7+
import { Link } from '@/i18n/navigation';
8+
import { BoardImage } from '@/components/board/board-image';
9+
import type { BoardSummary } from '@armbian/schemas';
10+
11+
interface SiblingBoardsCarouselProps {
12+
boards: BoardSummary[];
13+
vendorName: string;
14+
vendorSlug: string;
15+
vendorBoardCount: number;
16+
}
17+
18+
export function SiblingBoardsCarousel({
19+
boards,
20+
vendorName,
21+
vendorSlug,
22+
vendorBoardCount,
23+
}: SiblingBoardsCarouselProps) {
24+
const t = useTranslations('board');
25+
const [emblaRef, emblaApi] = useEmblaCarousel({
26+
loop: false,
27+
align: 'start',
28+
containScroll: 'trimSnaps',
29+
slidesToScroll: 'auto',
30+
});
31+
const [canPrev, setCanPrev] = useState(false);
32+
const [canNext, setCanNext] = useState(false);
33+
34+
const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]);
35+
const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]);
36+
37+
useEffect(() => {
38+
if (!emblaApi) return;
39+
const update = () => {
40+
setCanPrev(emblaApi.canScrollPrev());
41+
setCanNext(emblaApi.canScrollNext());
42+
};
43+
update();
44+
emblaApi.on('select', update);
45+
emblaApi.on('reInit', update);
46+
return () => {
47+
emblaApi.off('select', update);
48+
emblaApi.off('reInit', update);
49+
};
50+
}, [emblaApi]);
51+
52+
const showSeeAll = vendorBoardCount > boards.length + 1;
53+
const showArrows = canPrev || canNext;
54+
55+
return (
56+
<>
57+
<div className="flex items-center justify-between flex-wrap gap-x-3 gap-y-2 mb-6">
58+
<h2 className="text-lg font-bold">
59+
{t.rich('more_from', {
60+
vendor: vendorName,
61+
v: (chunks) => <span className="text-[rgb(var(--brand))]">{chunks}</span>,
62+
})}
63+
</h2>
64+
<div className="flex items-center gap-3 ml-auto">
65+
{showArrows && (
66+
<div className="flex items-center gap-1.5">
67+
<button
68+
type="button"
69+
onClick={scrollPrev}
70+
disabled={!canPrev}
71+
aria-label={t('carousel_prev')}
72+
className="w-8 h-8 rounded-full border border-[rgb(var(--border))] flex items-center justify-center transition-colors hover:border-[rgb(var(--brand)/0.5)] hover:text-[rgb(var(--brand))] disabled:opacity-30 disabled:hover:border-[rgb(var(--border))] disabled:hover:text-current disabled:cursor-not-allowed"
73+
>
74+
<ChevronLeft size={16} strokeWidth={2} />
75+
</button>
76+
<button
77+
type="button"
78+
onClick={scrollNext}
79+
disabled={!canNext}
80+
aria-label={t('carousel_next')}
81+
className="w-8 h-8 rounded-full border border-[rgb(var(--border))] flex items-center justify-center transition-colors hover:border-[rgb(var(--brand)/0.5)] hover:text-[rgb(var(--brand))] disabled:opacity-30 disabled:hover:border-[rgb(var(--border))] disabled:hover:text-current disabled:cursor-not-allowed"
82+
>
83+
<ChevronRight size={16} strokeWidth={2} />
84+
</button>
85+
</div>
86+
)}
87+
{showSeeAll && (
88+
<Link
89+
href={`/vendors/${vendorSlug}`}
90+
className="inline-flex items-center gap-1.5 text-xs font-semibold text-[rgb(var(--brand))] hover:underline"
91+
>
92+
{t('see_all_vendor', { count: vendorBoardCount })}
93+
<ArrowRight size={13} strokeWidth={2} />
94+
</Link>
95+
)}
96+
</div>
97+
</div>
98+
<div className="overflow-hidden -my-4 py-4 -mx-4 px-4" ref={emblaRef}>
99+
<div className="flex gap-3">
100+
{boards.map((sib) => (
101+
<Link
102+
key={sib.slug}
103+
href={`/boards/${sib.slug}`}
104+
className="hw-card rounded-2xl p-4 group relative flex flex-col w-[220px] shrink-0"
105+
>
106+
<div className="h-28 w-full flex items-center justify-center relative mb-3 z-10 p-3">
107+
<div className="absolute inset-0 bg-white/[0.03] rounded-xl group-hover:bg-[rgb(var(--brand)/0.05)] transition-colors border border-white/5 group-hover:border-[rgb(var(--brand)/0.2)]" />
108+
<div className="relative z-10 h-full flex items-center justify-center">
109+
<BoardImage src={sib.image_url} alt={sib.name} />
110+
</div>
111+
</div>
112+
<div className="mt-auto border-t border-white/10 pt-3 z-10 flex items-end justify-between gap-2">
113+
<div className="min-w-0">
114+
<span className="text-[9px] text-[rgb(var(--brand))] font-mono font-bold uppercase tracking-widest mb-0.5 block truncate">
115+
{sib.vendor_name}
116+
</span>
117+
<h3 className="text-sm font-bold group-hover:text-[rgb(var(--brand))] transition-colors tracking-tight truncate">
118+
{sib.name}
119+
</h3>
120+
</div>
121+
<div className="w-6 h-6 rounded-full bg-[rgb(var(--brand)/0.1)] border border-[rgb(var(--brand)/0.2)] flex items-center justify-center shrink-0 group-hover:bg-[rgb(var(--brand))] group-hover:border-[rgb(var(--brand))] transition-all">
122+
<ArrowRight
123+
size={12}
124+
strokeWidth={2.5}
125+
className="text-[rgb(var(--brand))] group-hover:text-black transition-colors"
126+
/>
127+
</div>
128+
</div>
129+
</Link>
130+
))}
131+
</div>
132+
</div>
133+
</>
134+
);
135+
}

apps/www/src/messages/de.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,11 @@
190190
"files": "Dateien",
191191
"flash_alongside_main": "Zusammen mit dem Hauptimage flashen",
192192
"display_panel": "Displaypanel",
193-
"boot_for_display": "Boot für {label}"
193+
"boot_for_display": "Boot für {label}",
194+
"more_from": "Mehr von <v>{vendor}</v>",
195+
"see_all_vendor": "Alle {count} Boards ansehen",
196+
"carousel_prev": "Vorherige Boards",
197+
"carousel_next": "Nächste Boards"
194198
},
195199
"support": {
196200
"platinum": "Platin",

apps/www/src/messages/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,11 @@
200200
"files": "files",
201201
"flash_alongside_main": "Flash alongside the main image",
202202
"display_panel": "Display panel",
203-
"boot_for_display": "Boot for {label}"
203+
"boot_for_display": "Boot for {label}",
204+
"more_from": "More from <v>{vendor}</v>",
205+
"see_all_vendor": "See all {count} boards",
206+
"carousel_prev": "Previous boards",
207+
"carousel_next": "Next boards"
204208
},
205209
"support": {
206210
"platinum": "Platinum",

apps/www/src/messages/es.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,11 @@
190190
"files": "archivos",
191191
"flash_alongside_main": "Flashear junto con la imagen principal",
192192
"display_panel": "Panel de pantalla",
193-
"boot_for_display": "Arranque para {label}"
193+
"boot_for_display": "Arranque para {label}",
194+
"more_from": "Más de <v>{vendor}</v>",
195+
"see_all_vendor": "Ver las {count} placas",
196+
"carousel_prev": "Placas anteriores",
197+
"carousel_next": "Placas siguientes"
194198
},
195199
"support": {
196200
"platinum": "Platino",

apps/www/src/messages/fr.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,11 @@
190190
"files": "fichiers",
191191
"flash_alongside_main": "Flasher avec l'image principale",
192192
"display_panel": "Panneau d'affichage",
193-
"boot_for_display": "Démarrage pour {label}"
193+
"boot_for_display": "Démarrage pour {label}",
194+
"more_from": "Plus de <v>{vendor}</v>",
195+
"see_all_vendor": "Voir les {count} cartes",
196+
"carousel_prev": "Cartes précédentes",
197+
"carousel_next": "Cartes suivantes"
194198
},
195199
"support": {
196200
"platinum": "Platine",

apps/www/src/messages/hr.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,11 @@
190190
"files": "datoteka",
191191
"flash_alongside_main": "Ispiši zajedno s glavnom slikom",
192192
"display_panel": "Ploča zaslona",
193-
"boot_for_display": "Pokretanje za {label}"
193+
"boot_for_display": "Pokretanje za {label}",
194+
"more_from": "Više od <v>{vendor}</v>",
195+
"see_all_vendor": "Prikaži svih {count} ploča",
196+
"carousel_prev": "Prethodne ploče",
197+
"carousel_next": "Sljedeće ploče"
194198
},
195199
"support": {
196200
"platinum": "Platinasta",

apps/www/src/messages/it.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,11 @@
200200
"files": "file",
201201
"flash_alongside_main": "Da flashare insieme all'immagine principale",
202202
"display_panel": "Pannello del display",
203-
"boot_for_display": "Avvio per {label}"
203+
"boot_for_display": "Avvio per {label}",
204+
"more_from": "Altre di <v>{vendor}</v>",
205+
"see_all_vendor": "Vedi tutte le {count} board",
206+
"carousel_prev": "Board precedenti",
207+
"carousel_next": "Board successive"
204208
},
205209
"support": {
206210
"platinum": "Platino",

apps/www/src/messages/ja.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,11 @@
190190
"files": "ファイル",
191191
"flash_alongside_main": "メインイメージと一緒に書き込む",
192192
"display_panel": "ディスプレイパネル",
193-
"boot_for_display": "{label} 用ブート"
193+
"boot_for_display": "{label} 用ブート",
194+
"more_from": "<v>{vendor}</v> の他のボード",
195+
"see_all_vendor": "{count} 件すべて表示",
196+
"carousel_prev": "前のボード",
197+
"carousel_next": "次のボード"
194198
},
195199
"support": {
196200
"platinum": "プラチナ",

0 commit comments

Comments
 (0)