PageSpeed/Lighthouse fixes: caching, images, touch targets, console noise

Based on a Lighthouse audit of the live site (95 performance / 91
accessibility / 96 best-practices / 100 SEO), addressing everything
except the flagged color-contrast issues (kept as-is, reversible
color changes need a separate decision):

- Cache-Control headers for hashed/immutable static assets via
  mod_headers (no mod_expires dependency, since that's not enabled
  everywhere) - ~1.1MB saved on repeat visits
- Converted the code-bundled homepage photos to WebP (skipped
  DB-managed/admin-uploaded images - those are content, not code) and
  added explicit width/height so the browser can reserve space before
  images load
- Bumped footer/contact-sidebar phone and email links to a 24px+
  touch target via padding
- /auth/me now returns 200 + {authenticated:false} instead of 401 for
  logged-out visitors - it's called on every public page load to
  check session state, so "not logged in" is the normal case, not an
  error; the 401 was showing up as a console error on literally every
  page view

Left unchanged: the lucide-react bundle splitting (a previous,
deliberate tradeoff - splitting per-icon created ~1600 tiny chunk
files that failed to upload on shared hosting) and video preload
(no concrete issue found in the audit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-07 02:40:57 +02:00
parent 6392c77de4
commit ec84b438ca
14 changed files with 78 additions and 21 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

View file

@ -66,10 +66,10 @@ export default function Footer() {
<p className="font-semibold text-neutral-900 dark:text-white">{t('footer.contactHeading')}</p> <p className="font-semibold text-neutral-900 dark:text-white">{t('footer.contactHeading')}</p>
</div> </div>
<p> <p>
<a href={`tel:${digitsOnly(phone)}`} className="hover:text-brand-500">{phone}</a> <a href={`tel:${digitsOnly(phone)}`} className="inline-block py-1 hover:text-brand-500">{phone}</a>
</p> </p>
<p> <p>
<a href={`mailto:${contactEmail}`} className="hover:text-brand-500">{contactEmail}</a> <a href={`mailto:${contactEmail}`} className="inline-block py-1 hover:text-brand-500">{contactEmail}</a>
</p> </p>
<Link <Link
to="/kontakt" to="/kontakt"

View file

@ -10,7 +10,7 @@ export function AuthProvider({ children }) {
useEffect(() => { useEffect(() => {
api api
.get('/auth/me') .get('/auth/me')
.then(setUser) .then((data) => setUser(data.authenticated === false ? null : data))
.catch(() => setUser(null)) .catch(() => setUser(null))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@ -36,7 +36,7 @@ export function AuthProvider({ children }) {
const refreshUser = async () => { const refreshUser = async () => {
const data = await api.get('/auth/me'); const data = await api.get('/auth/me');
setUser(data); setUser(data.authenticated === false ? null : data);
}; };
const hasPermission = (permission) => const hasPermission = (permission) =>

View file

@ -212,11 +212,11 @@ export default function ContactPage() {
</div> </div>
<div> <div>
<dt className="font-medium text-neutral-800 dark:text-neutral-100">{t('contactPage.phone')}</dt> <dt className="font-medium text-neutral-800 dark:text-neutral-100">{t('contactPage.phone')}</dt>
<dd><a href={`tel:${digitsOnly(phone)}`} className="hover:text-brand-500">{phone}</a></dd> <dd><a href={`tel:${digitsOnly(phone)}`} className="inline-block py-1 hover:text-brand-500">{phone}</a></dd>
</div> </div>
<div> <div>
<dt className="font-medium text-neutral-800 dark:text-neutral-100">{t('contactPage.email')}</dt> <dt className="font-medium text-neutral-800 dark:text-neutral-100">{t('contactPage.email')}</dt>
<dd><a href={`mailto:${contactEmail}`} className="hover:text-brand-500">{contactEmail}</a></dd> <dd><a href={`mailto:${contactEmail}`} className="inline-block py-1 hover:text-brand-500">{contactEmail}</a></dd>
</div> </div>
<div> <div>
<dt className="font-medium text-neutral-800 dark:text-neutral-100">{t('contactPage.hours')}</dt> <dt className="font-medium text-neutral-800 dark:text-neutral-100">{t('contactPage.hours')}</dt>

View file

@ -8,14 +8,14 @@ import StarRating from '../../components/StarRating.jsx';
import TestimonialSlider from '../../components/TestimonialSlider.jsx'; import TestimonialSlider from '../../components/TestimonialSlider.jsx';
import { useSiteSettings } from '../../context/SiteSettingsContext.jsx'; import { useSiteSettings } from '../../context/SiteSettingsContext.jsx';
import { useLanguage } from '../../context/LanguageContext.jsx'; import { useLanguage } from '../../context/LanguageContext.jsx';
import defaultHeroImage from '../../assets/photos/gallery-dash2.jpg'; import defaultHeroImage from '../../assets/photos/gallery-dash2.webp';
import galleryAmp from '../../assets/photos/gallery-amp-purple.jpg'; import galleryAmp from '../../assets/photos/gallery-amp-purple.webp';
import gallerySpeaker from '../../assets/photos/gallery-speaker.jpg'; import gallerySpeaker from '../../assets/photos/gallery-speaker.webp';
import gallerySubs from '../../assets/photos/gallery-subs.jpg'; import gallerySubs from '../../assets/photos/gallery-subs.webp';
import galleryDash from '../../assets/photos/gallery-dash.jpg'; import galleryDash from '../../assets/photos/gallery-dash.webp';
import galleryTrunk from '../../assets/photos/hero-trunk.jpg'; import galleryTrunk from '../../assets/photos/hero-trunk.webp';
import galleryRear from '../../assets/photos/gallery-rear.jpg'; import galleryRear from '../../assets/photos/gallery-rear.webp';
import heroHighlight from '../../assets/leistungen/hero-highlight.jpg'; import heroHighlight from '../../assets/leistungen/hero-highlight.webp';
import youtubeVideo from '../../assets/videos/youtube-hintergrund.mp4'; import youtubeVideo from '../../assets/videos/youtube-hintergrund.mp4';
const YOUTUBE_URL = 'https://www.youtube.com/@hifiplanet2812'; const YOUTUBE_URL = 'https://www.youtube.com/@hifiplanet2812';
@ -23,7 +23,18 @@ const YOUTUBE_VIDEO_ID = 'ostj2mKDVUc';
const brands = ['Focal', 'Helix', 'Ground Zero', 'Mosconi', 'Hifonics', 'Audison']; const brands = ['Focal', 'Helix', 'Ground Zero', 'Mosconi', 'Hifonics', 'Audison'];
const galleryImages = [gallerySubs, galleryAmp, gallerySpeaker, galleryDash, galleryRear, galleryTrunk]; // Native Pixelmasse der Bilder unten - nur fuer die width/height-Attribute auf <img>,
// damit der Browser vor dem Laden Platz reserviert (verhindert Layout-Sprung). Die
// tatsaechliche Anzeigegroesse wird weiterhin komplett per CSS (aspect-square etc.)
// bestimmt, diese Werte muessen also nicht exakt zum gerenderten Seitenverhaeltnis passen.
const galleryImages = [
{ src: gallerySubs, width: 900, height: 600 },
{ src: galleryAmp, width: 900, height: 600 },
{ src: gallerySpeaker, width: 900, height: 1200 },
{ src: galleryDash, width: 900, height: 814 },
{ src: galleryRear, width: 900, height: 1200 },
{ src: galleryTrunk, width: 1920, height: 1280 },
];
export default function Home() { export default function Home() {
const { hero_image_path: heroImagePath } = useSiteSettings(); const { hero_image_path: heroImagePath } = useSiteSettings();
@ -33,7 +44,7 @@ export default function Home() {
const stats = t('home.stats'); const stats = t('home.stats');
const steps = t('home.steps'); const steps = t('home.steps');
const galleryAlts = t('home.galleryAlts'); const galleryAlts = t('home.galleryAlts');
const gallery = galleryImages.map((src, i) => ({ src, alt: galleryAlts[i] })); const gallery = galleryImages.map((img, i) => ({ ...img, alt: galleryAlts[i] }));
const testimonials = t('home.testimonials'); const testimonials = t('home.testimonials');
const faqs = t('home.faqs'); const faqs = t('home.faqs');
@ -221,7 +232,14 @@ export default function Home() {
<div className="mx-auto max-w-6xl space-y-16 overflow-hidden px-4 sm:px-6"> <div className="mx-auto max-w-6xl space-y-16 overflow-hidden px-4 sm:px-6">
<div className="grid items-center gap-8 md:grid-cols-2"> <div className="grid items-center gap-8 md:grid-cols-2">
<Reveal direction="left" shine className="rounded-xl shadow-lg"> <Reveal direction="left" shine className="rounded-xl shadow-lg">
<img src={gallerySubs} alt={t('home.craftImageAlt')} className="w-full rounded-xl object-cover" style={{ maxHeight: 380 }} /> <img
src={gallerySubs}
alt={t('home.craftImageAlt')}
width={900}
height={600}
className="w-full rounded-xl object-cover"
style={{ maxHeight: 380 }}
/>
</Reveal> </Reveal>
<Reveal direction="right" index={1}> <Reveal direction="right" index={1}>
<p className="mb-2 text-sm font-semibold uppercase tracking-wide text-brand-600 dark:text-brand-400">{t('home.craftEyebrow')}</p> <p className="mb-2 text-sm font-semibold uppercase tracking-wide text-brand-600 dark:text-brand-400">{t('home.craftEyebrow')}</p>
@ -241,7 +259,14 @@ export default function Home() {
</p> </p>
</Reveal> </Reveal>
<Reveal direction="right" index={1} shine className="order-1 md:order-2 rounded-xl shadow-lg"> <Reveal direction="right" index={1} shine className="order-1 md:order-2 rounded-xl shadow-lg">
<img src={galleryAmp} alt={t('home.adviceImageAlt')} className="w-full rounded-xl object-cover" style={{ maxHeight: 380 }} /> <img
src={galleryAmp}
alt={t('home.adviceImageAlt')}
width={900}
height={600}
className="w-full rounded-xl object-cover"
style={{ maxHeight: 380 }}
/>
</Reveal> </Reveal>
</div> </div>
</div> </div>
@ -257,7 +282,14 @@ export default function Home() {
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{gallery.map((img, i) => ( {gallery.map((img, i) => (
<Reveal key={img.src} index={i % 3} shine className="aspect-square overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-800"> <Reveal key={img.src} index={i % 3} shine className="aspect-square overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-800">
<img src={img.src} alt={img.alt} loading="lazy" className="h-full w-full object-cover transition duration-300 hover:scale-105" /> <img
src={img.src}
alt={img.alt}
width={img.width}
height={img.height}
loading="lazy"
className="h-full w-full object-cover transition duration-300 hover:scale-105"
/>
</Reveal> </Reveal>
))} ))}
</div> </div>
@ -289,7 +321,14 @@ export default function Home() {
<section className="mx-auto max-w-6xl overflow-hidden px-4 py-16 sm:px-6"> <section className="mx-auto max-w-6xl overflow-hidden px-4 py-16 sm:px-6">
<div className="grid items-center gap-10 md:grid-cols-2"> <div className="grid items-center gap-10 md:grid-cols-2">
<Reveal direction="left" shine className="overflow-hidden rounded-xl shadow-lg"> <Reveal direction="left" shine className="overflow-hidden rounded-xl shadow-lg">
<img src={heroHighlight} alt={t('home.moreImageAlt')} className="h-full w-full object-cover" loading="lazy" /> <img
src={heroHighlight}
alt={t('home.moreImageAlt')}
width={1600}
height={905}
className="h-full w-full object-cover"
loading="lazy"
/>
</Reveal> </Reveal>
<Reveal direction="right" index={1} className="text-center md:text-left"> <Reveal direction="right" index={1} className="text-center md:text-left">
<h2 className="mb-3 text-2xl font-bold text-neutral-900 dark:text-white">{t('home.moreTitle')}</h2> <h2 className="mb-3 text-2xl font-bold text-neutral-900 dark:text-white">{t('home.moreTitle')}</h2>

View file

@ -1,6 +1,20 @@
RewriteEngine On RewriteEngine On
RewriteBase / RewriteBase /
# Lang-lebiger Cache fuer gehashte Build-Assets (Vite haengt bei jeder Aenderung einen
# neuen Hash an den Dateinamen) und hochgeladene Bilder (bekommen beim Upload einen
# zufaelligen Dateinamen) - beide aendern sich unter derselben URL nie, "immutable" ist
# hier also sicher. Ueber mod_headers statt mod_expires, da Letzteres nicht ueberall
# aktiviert ist.
<IfModule mod_headers.c>
<FilesMatch "\.(js|css|woff2?|ttf|eot)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "\.(jpg|jpeg|png|webp|avif|gif|svg|ico|mp4|webm)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
# Ohne dies leitet Apache (mod_dir) Anfragen an /api/uploads mit einem 301 auf # Ohne dies leitet Apache (mod_dir) Anfragen an /api/uploads mit einem 301 auf
# /api/uploads/ um, bevor mod_rewrite greifen kann - weil api/uploads/ als # /api/uploads/ um, bevor mod_rewrite greifen kann - weil api/uploads/ als
# echtes Verzeichnis existiert. Bei einem POST mit Datei-Upload geht dabei der # echtes Verzeichnis existiert. Bei einem POST mit Datei-Upload geht dabei der

View file

@ -154,8 +154,12 @@ class AuthController
public static function me(): void public static function me(): void
{ {
// Bewusst kein 401 hier: /auth/me wird auf JEDER oeffentlichen Seite aufgerufen,
// um den Login-Status zu pruefen - "nicht angemeldet" ist der Normalfall fuer
// Besucher, kein Fehler. Ein 401 erzeugt in der Browser-Konsole/DevTools trotz
// JS-seitigem catch() einen sichtbaren Netzwerk-Fehler (bemaengelt u.a. Lighthouse).
if (empty($_SESSION['admin_id'])) { if (empty($_SESSION['admin_id'])) {
Http::error('Nicht angemeldet', 401); Http::send(['authenticated' => false]);
} }
Http::send(self::userPayload((int) $_SESSION['admin_id'], $_SESSION['admin_username'])); Http::send(self::userPayload((int) $_SESSION['admin_id'], $_SESSION['admin_username']));
} }