PortfolioSectionpopular
apps/www/src/sections/PortfolioSection.astro · 164 lines · group Dynamic content
mgmt.tools caches portfolio images for 24h; a daily marker busts it
In the fleet
Used on 3 sites built with this engine.
Samples 1
MDX of this sample
<PortfolioSection
title="Recent installations"
showMoreText="See more"
showMorePath="/portfolio/"
issuesId=""
/>Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| title | string | yes | ||
| description | string | |||
| issuesId | string | yes | Comma-separated Redmine issue ids, e.g. "101, 102, 103" | |
| sourceChangedTitle | Record<string, string> | Per-project override of the source link text, keyed by issue id | ||
| showMoreText | string | |||
| showMorePath | string | '/portfolio/' | ||
| page | boolean | false | Full-page variant (light, big h1) vs dark strip variant |
Source apps/www/src/sections/PortfolioSection.astro @ gitt.one
---
import ChevronRight from 'astro-heroicons/outline/ChevronRight.astro';
import pagesConfig from '../../pages.config.js';
interface Props {
title: string;
description?: string;
/** Comma-separated Redmine issue ids, e.g. "101, 102, 103" */
issuesId: string;
/** Per-project override of the source link text, keyed by issue id */
sourceChangedTitle?: Record<string, string>;
showMoreText?: string;
showMorePath?: string;
/** Full-page variant (light, big h1) vs dark strip variant */
page?: boolean;
}
interface Project {
id: number;
image: string;
title: string;
description: string;
source?: string;
sourceTitle?: string;
}
const {
title,
description,
issuesId,
sourceChangedTitle,
showMoreText,
showMorePath = '/portfolio/',
page = false,
} = Astro.props;
const { portfolioUrl, portfolioBearer } = pagesConfig as {
portfolioUrl?: string;
portfolioBearer?: string;
};
// mgmt.tools caches portfolio images for 24h; a daily marker busts it
const buildDate = new Date().toISOString().slice(0, 10);
let projects: Project[] | null = null;
if (!portfolioUrl) {
console.warn('PortfolioSection: pagesConfig.portfolioUrl is not set, rendering nothing');
} else {
try {
const ids = issuesId.replace(/\s/g, '');
const resp = await fetch(portfolioUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${portfolioBearer}`,
},
body: JSON.stringify({ issues: ids }),
});
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
const data: Project[] = await resp.json();
const byId = new Map(data.map((p) => [String(p.id), p]));
projects = [];
for (const id of ids.split(',')) {
const project = byId.get(id);
if (!project) continue;
const override = sourceChangedTitle?.[String(project.id)];
if (override) project.sourceTitle = override;
projects.push(project);
}
} catch (e) {
console.warn(`PortfolioSection: portfolio fetch failed (${e}), rendering nothing`);
projects = null;
}
}
const withVersion = (url: string) => `${url}?v=${buildDate}`;
---
{
projects && (
<div class="bg-white">
<div class:list={['mx-auto', !page && 'bg-stone-800 py-12 px-4 sm:px-6 lg:px-8']}>
<div class:list={['space-y-12 mx-auto', !page && 'max-w-7xl']}>
{page ? (
<div class="pb-16 pt-12 bg-stone-800 sm:pb-20 sm:pt-16">
<div class="max-w-md mx-auto px-4 sm:max-w-lg sm:px-6 lg:max-w-7xl lg:px-8">
<h1 class="text-4xl leading-10 font-extrabold tracking-tight text-white text-center sm:text-5xl sm:leading-none lg:text-6xl">
{title}
</h1>
{description && (
<p class="mt-6 max-w-3xl mx-auto text-xl leading-normal text-white/80 text-center">
{description}
</p>
)}
</div>
</div>
) : (
<div class="max-w-7xl mx-auto space-y-5 sm:space-y-4">
<h2 class="text-3xl text-white font-extrabold tracking-tight sm:text-4xl">
{title}
</h2>
</div>
)}
<div
class:list={[
'max-w-7xl mx-auto space-y-12 sm:grid sm:grid-cols-2 sm:gap-x-6 sm:gap-y-12 sm:space-y-0 lg:grid-cols-3 lg:gap-x-8',
page && 'pb-12 px-4',
]}
>
{projects.map((item) => (
<div class="bg-white flex flex-col rounded-lg shadow-lg overflow-hidden">
<div class="flex-shrink-0 lg:min-h-[4rem] lg:flex items-center justify-center">
<img
src={withVersion(item.image)}
loading="lazy"
width="600"
height="320"
class="max-h-80 sm:h-auto w-full object-cover"
alt={item.title}
/>
</div>
<div class="flex-1 bg-white flex flex-col justify-between p-4">
<div class="flex-1">
<div class="block mt-2">
<p class="text-xl font-semibold text-stone-900">{item.title}</p>
<p class="mt-3 text-base text-stone-500">{item.description}</p>
{item.source && (
<a
href={
item.source.includes('https://')
? item.source
: `https://${item.source}`
}
class="inline-block mt-3 text-base text-primary-700 hover:text-primary-800"
>
{item.sourceTitle || item.source}
</a>
)}
</div>
</div>
</div>
</div>
))}
</div>
{showMoreText && (
<div class="text-center">
<a
href={showMorePath}
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-700"
>
{showMoreText}{' '}
<ChevronRight class="flex-shrink-0 w-6 h-6 text-primary-200" aria-hidden="true" />
</a>
</div>
)}
</div>
</div>
</div>
)
}Source links point to engine-astro@38c294d9. Samples are demo content: the components are the real ones, the copy and the pictures are made up.