feat: SEO, PWA, and performance optimizations
- Add sitemap.xml endpoint and update robots.txt for SEO - Improve manifest.json with richer metadata and categories - Add meta tags for social sharing and accessibility - Preload critical assets and fonts for faster loading - Optimize login background image and resource hints - Enhance service worker for better caching strategies - Add security headers to server responses - Update Vite config for chunking and dependency optimization - Add logboek.md for project tracking
This commit is contained in:
26
src/app.css
26
src/app.css
@@ -80,6 +80,7 @@
|
||||
src: url('/fonts/Washington.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -131,5 +132,30 @@
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Ensure links have visible focus indicators and non-color differentiation */
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:focus {
|
||||
outline: 2px solid var(--color-ring);
|
||||
outline-offset: 2px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Special handling for map attribution links */
|
||||
.maplibregl-ctrl-attrib a {
|
||||
color: #0078a8 !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib a:hover,
|
||||
.maplibregl-ctrl-attrib a:focus {
|
||||
text-decoration: underline !important;
|
||||
background-color: rgba(0, 120, 168, 0.1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
10
src/app.html
10
src/app.html
@@ -2,8 +2,16 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#333333" />
|
||||
<meta name="theme-color" content="#f8f8f8" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#333333" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Serengo" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/logo.svg" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -39,5 +39,31 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
|
||||
event.locals.user = user;
|
||||
event.locals.session = session;
|
||||
return resolve(event);
|
||||
|
||||
const response = await resolve(event);
|
||||
|
||||
// Add security headers
|
||||
response.headers.set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
||||
"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " +
|
||||
"font-src 'self' fonts.gstatic.com; " +
|
||||
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org; " +
|
||||
"connect-src 'self' *.openstreetmap.org; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self';"
|
||||
);
|
||||
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
// Add HSTS for HTTPS in production
|
||||
if (event.url.protocol === 'https:') {
|
||||
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,20 @@
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<!-- Performance optimizations -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/Washington.ttf"
|
||||
as="font"
|
||||
type="font/ttf"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link rel="dns-prefetch" href="//tile.openstreetmap.org" />
|
||||
<link rel="preconnect" href="https://tile.openstreetmap.org" crossorigin="anonymous" />
|
||||
<!-- Resource hints for login page background -->
|
||||
{#if isLoginRoute}
|
||||
<link rel="preload" href="/cafe-bg-compressed.jpg" as="image" />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<Toaster />
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
import { Map } from '$lib';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Serengo - Meet the Unexpected</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Discover unexpected places and experiences with Serengo's interactive map. Find hidden gems and explore your surroundings like never before."
|
||||
/>
|
||||
<meta property="og:title" content="Serengo - Meet the Unexpected" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Discover unexpected places and experiences with Serengo's interactive map. Find hidden gems and explore your surroundings like never before."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Serengo - Meet the Unexpected" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Discover unexpected places and experiences with Serengo's interactive map. Find hidden gems and explore your surroundings like never before."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="home-container">
|
||||
<main class="main-content">
|
||||
<div class="map-section">
|
||||
|
||||
@@ -5,6 +5,26 @@
|
||||
let { form }: { form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - Serengo</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Sign in to your Serengo account to access personalized maps and discover unexpected places around you."
|
||||
/>
|
||||
<meta property="og:title" content="Login - Serengo" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Sign in to your Serengo account to access personalized maps and discover unexpected places around you."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Login - Serengo" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Sign in to your Serengo account to access personalized maps and discover unexpected places around you."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="login-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div class="flex w-full max-w-sm flex-col gap-6">
|
||||
<LoginForm {form} />
|
||||
@@ -12,7 +32,7 @@
|
||||
|
||||
<style>
|
||||
.login-background {
|
||||
background-image: url('/cafe-bg.jpeg');
|
||||
background-image: url('/cafe-bg-compressed.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
@@ -34,5 +54,16 @@
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Preload hint for faster loading */
|
||||
@media (prefers-reduced-data: no-preference) {
|
||||
.login-background::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-image: url('/cafe-bg-compressed.jpg');
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
|
||||
29
src/routes/sitemap.xml/+server.ts
Normal file
29
src/routes/sitemap.xml/+server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const baseUrl = url.origin;
|
||||
const lastmod = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
||||
|
||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>${baseUrl}/</loc>
|
||||
<lastmod>${lastmod}</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>${baseUrl}/login</loc>
|
||||
<lastmod>${lastmod}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
</urlset>`;
|
||||
|
||||
return new Response(sitemap, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'Cache-Control': 'max-age=86400' // Cache for 24 hours
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -15,33 +15,70 @@ import { build, files, version } from '$service-worker';
|
||||
// This gives `self` the correct types
|
||||
const self = globalThis.self as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
// Create a unique cache name for this deployment
|
||||
// Create cache names for this deployment
|
||||
const CACHE = `cache-${version}`;
|
||||
const RUNTIME_CACHE = `runtime-${version}`;
|
||||
const IMAGE_CACHE = `images-${version}`;
|
||||
|
||||
const ASSETS = [
|
||||
...build, // the app itself
|
||||
...files // everything in `static`
|
||||
];
|
||||
|
||||
// Assets to precache for better performance
|
||||
const CRITICAL_ASSETS = [
|
||||
'/cafe-bg-compressed.jpg',
|
||||
'/fonts/Washington.ttf',
|
||||
'/logo.svg'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
// Create a new cache and add all files to it
|
||||
async function addFilesToCache() {
|
||||
const cache = await caches.open(CACHE);
|
||||
const imageCache = await caches.open(IMAGE_CACHE);
|
||||
|
||||
// Cache core assets
|
||||
await cache.addAll(ASSETS);
|
||||
|
||||
// Precache critical assets with error handling
|
||||
await Promise.allSettled(
|
||||
CRITICAL_ASSETS.map(async (asset) => {
|
||||
try {
|
||||
const response = await fetch(asset);
|
||||
if (response.ok) {
|
||||
if (asset.includes('jpg') || asset.includes('jpeg') || asset.includes('png') || asset.includes('webp')) {
|
||||
await imageCache.put(asset, response);
|
||||
} else {
|
||||
await cache.put(asset, response);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to cache ${asset}:`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
event.waitUntil(addFilesToCache());
|
||||
// Skip waiting to activate immediately
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
// Remove previous cached data from disk
|
||||
async function deleteOldCaches() {
|
||||
const currentCaches = [CACHE, RUNTIME_CACHE, IMAGE_CACHE];
|
||||
for (const key of await caches.keys()) {
|
||||
if (key !== CACHE) await caches.delete(key);
|
||||
if (!currentCaches.includes(key)) {
|
||||
await caches.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event.waitUntil(deleteOldCaches());
|
||||
// Claim clients immediately
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
@@ -51,6 +88,8 @@ self.addEventListener('fetch', (event) => {
|
||||
async function respond() {
|
||||
const url = new URL(event.request.url);
|
||||
const cache = await caches.open(CACHE);
|
||||
const runtimeCache = await caches.open(RUNTIME_CACHE);
|
||||
const imageCache = await caches.open(IMAGE_CACHE);
|
||||
|
||||
// `build`/`files` can always be served from the cache
|
||||
if (ASSETS.includes(url.pathname)) {
|
||||
@@ -61,6 +100,50 @@ self.addEventListener('fetch', (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle images with cache-first strategy
|
||||
if (url.pathname.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)) {
|
||||
const cachedResponse = await imageCache.match(event.request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(event.request);
|
||||
if (response.ok) {
|
||||
imageCache.put(event.request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
// Return a fallback image or the cached version
|
||||
return cachedResponse || new Response('Image not available', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle fonts with cache-first strategy
|
||||
if (url.pathname.match(/\.(woff|woff2|ttf|otf)$/i)) {
|
||||
const cachedResponse = await cache.match(event.request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle API and dynamic content with network-first strategy
|
||||
if (url.pathname.startsWith('/api/') || url.searchParams.has('_data')) {
|
||||
try {
|
||||
const response = await fetch(event.request);
|
||||
if (response.ok) {
|
||||
runtimeCache.put(event.request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
const cachedResponse = await runtimeCache.match(event.request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// for everything else, try the network first, but
|
||||
// fall back to the cache if we're offline
|
||||
try {
|
||||
@@ -73,15 +156,19 @@ self.addEventListener('fetch', (event) => {
|
||||
}
|
||||
|
||||
if (response.status === 200) {
|
||||
cache.put(event.request, response.clone());
|
||||
runtimeCache.put(event.request, response.clone());
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
const response = await cache.match(event.request);
|
||||
// Try all caches for fallback
|
||||
const cachedResponse =
|
||||
await cache.match(event.request) ||
|
||||
await runtimeCache.match(event.request) ||
|
||||
await imageCache.match(event.request);
|
||||
|
||||
if (response) {
|
||||
return response;
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// if there's no cache, then just error out
|
||||
|
||||
Reference in New Issue
Block a user