PortfolioSectionpopular
apps/www/src/sections/PortfolioSection.astro · 194 lines · group Dynamic content
PortfolioSection — project cards pulled from the mgmt.tools portfolio at build time: picture, title, description and a source link per project. Use for: "our work", "recent installations", a case-study list — where the projects are maintained as Redmine issues rather than in the content repo. Not for: a hand-written list of projects (<Gallery> with <GalleryItem>, or <FeatureCards>). Children: none. The cards come from the API: `issuesId` names the Redmine issues, in the order they should appear. Shapes: default — a dark strip with a heading and the cards; page={true} — a full page with a big title on a dark band and the cards on white. Setup: needs `portfolioUrl` and `portfolioBearer` in pages.config.js; without them the section renders nothing and the build logs a warning. The cards show what the issues held at the last build — editing an issue needs a rebuild. Ask the user: the portfolio is a Redmine project hosted by the Gitt One Pages team, who also issue the two config values. Write the tag with the issue ids the user gives; never invent ids, URLs or tokens, and never copy `portfolioBearer` from another site. When the values or the ids are missing, end the reply with what the user has to do: ask the team for a portfolio, put `portfolioUrl` and `portfolioBearer` into pages.config.js themselves, and send the issue ids in the order the cards should appear. Instructions for people: https://docs.pages.gitt.one/setup/contact-form-and-portfolio/
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 | the heading | |
| description | string | a paragraph under the heading; page shape only | ||
| issuesId | string | yes | comma-separated Redmine issue ids, in display order, e.g. "101, 102, 103" | |
| sourceChangedTitle | Record<string, string> | per-project override of the source link text, keyed by issue id | ||
| showMoreText | string | text of the button under the cards; omit to hide it | ||
| showMorePath | string | '/portfolio/' | link of that button | |
| page | boolean | false | full-page shape (big title on a dark band, cards on white) instead of the dark strip |
Source apps/www/src/sections/PortfolioSection.astro @ gitt.one
---
/**
* PortfolioSection — project cards pulled from the mgmt.tools portfolio at
* build time: picture, title, description and a source link per project.
*
* Use for: "our work", "recent installations", a case-study list — where the
* projects are maintained as Redmine issues rather than in the content repo.
* Not for: a hand-written list of projects (<Gallery> with <GalleryItem>, or
* <FeatureCards>).
* Children: none. The cards come from the API: `issuesId` names the Redmine
* issues, in the order they should appear.
* Shapes: default — a dark strip with a heading and the cards; page={true} — a
* full page with a big title on a dark band and the cards on white.
* Setup: needs `portfolioUrl` and `portfolioBearer` in pages.config.js;
* without them the section renders nothing and the build logs a warning. The
* cards show what the issues held at the last build — editing an issue needs
* a rebuild.
* Ask the user: the portfolio is a Redmine project hosted by the Gitt One
* Pages team, who also issue the two config values. Write the tag with the
* issue ids the user gives; never invent ids, URLs or tokens, and never copy
* `portfolioBearer` from another site. When the values or the ids are
* missing, end the reply with what the user has to do: ask the team for a
* portfolio, put `portfolioUrl` and `portfolioBearer` into pages.config.js
* themselves, and send the issue ids in the order the cards should appear.
* Instructions for people:
* https://docs.pages.gitt.one/setup/contact-form-and-portfolio/
*/
import ChevronRight from 'astro-heroicons/outline/ChevronRight.astro';
import pagesConfig from '../../pages.config.js';
interface Props {
/** the heading */
title: string;
/** a paragraph under the heading; page shape only */
description?: string;
/** comma-separated Redmine issue ids, in display order, e.g. "101, 102, 103" */
issuesId: string;
/** per-project override of the source link text, keyed by issue id */
sourceChangedTitle?: Record<string, string>;
/** text of the button under the cards; omit to hide it */
showMoreText?: string;
/** link of that button */
showMorePath?: string;
/** full-page shape (big title on a dark band, cards on white) instead of the dark strip */
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@7b686388. Samples are demo content: the components are the real ones, the copy and the pictures are made up.