5 Commits
Author SHA1 Message Date
u80864958 b4e8baabdc fix: language change
/ publish (push) Successful in 1m19s
2026-01-23 14:41:52 +01:00
u80864958 ab3c8982e2 feat: lru cache 2026-01-23 14:32:51 +01:00
u80864958 df9e6e95cb feat: cache images 2026-01-23 13:50:40 +01:00
u80864958 2154d6cfa8 feat: umami tracking
/ publish (push) Successful in 1m9s
2026-01-23 09:49:56 +01:00
schreifuchs 9002f13f80 style: align markdown text left and center images 2026-01-21 19:52:14 +01:00
8 changed files with 241 additions and 133 deletions
+1
View File
@@ -30,6 +30,7 @@
"@inlang/paraglide-js": "^1.11.0", "@inlang/paraglide-js": "^1.11.0",
"@inlang/paraglide-sveltekit": "^0.16.1", "@inlang/paraglide-sveltekit": "^0.16.1",
"@ts-stack/markdown": "^1.5.0", "@ts-stack/markdown": "^1.5.0",
"lru-cache": "^11.2.4",
"mime-types": "^3.0.2", "mime-types": "^3.0.2",
"svelte-preprocess": "^6.0.2", "svelte-preprocess": "^6.0.2",
"webdav": "^5.8.0" "webdav": "^5.8.0"
+9
View File
@@ -20,6 +20,9 @@ importers:
'@ts-stack/markdown': '@ts-stack/markdown':
specifier: ^1.5.0 specifier: ^1.5.0
version: 1.5.0 version: 1.5.0
lru-cache:
specifier: ^11.2.4
version: 11.2.4
mime-types: mime-types:
specifier: ^3.0.2 specifier: ^3.0.2
version: 3.0.2 version: 3.0.2
@@ -1269,6 +1272,10 @@ packages:
resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==} resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==}
engines: {node: 14 || >=16.14} engines: {node: 14 || >=16.14}
lru-cache@11.2.4:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22}
magic-string@0.30.10: magic-string@0.30.10:
resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==} resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==}
@@ -2988,6 +2995,8 @@ snapshots:
lru-cache@10.2.2: {} lru-cache@10.2.2: {}
lru-cache@11.2.4: {}
magic-string@0.30.10: magic-string@0.30.10:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/sourcemap-codec': 1.4.15
+9 -1
View File
@@ -2,10 +2,18 @@
<html lang="%paraglide.lang%" dir="%paraglide.textDirection%"> <html lang="%paraglide.lang%" dir="%paraglide.textDirection%">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<script
defer
src="https://umami.schreifuchs.ch/script.js"
data-website-id="f09f9cc5-6357-48df-8fb2-5c62089c04a0"
></script>
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>schreifuchs.ch</title> <title>schreifuchs.ch</title>
<meta name="description" content="Portfolio von Niklas - Fotografie, Informatik, Musik und Video." /> <meta
name="description"
content="Portfolio von Niklas - Fotografie, Informatik, Musik und Video."
/>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+3 -2
View File
@@ -55,11 +55,10 @@
<style global> <style global>
.markdown { .markdown {
color: white; color: white;
text-align: center; text-align: left;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
} }
.markdown h1 { .markdown h1 {
font-size: 2rem; font-size: 2rem;
@@ -80,6 +79,8 @@
.markdown img { .markdown img {
max-height: 90vh; max-height: 90vh;
display: block;
margin: 0 auto;
} }
.markdown table, .markdown table,
+63 -19
View File
@@ -1,5 +1,44 @@
const cache = new Map<string, { timestamp: number; data: any }>(); import { LRUCache } from "lru-cache";
// const cache = new Map<string, { timestamp: number; data: any }>();
const DEFAULT_TTL = 1000 * 60 * 15; // 15 minutes default TTL const DEFAULT_TTL = 1000 * 60 * 15; // 15 minutes default TTL
const MAX_CACHE_SIZE = 1000 * 1000 * 750; // 750 MB
const cache = new LRUCache({
max: 100,
// Maximum allowed size (in bytes)
maxSize: MAX_CACHE_SIZE,
// IMPORTANT: We must teach the cache how to calculate the size of an item.
// We check for your specific image structure, generic buffers, or fallback to JSON length.
sizeCalculation: (value) => {
// 1. Check if it's our specific image object { buffer: ArrayBuffer, ... }
if (value && typeof value === "object") {
const obj = value as Record<string, any>;
if (obj.buffer instanceof ArrayBuffer) {
return obj.buffer.byteLength;
}
}
// 2. Handle raw ArrayBuffers or Node.js Buffers
if (value instanceof ArrayBuffer) {
return value.byteLength;
}
if (Buffer.isBuffer(value)) {
return value.length;
}
// 3. Fallback for strings or JSON metadata
try {
const str = JSON.stringify(value);
return str ? str.length : 1;
} catch {
return 1;
}
},
ttl: DEFAULT_TTL,
});
/** /**
* Caches the result of a promise-returning function. * Caches the result of a promise-returning function.
@@ -7,37 +46,42 @@ const DEFAULT_TTL = 1000 * 60 * 15; // 15 minutes default TTL
* @param fetcher Function that returns a promise with the data to cache. * @param fetcher Function that returns a promise with the data to cache.
* @param ttl Time to live in milliseconds. Defaults to 15 minutes. * @param ttl Time to live in milliseconds. Defaults to 15 minutes.
*/ */
export async function withCache<T>(key: string, fetcher: () => Promise<T>, ttl = DEFAULT_TTL): Promise<T> { export async function withCache<T>(
const now = Date.now(); key: string,
const entry = cache.get(key); fetcher: () => Promise<T>,
ttl = DEFAULT_TTL,
): Promise<T> {
const entry = cache.get(key);
if (entry && (now - entry.timestamp < ttl)) { if (entry) {
return entry.data as T; return entry as T;
} }
try { try {
const data = await fetcher(); const data = await fetcher();
cache.set(key, { timestamp: now, data }); if (data) {
return data; cache.set(key, data, { ttl: ttl });
} catch (error) {
console.error(`Error fetching data for key ${key}:`, error);
// If fetch fails but we have stale data, return it?
// For now, let's bubble the error up so the page handles it (e.g. 404),
// or maybe just re-throw.
throw error;
} }
return data;
} catch (error) {
console.error(`Error fetching data for key ${key}:`, error);
// If fetch fails but we have stale data, return it?
// For now, let's bubble the error up so the page handles it (e.g. 404),
// or maybe just re-throw.
throw error;
}
} }
/** /**
* Manually clears a specific cache key. * Manually clears a specific cache key.
*/ */
export function clearCache(key: string) { export function clearCache(key: string) {
cache.delete(key); cache.delete(key);
} }
/** /**
* Clears the entire cache. * Clears the entire cache.
*/ */
export function clearAllCache() { export function clearAllCache() {
cache.clear(); cache.clear();
} }
+40 -31
View File
@@ -1,40 +1,49 @@
<script lang="ts"> <script lang="ts">
import "../app.css"; import "../app.css";
import type { Snippet } from 'svelte'; import type { Snippet } from "svelte";
import { ParaglideJS } from '@inlang/paraglide-sveltekit'; import { ParaglideJS } from "@inlang/paraglide-sveltekit";
import { i18n } from '$lib/i18n'; import { i18n } from "$lib/i18n";
import { languageTag } from '$lib/paraglide/runtime'; import { languageTag } from "$lib/paraglide/runtime";
import * as m from '$lib/paraglide/messages'; import { page } from "$app/state";
import * as m from "$lib/paraglide/messages";
let { children }: { children?: Snippet } = $props(); let { children }: { children?: Snippet } = $props();
</script> </script>
<svelte:head> <svelte:head>
<title>{m.site_title()}</title> <title>{m.site_title()}</title>
<meta name="description" content={m.site_description()} /> <meta name="description" content={m.site_description()} />
</svelte:head> </svelte:head>
<ParaglideJS {i18n}> <ParaglideJS {i18n}>
<header class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start"> <header
<a href="/" class="text-white text-2xl">schreifuchs.ch</a> class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start"
<nav class="flex gap-2 p-2"> >
<a <a href={i18n.resolveRoute("/")} class="text-white text-2xl"
href="/" >schreifuchs.ch</a
hreflang="de" >
data-no-translate <nav class="flex gap-2 p-2">
class="text-white hover:underline {languageTag() === 'de' ? 'font-bold' : ''}" <a
> href={i18n.resolveRoute(i18n.route(page.url.pathname), "de")}
DE hreflang="de"
</a> data-no-translate
<a class="text-white hover:underline {languageTag() === 'de'
href="/en" ? 'font-bold'
hreflang="en" : ''}"
data-no-translate >
class="text-white hover:underline {languageTag() === 'en' ? 'font-bold' : ''}" DE
> </a>
EN <a
</a> href={i18n.resolveRoute(i18n.route(page.url.pathname), "en")}
</nav> hreflang="en"
</header> data-no-translate
{@render children?.()} class="text-white hover:underline {languageTag() === 'en'
? 'font-bold'
: ''}"
>
EN
</a>
</nav>
</header>
{@render children?.()}
</ParaglideJS> </ParaglideJS>
+2 -1
View File
@@ -23,7 +23,7 @@
// Helper to resolve image source: Local override -> Nextcloud cover -> Placeholder (handled by ImageTile) // Helper to resolve image source: Local override -> Nextcloud cover -> Placeholder (handled by ImageTile)
const getTileImage = (page: { slug: string; coverImage: string | null }) => { const getTileImage = (page: { slug: string; coverImage: string | null }) => {
return staticImages[page.slug.toLowerCase()] || page.coverImage || ""; return page.coverImage || staticImages[page.slug.toLowerCase()] || "";
}; };
</script> </script>
@@ -47,3 +47,4 @@
</h2> </h2>
</ImageLinkTile> </ImageLinkTile>
</main> </main>
+108 -73
View File
@@ -1,101 +1,136 @@
// src/routes/assets/[...path]/+server.ts
import { client } from "$lib/server/nextcloud"; import { client } from "$lib/server/nextcloud";
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
import mime from "mime-types"; import mime from "mime-types";
import { env } from "$env/dynamic/private"; import { env } from "$env/dynamic/private";
import { withCache } from "$lib/server/cache";
const DEFAULT_TTL = 1000 * 60 * 15; // 15min
const EXTENDED_TTL = 1000 * 60 * 60 * 24 * 7; // 1 week
// Define what we are storing in the cache
type CachedAsset = {
buffer: ArrayBuffer;
contentType: string;
};
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url }) => {
const { path } = params; const { path } = params;
const width = url.searchParams.get("w"); const width = url.searchParams.get("w");
const height = url.searchParams.get("h"); const height = url.searchParams.get("h");
if (!path) { if (!path) {
throw error(400, "Path is required"); throw error(400, "Path is required");
} }
try { // 1. Create a unique cache key for this specific asset variation
let buffer: ArrayBuffer; const cacheKey = `asset:${path}?w=${width ?? ""}&h=${height ?? ""}`;
let contentType: string;
const ttl = path.toString().includes("cover") ? DEFAULT_TTL : EXTENDED_TTL;
try {
// 2. Use withCache to wrap the fetching logic
const cachedData = await withCache<CachedAsset>(
cacheKey,
async () => {
// --- START OF FETCH LOGIC ---
// This function only runs if the data is NOT in the cache
// Try both original path and hidden variant if applicable
const pathsToTry = [path]; const pathsToTry = [path];
if (!path.startsWith('_')) { if (!path.startsWith("_")) {
pathsToTry.push(`_${path}`); pathsToTry.push(`_${path}`);
} }
let response: Response | null = null;
let lastError: any = null; let lastError: any = null;
for (const currentPath of pathsToTry) { for (const currentPath of pathsToTry) {
try { try {
if (width || height) { // A. Try Nextcloud Preview (Resizing)
// Use Nextcloud Preview API if (width || height) {
const baseUrl = env.NEXTCLOUD_URL?.endsWith("/") ? env.NEXTCLOUD_URL : `${env.NEXTCLOUD_URL}/`; const baseUrl = env.NEXTCLOUD_URL?.endsWith("/")
? env.NEXTCLOUD_URL
: `${env.NEXTCLOUD_URL}/`;
let fullPath = currentPath; let fullPath = currentPath;
if (env.NEXTCLOUD_BASE_DIR) { if (env.NEXTCLOUD_BASE_DIR) {
const baseDir = env.NEXTCLOUD_BASE_DIR.replace(/^\/+|\/+$/g, ""); const baseDir = env.NEXTCLOUD_BASE_DIR.replace(
fullPath = baseDir ? `${baseDir}/${currentPath}` : currentPath; /^\/+|\/+$/g,
} "",
);
fullPath = baseDir ? `${baseDir}/${currentPath}` : currentPath;
}
const isJpg = fullPath.match(/\.(jpg|jpeg)$/i); const isJpg = fullPath.match(/\.(jpg|jpeg)$/i);
const previewExtension = isJpg ? 'jpg' : 'png'; const previewExtension = isJpg ? "jpg" : "png";
const previewUrl = new URL(`${baseUrl}index.php/core/preview.${previewExtension}`); const previewUrl = new URL(
// Ensure leading slash for the file parameter `${baseUrl}index.php/core/preview.${previewExtension}`,
const fileParam = fullPath.startsWith('/') ? fullPath : `/${fullPath}`; );
previewUrl.searchParams.set("file", fileParam);
if (width) previewUrl.searchParams.set("x", width);
if (height) previewUrl.searchParams.set("y", height);
previewUrl.searchParams.set("forceIcon", "0");
const auth = Buffer.from(`${env.NEXTCLOUD_USER}:${env.NEXTCLOUD_PASSWORD}`).toString("base64"); const fileParam = fullPath.startsWith("/")
? fullPath
: `/${fullPath}`;
previewUrl.searchParams.set("file", fileParam);
if (width) previewUrl.searchParams.set("x", width);
if (height) previewUrl.searchParams.set("y", height);
previewUrl.searchParams.set("forceIcon", "0");
console.log(`Fetching preview: ${previewUrl.toString()} for file: ${fullPath}`); const auth = Buffer.from(
`${env.NEXTCLOUD_USER}:${env.NEXTCLOUD_PASSWORD}`,
).toString("base64");
const ncResponse = await fetch(previewUrl.toString(), { console.log(
headers: { `Fetching preview: ${previewUrl.toString()} for file: ${fullPath}`,
"Authorization": `Basic ${auth}` );
}
});
if (ncResponse.ok) { const ncResponse = await fetch(previewUrl.toString(), {
buffer = await ncResponse.arrayBuffer(); headers: { Authorization: `Basic ${auth}` },
contentType = ncResponse.headers.get("Content-Type") || "image/png"; });
return new Response(buffer, { if (ncResponse.ok) {
headers: { const buffer = await ncResponse.arrayBuffer();
"Content-Type": contentType, const contentType =
"Cache-Control": "public, max-age=86400", ncResponse.headers.get("Content-Type") || "image/png";
"Vary": "Accept-Encoding"
}
});
} else {
console.warn(`Nextcloud preview failed (${ncResponse.status}) for ${fullPath}.`);
}
}
// Fetch original via WebDAV (Fallback or no resize requested) // Return the raw data to be cached
console.log(`Fetching original file via WebDAV: /${currentPath}`); return { buffer, contentType };
const data = await client.getFileContents("/" + currentPath); } else {
buffer = data as ArrayBuffer; console.warn(
contentType = mime.lookup(currentPath) || "application/octet-stream"; `Nextcloud preview failed (${ncResponse.status}) for ${fullPath}.`,
);
return new Response(buffer, { }
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
"Vary": "Accept-Encoding"
}
});
} catch (e) {
lastError = e;
continue; // Try next path variant
} }
// B. Try WebDAV (Original)
console.log(`Fetching original file via WebDAV: /${currentPath}`);
const data = await client.getFileContents("/" + currentPath);
const buffer = data as ArrayBuffer;
const contentType =
mime.lookup(currentPath) || "application/octet-stream";
// Return the raw data to be cached
return { buffer, contentType };
} catch (e) {
lastError = e;
continue; // Try next path variant
}
} }
throw lastError || new Error("Asset not found"); throw lastError || new Error("Asset not found");
} catch (e) { // --- END OF FETCH LOGIC ---
console.error(`Error fetching asset from Nextcloud: ${path}`, e); },
throw error(404, "Asset not found"); ttl,
} );
// 3. Construct a fresh Response using the cached data
return new Response(cachedData.buffer, {
headers: {
"Content-Type": cachedData.contentType,
"Cache-Control": "public, max-age=86400", // Browser cache
Vary: "Accept-Encoding",
},
});
} catch (e) {
console.error(`Error fetching asset: ${path}`, e);
throw error(404, "Asset not found");
}
}; };