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;
}