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

sample
MDX of this sample
<PortfolioSection
  title="Recent installations"
  showMoreText="See more"
  showMorePath="/portfolio/"
  issuesId=""
/>

Props

NameTypeRequiredDefaultDescription
titlestringyesthe heading
descriptionstringa paragraph under the heading; page shape only
issuesIdstringyescomma-separated Redmine issue ids, in display order, e.g. "101, 102, 103"
sourceChangedTitleRecord<string, string>per-project override of the source link text, keyed by issue id
showMoreTextstringtext of the button under the cards; omit to hide it
showMorePathstring'/portfolio/'link of that button
pagebooleanfalsefull-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.