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>
BIN
hifi-src/src/assets/leistungen/hero-highlight.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
hifi-src/src/assets/photos/gallery-amp-purple.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
hifi-src/src/assets/photos/gallery-dash.webp
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
hifi-src/src/assets/photos/gallery-dash2.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
hifi-src/src/assets/photos/gallery-rear.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
hifi-src/src/assets/photos/gallery-speaker.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
hifi-src/src/assets/photos/gallery-subs.webp
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
hifi-src/src/assets/photos/hero-trunk.webp
Normal file
|
After Width: | Height: | Size: 285 KiB |
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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']));
|
||||||
}
|
}
|
||||||
|
|
|
||||||