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

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

Props

NameTypeRequiredDefaultDescription
titlestringyes
descriptionstring
issuesIdstringyesComma-separated Redmine issue ids, e.g. "101, 102, 103"
sourceChangedTitleRecord<string, string>Per-project override of the source link text, keyed by issue id
showMoreTextstring
showMorePathstring'/portfolio/'
pagebooleanfalseFull-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.