Zum Inhalt springen
DasPeTe Tutorials

Dein Portfolio mit Manablox · Teil 6 von 7 der Serie

Teil 6: Galerie, Kontakt und der letzte Schliff

Gestalte die übrigen Abschnitte, ergänze einen Inhaltstyp um ein Feld für einen Kontaktknopf, beschreib deine Seiten für Suchmaschinen und bau die Website für den Betrieb.

Quest-Ziele

  • Galerie, Aufruf zum Handeln, Über-mich-Seite und Zitat gestalten
  • Einem Blocktyp im Admin ein Feld hinzufügen
  • Jede Seite für Suchmaschinen und Link-Vorschauen beschreiben
  • Unbekannte Adressen mit einer freundlichen 404-Seite beantworten
  • Den Code prüfen und einen Produktions-Build erstellen

Startseite und Projekte sind fertig. Vier Blocktypen sehen noch schlicht aus: die Galerie, der Aufruf zum Handeln, Bild mit Text und das Zitat. Danach kommt die Kontaktseite, und dann alles, was eine Website braucht, bevor sie online geht.

Die Galerie

Die Galerie nutzt denselben Grid-Trick wie die Karten: so viele Spalten, wie hineinpassen.

src/components/blocks/GalleryBlock.astro
---
import { fieldAttribute } from '@manablox/live-preview';
import { assetUrl } from '@manablox/public-sdk';
import { assets, text } from '../../lib/fields';

interface Props {
  block: Record<string, unknown>;
  path: (string | number)[];
}

const { block, path } = Astro.props;
const headline = text(block.headline);
const images = assets(block.images);
---

<section class="block block-gallery" {...fieldAttribute(path)}>
  {headline && <h2 {...fieldAttribute([...path, 'headline'])}>{headline}</h2>}
  <div class="images" {...fieldAttribute([...path, 'images'])}>
    {
      images.map((image) => (
        <img
          class="rounded"
          src={assetUrl(image, { preset: 'card' })}
          alt={image.alt ?? ''}
          width={image.width ?? undefined}
          height={image.height ?? undefined}
          loading="lazy"
        />
      ))
    }
  </div>
</section>

<style>
  .images {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
    gap: 1rem;
  }
</style>

Der Aufruf zum Handeln

Der Aufruf zum Handeln am Ende der Startseite wird zu einem dunklen Kasten mit dem pinken Knopf in der Mitte:

src/components/blocks/CallToActionBlock.astro
---
import { fieldAttribute } from '@manablox/live-preview';
import { link, text } from '../../lib/fields';

interface Props {
  block: Record<string, unknown>;
  path: (string | number)[];
}

const { block, path } = Astro.props;
const headline = text(block.headline);
const textValue = text(block.text);
const button = link(block.button);
---

<section class="block block-call-to-action" {...fieldAttribute(path)}>
  {headline && <h2 {...fieldAttribute([...path, 'headline'])}>{headline}</h2>}
  {textValue && <p class="lead" {...fieldAttribute([...path, 'text'])}>{textValue}</p>}
  {
    button && (
      <p {...fieldAttribute([...path, 'button'])}>
        <a class="button" href={button.href}>{button.label}</a>
      </p>
    )
  }
</section>

<style>
  .block-call-to-action {
    border-radius: var(--radius);
    background: var(--ink);
    color: white;
    padding: 3rem 2rem;
    text-align: center;
  }
  .lead {
    color: #cfc8e6;
  }
</style>

Die Über-mich-Seite

Die Über-mich-Seite verwendet zwei weitere Blöcke. Image and text setzt das Bild neben den Text, aber nur, wenn der Bildschirm breit genug ist. Das macht die Regel @media (min-width: 48rem): Unter 48rem, etwa 768 Pixeln, stehen Bild und Text untereinander.

src/components/blocks/MediaTextBlock.astro
---
import { fieldAttribute } from '@manablox/live-preview';
import { assetUrl } from '@manablox/public-sdk';
import { assets, html, link, text } from '../../lib/fields';

interface Props {
  block: Record<string, unknown>;
  path: (string | number)[];
}

const { block, path } = Astro.props;
const headline = text(block.headline);
const body = html(block.body);
const image = assets(block.image)[0];
const button = link(block.button);
---

<section class="block block-media-text" {...fieldAttribute(path)}>
  {
    image && (
      <img
        class="rounded"
        src={assetUrl(image, { preset: 'card' })}
        alt={image.alt ?? ''}
        width={image.width ?? undefined}
        height={image.height ?? undefined}
        loading="lazy"
        {...fieldAttribute([...path, 'image'])}
      />
    )
  }
  <div>
    {headline && <h2 {...fieldAttribute([...path, 'headline'])}>{headline}</h2>}
    {body && <div {...fieldAttribute([...path, 'body'])} set:html={body} />}
    {
      button && (
        <p {...fieldAttribute([...path, 'button'])}>
          <a class="button" href={button.href}>{button.label}</a>
        </p>
      )
    }
  </div>
</section>

<style>
  .block-media-text {
    display: grid;
    gap: 2rem;
    align-items: center;
  }
  /* Side by side once there is room for both. */
  @media (min-width: 48rem) {
    .block-media-text {
      grid-template-columns: 1fr 1.5fr;
    }
  }
</style>

Das Quote wird zu einer <figure> mit einem <blockquote> und einer Bildunterschrift dafür, wer es gesagt hat, den HTML-Elementen, die für Zitate gemacht sind:

src/components/blocks/QuoteBlock.astro
---
import { fieldAttribute } from '@manablox/live-preview';
import { text } from '../../lib/fields';

interface Props {
  block: Record<string, unknown>;
  path: (string | number)[];
}

const { block, path } = Astro.props;
const quote = text(block.quote);
const author = text(block.author);
const role = text(block.role);
---

<figure class="block block-quote" {...fieldAttribute(path)}>
  {quote && <blockquote {...fieldAttribute([...path, 'quote'])}>“{quote}”</blockquote>}
  <figcaption>
    {author && <strong {...fieldAttribute([...path, 'author'])}>{author}</strong>}
    {role && <span {...fieldAttribute([...path, 'role'])}>, {role}</span>}
  </figcaption>
</figure>

<style>
  .block-quote {
    margin-inline: 0;
    border-left: 6px solid var(--pink);
    padding-left: 1.5rem;
  }
  blockquote {
    margin: 0 0 0.75rem;
    font-size: 1.75rem;
    line-height: 1.3;
  }
  figcaption {
    color: var(--muted);
  }
</style>
Die Über-mich-Seite mit dem Bild neben dem Text, und das Zitat.

Ein Kontaktknopf

Das Kontaktformular der gestalteten Website funktioniert in deinem eigenen Frontend nicht. Es schickt seine Nachrichten über den Site-Prozess, der einen geheimen Schlüssel hat, und die öffentliche API liest nur. Ein einfacher Knopf, der eine E-Mail an dich öffnet, erledigt die Aufgabe ganz ohne Server-Code. Aber woher kommt die E-Mail-Adresse?

Natürlich aus dem CMS. Ergänze den Block Contact form um ein Feld: Öffne im Admin Content types, wechsle zu Blocks, öffne Contact form und klick auf Add field.

Die Felder des Blocktyps Contact form.

Eine E-Mail-Adresse ist Text, wähl also Text.

Alle Arten von Feldern: Text, Zahlen, Datum, Links, Bilder und mehr.

Setz das Label auf Email. Der technische Name wird zu email, und unter diesem Namen liest dein Code das Feld. Klick auf Save.

Das neue Feld Email, technischer Name email.

Öffne jetzt die Seite Contact, klapp ihren Block Contact form auf, und da ist das neue Feld. Trag deine Adresse ein und veröffentliche.

Der Kontaktblock hat jetzt ein Feld Email.

In der Komponente geht das Formularfeld und der Knopf kommt. Links mit mailto: öffnen das E-Mail-Programm der Besucher, mit deiner Adresse schon eingetragen.

src/components/blocks/ContactBlock.astro
---
import { fieldAttribute } from '@manablox/live-preview';
import { text } from '../../lib/fields';

interface Props {
  block: Record<string, unknown>;
  path: (string | number)[];
}

const { block, path } = Astro.props;
const headline = text(block.headline);
const textValue = text(block.text);
const email = text(block.email);
---

<section class="block block-contact" {...fieldAttribute(path)}>
  {headline && <h2 {...fieldAttribute([...path, 'headline'])}>{headline}</h2>}
  {textValue && <p class="lead" {...fieldAttribute([...path, 'text'])}>{textValue}</p>}
  {
    email && (
      <p {...fieldAttribute([...path, 'email'])}>
        <a class="button" href={`mailto:${email}`}>Write me an email</a>
      </p>
    )
  }
</section>
Die Kontaktseite mit ihrem neuen Knopf.

Für Suchmaschinen und Link-Vorschauen

Wenn jemand einen Link auf dein Portfolio teilt, zeigen Messenger und soziale Netzwerke eine Vorschau mit Titel, Beschreibung und Bild. Auch Suchmaschinen zeigen die Beschreibung unter dem Titel. All das lesen sie aus <meta>-Tags im <head> der Seite. Das Layout bekommt dafür zwei neue Props. Ersetze das interface Props und die Zeile danach in src/layouts/Site.astro:

src/layouts/Site.astro
interface Props {
  title: string;
  description?: string | null;
  image?: string | null;
  bare?: boolean;
}

const { title, description, image, bare = false } = Astro.props;

und füg die Tags direkt unter dem <title> ein:

src/layouts/Site.astro
<title>{title === siteName ? title : `${title} · ${siteName}`}</title>
    {description && <meta name="description" content={description} />}
    <meta property="og:title" content={title} />
    {description && <meta property="og:description" content={description} />}
    {image && <meta property="og:image" content={image} />}

Die Seiten-Route füllt sie aus: Die Zusammenfassung wird zur Beschreibung, und das Titelbild eines Projekts wird zum Bild. Wo du schon in der Datei bist, bekommt sie auch eine echte 404-Seite. Bisher bekam eine unbekannte Adresse eine leere Antwort, jetzt bekommt sie dein Layout, eine kurze Nachricht und einen Weg zurück.

src/pages/[...slug].astro
---
import { assetUrl, blocksOf, gridOf, ManabloxError } from '@manablox/public-sdk';
import Blocks from '../components/Blocks.astro';
import { contentRenderers } from '../components/content/index';
import Site from '../layouts/Site.astro';
import { assets, text } from '../lib/fields';
import { manablox } from '../lib/manablox';

const client = manablox();
const path = Astro.url.pathname;

let page: Record<string, unknown> | null = null;
let failure: string | null = null;

try {
  // `expand` inlines the relations the components show, which would otherwise be ids.
  page = await client.byPermalink(path, {
    expand: ['cover', 'gallery', 'image', 'images', 'projects'],
  });
} catch (error) {
  failure = error instanceof ManabloxError ? error.message : String(error);
}

const notFound = !failure && !page;
const fields = (page?.fields ?? {}) as Record<string, unknown>;
const blocks = blocksOf(fields.components) as Record<string, unknown>[];
const grid = gridOf(fields.components);
const Content = page ? contentRenderers[String(page.type)] : undefined;

// What search engines and link previews show: the summary, and the cover if there is one.
const description = text(fields.summary);
const cover = assets(fields.cover)[0];
const image = cover ? assetUrl(cover, { preset: 'hero' }) : null;

Astro.response.status = failure ? 503 : notFound ? 404 : 200;
Astro.response.headers.set(
  'cache-control',
  failure ? 'no-store' : 'public, max-age=0, s-maxage=300',
);
---

<Site
  title={failure ? 'Something went wrong' : notFound ? 'Page not found' : String(page?.title ?? '')}
  description={description}
  image={image}
>
  {
    notFound ? (
      <div class="state">
        <h1>Page not found</h1>
        <p class="lead">There is nothing here. Maybe the link is old, or the page moved.</p>
        <a class="button" href="/">Back to the start</a>
      </div>
    ) : failure ? (
      <div class="state">
        <h1>Something went wrong</h1>
        <p>{failure}</p>
      </div>
    ) : Content ? (
      <Content node={{ title: String(page?.title ?? ''), fields }} />
    ) : (
      <article>
        <h1>{String(page?.title || 'Untitled')}</h1>
        <Blocks blocks={blocks} path={['components']} grid={grid} />
      </article>
    )
  }
</Site>

<style>
  .state {
    padding: 4rem 0;
    text-align: center;
  }
  .state h1 {
    font-size: 3rem;
  }
</style>

Astro.response.status sagt Browsern und Suchmaschinen weiterhin, dass es die Seite nicht gibt, so landet die 404-Seite nie in Suchergebnissen. Prüf die Tags mit curl, das eine Seite im Terminal abruft, oder mit Seitenquelltext anzeigen in deinem Browser:

~/projects/my-site
$ curl -s http://localhost:3005/work/pixel-weather | grep '<meta'<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="A tiny weather app that shows the forecast as pixel art landscapes."><meta property="og:title" content="Pixel Weather"><meta property="og:description" content="A tiny weather app that shows the forecast as pixel art landscapes."><meta property="og:image" content="http://localhost:3100/media/29d8b48b-b908-49b5-9572-d477c8a05115/hero.webp?s=wY1eoDbUJRYSujfWdOJ0ssPEJUGe4gL6&amp;v=mw0c4cpy">
Eine Adresse, die es nicht gibt.Den Screenshot in voller Größe öffnen
Eine Adresse, die es nicht gibt.

Auf dem Handy prüfen

Die meisten Besucher kommen mit dem Handy. Öffne die Entwicklerwerkzeuge deines Browsers (F12) und schalte die Geräteansicht ein, um die Website in Handygröße zu sehen. Der Kopfbereich bricht um, die Überschrift des Heros schrumpft mit clamp(), und die Karten stapeln sich in einer Spalte:

Die Startseite auf dem Handy.
Eine Karte pro Zeile.

Den Code prüfen

pnpm typecheck führt astro check aus, das jede Datei nach Fehlern durchsucht, etwa nach einer falsch geschriebenen Variable oder einer Prop, die eine Komponente nicht kennt:

~/projects/my-site
$ pnpm typecheck07:16:29 [check] Getting diagnostics for Astro files in /home/robin/projects/my-site...Result (36 files):- 0 errors- 0 warnings- 0 hints

Der Produktions-Build

pnpm dev ist zum Arbeiten gemacht: Es beobachtet deine Dateien und baut neu, was sich geändert hat. Ein echter Server führt stattdessen einen Build aus: die fertige, optimierte Website, einmal gebaut. Beende den Entwicklungsserver mit Strg+C, bau und starte:

~/projects/my-site
$ pnpm build07:17:04 [build] output: "server"07:17:04 [build] adapter: @astrojs/node07:17:04 [build] Building server entrypoints...07:17:04 [build] Server built in 435ms07:17:04 [build] Complete!$ pnpm start07:17:10 [@astrojs/node] Server listening on  local: http://localhost:3005

Öffne localhost:3005: dieselbe Website, jetzt so, wie sie auf dem Server laufen wird. Der Build liegt in dist/, und pnpm start führt dist/server/entry.mjs mit Node aus. Ändere etwas im Admin und lade neu: Der Build liest das CMS, während er läuft, Inhalte erscheinen also ohne neuen Build, innerhalb einer halben Minute, weil der Client in src/lib/manablox.ts Antworten 30 Sekunden lang aufhebt. Nur Änderungen am Code brauchen einen neuen Build.

Dein Portfolio ist fertig. Im letzten Teil geht es online.

Kommentare

Die Kommentare sind GitHub Discussions: Zum Schreiben mit GitHub anmelden.

Die Kommentare kommen über giscus von GitHub. Beim Laden gehen deine IP-Adresse an giscus.app und GitHub.