Die Storytellerin: Portfolio Query

portfolio-query.php

PHP
<?php
// ---------- GW Portfolio Shortcode (Child Theme) ----------
add_action('init', function () {
    // Assets registrieren (noch nicht laden)
    $dir = get_stylesheet_directory();      // Child-Theme Pfad
    $url = get_stylesheet_directory_uri();  // Child-Theme URL

    $css_path = $dir . '/assets/styles/portfolio.css';
    $js_path  = $dir . '/assets/scripts/portfolio.js';

    wp_register_style(
        'gw-portfolio',
        $url . '/assets/styles/portfolio.css',
        [],
        file_exists($css_path) ? filemtime($css_path) : null
    );

    wp_register_script(
        'gw-portfolio',
        $url . '/assets/scripts/portfolio.js',
        [],
        file_exists($js_path) ? filemtime($js_path) : null,
        true // in_footer
    );

    // Optional: Defer für bessere Performance (WP 6.3+)
    if (function_exists('wp_script_add_data')) {
        wp_script_add_data('gw-portfolio', 'strategy', 'defer');
    }

    // Shortcode registrieren
    add_shortcode('portfolio', 'gw_child_portfolio_shortcode');
});

function gw_child_portfolio_shortcode($atts) {
    $atts = shortcode_atts([
        'posts_per_page' => -1,         // alles rendern, Pagination macht JS
        'taxonomy'       => 'kategorie',
        'post_type'      => 'portfolio',
        'page_desktop'   => 16,
        'page_mobile'    => 6,
    ], $atts, 'portfolio');

    // Assets NUR laden, wenn Shortcode tatsächlich auf der Seite ist
    wp_enqueue_style('gw-portfolio');
    wp_enqueue_script('gw-portfolio');

    // (Optional) Strings & Konfig ans JS übergeben
    wp_localize_script('gw-portfolio', 'GWPortfolioConfig', [
        'i18n' => [
            'loadMore' => 'Weitere Beiträge laden',
            'noMore'   => 'Keine weiteren Beiträge',
        ],
    ]);

    // Terms alphabetisch, nur belegte
    $terms = get_terms([
        'taxonomy'   => $atts['taxonomy'],
        'hide_empty' => true,
        'orderby'    => 'name',
        'order'      => 'ASC',
    ]);

    $total_posts = (int) wp_count_posts($atts['post_type'])->publish;

    // Alle Portfolio-IDs (Server sortiert)
    $q = new WP_Query([
        'post_type'      => $atts['post_type'],
        'post_status'    => 'publish',
        'posts_per_page' => $atts['posts_per_page'],
        'orderby'        => 'date',
        'order'          => 'DESC',
        'no_found_rows'  => true,
        'fields'         => 'ids',
        'update_post_meta_cache' => true,
        'update_post_term_cache' => true,
    ]);

    $uid = uniqid('pf_', false);

    ob_start(); ?>
    <div id="<?php echo esc_attr($uid); ?>" class="pf-wrap"
        data-pagesize-desktop="<?php echo (int) $atts['page_desktop']; ?>"
        data-pagesize-mobile="<?php echo (int) $atts['page_mobile']; ?>">

        <nav class="pf-filter" aria-label="Portfolio Filter">
            <ul class="pf-filter-list" role="list">
                <li>
                    <button type="button" class="pf-chip is-active" data-filter="__all__" aria-pressed="true">
                        Alle <span class="pf-count">(<?php echo (int) $total_posts; ?>)</span>
                    </button>
                </li>
                <?php foreach ($terms as $t): ?>
                    <li>
                        <button type="button" class="pf-chip" data-filter="<?php echo esc_attr($t->slug); ?>" aria-pressed="false">
                            <?php echo esc_html($t->name); ?> <span class="pf-count">(<?php echo (int) $t->count; ?>)</span>
                        </button>
                    </li>
                <?php endforeach; ?>
            </ul>
        </nav>

        <div class="pf-grid" role="list">
            <?php if ($q->posts) :
                foreach ($q->posts as $post_id) :
                    $link_url = get_post_meta($post_id, 'link_url', true);
                    $permalink = $link_url ? esc_url($link_url) : get_permalink($post_id);
                    $target    = $link_url ? ' target="_blank" rel="noopener"' : '';
                    $ext_icon  = $link_url ? '<span class="external-link-icon" aria-hidden="true">↗</span>' : '';

                    $post_terms = wp_get_post_terms($post_id, $atts['taxonomy'], ['fields' => 'all']);
                    $term_slugs = array_map(fn($t) => $t->slug, $post_terms);
                    $term_names = array_map(fn($t) => $t->name, $post_terms);

                    $data_terms = esc_attr(implode(',', $term_slugs));
                    $data_names = esc_attr(implode(',', $term_names));
                    $ts = get_post_time('U', true, $post_id);
                ?>
                <a href="<?php echo $permalink; ?>" class="pf-item" role="listitem"
                   data-terms="<?php echo $data_terms; ?>"
                   data-termnames="<?php echo $data_names; ?>"
                   data-ts="<?php echo (int) $ts; ?>" <?php echo $target; ?>>
                    <div class="pf-image">
                        <?php
                        if (has_post_thumbnail($post_id)) {
                            echo get_the_post_thumbnail($post_id, 'medium', ['loading' => 'lazy', 'decoding' => 'async']);
                        } else {
                            echo '<div class="pf-thumb-fallback" aria-hidden="true"></div>';
                        }
                        ?>
                    </div>
                    <h3 class="pf-title"><?php echo esc_html(get_the_title($post_id)); ?></h3>
                    <div class="pf-linktext">
                        <?php echo $link_url ? 'Externer Link ' . $ext_icon : 'Zum Beitrag'; ?>
                    </div>
                </a>
            <?php endforeach; else : ?>
                <p>Keine Portfolio-Einträge gefunden.</p>
            <?php endif; ?>
        </div>

        <div class="pf-actions">
            <button type="button" class="pf-loadmore" aria-live="polite" aria-label="Weitere Beiträge laden">Mehr laden</button>
        </div>
            </div>
    <?php
    wp_reset_postdata();
    return ob_get_clean();
}

portfolio.js

JavaScript
(() => {
  document.querySelectorAll('.pf-wrap').forEach((root) => {
    const grid = root.querySelector('.pf-grid');
    const chips = Array.from(root.querySelectorAll('.pf-chip'));
    const loadMoreBtn = root.querySelector('.pf-loadmore');

    const isMobile = () => window.matchMedia('(max-width: 799px)').matches;
    const pageSize = () => (isMobile()
      ? parseInt(root.dataset.pagesizeMobile || '6', 10)
      : parseInt(root.dataset.pagesizeDesktop || '16', 10)
    );

    let selected = new Set(['__all__']);
    let visibleCount = 0;
    const items = Array.from(grid.querySelectorAll('.pf-item'));

    const itemMatchesSelection = (item) => {
      if (selected.has('__all__')) return true;
      const terms = (item.getAttribute('data-terms') || '').split(',').filter(Boolean);
      return terms.some(t => selected.has(t));
    };

    function setChipActiveStates() {
      chips.forEach(btn => {
        const active = selected.has(btn.dataset.filter);
        btn.classList.toggle('is-active', active);
        btn.setAttribute('aria-pressed', active ? 'true' : 'false');
      });
    }

    function applyFilter(resetPage = true) {
      let matched = [];
      items.forEach(item => {
        const match = itemMatchesSelection(item);
        item.classList.toggle('is-hidden', !match);
        if (match) matched.push(item);
      });

      if (resetPage) visibleCount = 0;

      const step = pageSize();
      const target = Math.min(matched.length, visibleCount + step);
      matched.forEach((item, idx) => {
        item.classList.toggle('is-hidden', idx >= target);
      });
      visibleCount = target;

      const canLoadMore = visibleCount < matched.length;
      loadMoreBtn.disabled = !canLoadMore;
      loadMoreBtn.hidden = !canLoadMore; // Button nur zeigen, wenn wirklich mehr da ist
      loadMoreBtn.setAttribute('aria-label', canLoadMore
        ? (window.GWPortfolioConfig?.i18n?.loadMore || 'Weitere Beiträge laden')
        : (window.GWPortfolioConfig?.i18n?.noMore || 'Keine weiteren Beiträge'));
    }

    chips.forEach(btn => {
      btn.addEventListener('click', () => {
        const f = btn.dataset.filter;
        if (f === '__all__') {
          selected = new Set(['__all__']);
        } else {
          selected.delete('__all__');
          if (selected.has(f)) selected.delete(f); else selected.add(f);
          if (selected.size === 0) selected.add('__all__');
        }
        setChipActiveStates();
        applyFilter(true);
      });
    });

    loadMoreBtn.addEventListener('click', () => {
      const matched = items.filter(item => itemMatchesSelection(item));
      const currentlyVisible = matched.filter(item => !item.classList.contains('is-hidden')).length;

      const step = pageSize();
      const target = currentlyVisible + step;

      matched.forEach((item, idx) => {
        if (idx < target) item.classList.remove('is-hidden');
      });

      visibleCount = Math.min(target, matched.length);
      const canLoadMore = visibleCount < matched.length;
      loadMoreBtn.disabled = !canLoadMore;
      loadMoreBtn.hidden = !canLoadMore;
    });

    let rAF = null;
    window.addEventListener('resize', () => {
      if (rAF) cancelAnimationFrame(rAF);
      rAF = requestAnimationFrame(() => applyFilter(true));
    });

    setChipActiveStates();
    applyFilter(true);
  });
})();

portfolio.css

CSS
/* 
Portfolio HTML Structure
<DIV pf-wrap>
  <NAV pf-filter>
    <UL pf-filter-list>
      <Li>
        <BUTTON pf-chip (is-active)>
          <SPAN pf-count>
  <DIV pf-grid>
    <A pf-item>
  <DIV pf-actions>
    <BUTTON pf-loadmore>
*/

/* WRAP VARIABLES */
.pf-wrap {
  --accent: #00078f;
  --white: #fff;
  --black: #000;
  --border: #ddd;
}

/* FILTER */
.pf-filter {
  margin-bottom: 1.5em;
}

.pf-filter-list {
  display: flex;
  flex-wrap: wrap !important;
  gap: .5rem;
  padding: 0 !important;
  margin: 0;
  list-style: none;
}

.pf-chip {
  appearance: none;
  border: 1px solid var(--border);
  background: var(--black);
  padding: .5rem .75rem;
  border-radius: 999px;
  cursor: pointer;
  font: inherit;
  line-height: 1;
  white-space: nowrap;
}

.pf-chip:hover,
.pf-chip:focus-visible,
.pf-chip.is-active {
  background: var(--accent);
  color: var(--white);
  border-color: var(--accent);
}

.pf-count {
  opacity: .7;
  margin-left: .25rem;
}

/* GRID */
.pf-grid {
  display: grid;
  gap: 1.5rem;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}

.pf-item {
  display: flex;
  flex-direction: column;
  text-decoration: none;
  color: inherit;
  border: 1px solid var(--border);
  transition: transform .2s ease, box-shadow .2s ease;
  height: 100%;
}

.pf-item:hover {
  transform: translateY(-3px);
  box-shadow: 0 8px 18px rgba(0, 0, 0, .08);
}

.pf-image {
  width: 100%;
  aspect-ratio: 1/1;
  background: var(--white);
  overflow: hidden;
}

.pf-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: transform .3s ease;
}

.pf-item:hover .pf-image img {
  transform: scale(1.04);
}

.pf-thumb-fallback {
  width: 100%;
  height: 100%;
  background: repeating-linear-gradient(45deg, #f0f0f0, #f0f0f0 10px, #fafafa 10px, #fafafa 20px);
}

.pf-title {
  margin: 0.8rem 1rem auto !important;
  font-size: 1.125rem;
  line-height: 1.2;
}

.pf-linktext {
  margin: 0 1rem 1rem;
  color: var(--accent);
  font-size: .9rem;
  display: flex;
  align-items: center;
  margin-top: 2em !important;
}

.external-link-icon {
  font-size: 1rem;
  opacity: .7;
  margin-left: .5rem;
}

.pf-item.is-hidden {
  display: none !important;
}

.pf-actions {
  display: flex;
  justify-content: center;
  margin-top: 2em;
}

.pf-loadmore {
  appearance: none;
  border: 1px solid var(--border);
  background: var(--black);
  padding: .4rem 0.8rem;
  border-radius: .6rem;
  cursor: pointer;
  font-size: 0.9rem;
}

.pf-loadmore:hover,
.pf-loadmore:focus-visible {
  background: var(--accent) !important;
  color: var(--white) !important;
}

.pf-loadmore[disabled] {
  opacity: .5;
  cursor: not-allowed;
}