feat: caching

This commit is contained in:
2026-01-20 20:07:14 +01:00
parent ff2a2fee48
commit 0f13d67f10
17 changed files with 333 additions and 224 deletions
+53 -12
View File
@@ -1,31 +1,72 @@
# Schreifuchs
# schreifuchs.ch
Schreifuchs.ch ist meine persönliche Webseite.
Personal portfolio website for Schreifuchs, featuring photography, informatics, music, and video.
## Developing
The site is built with **Svelte 5** and **SvelteKit**, and it is powered by a dynamic **Nextcloud** backend via WebDAV.
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
## Features
- **Dynamic Content:** Pages are automatically discovered based on the folder structure in Nextcloud.
- **Markdown Driven:** Content for each page is fetched from a corresponding Markdown file.
- **Asset Proxying:** Images and attachments are securely streamed from Nextcloud through a server-side proxy.
- **Performance:** In-memory caching for Nextcloud requests (15-minute TTL).
- **Responsive Design:** Styled with Tailwind CSS, optimized for all screen sizes.
## Tech Stack
- **Framework:** Svelte 5 (Runes) & SvelteKit
- **Styling:** Tailwind CSS
- **Backend:** Node.js (via `@sveltejs/adapter-node`)
- **Integration:** `webdav` library for Nextcloud communication
- **Content:** `@ts-stack/markdown` for rendering
## Configuration
The application requires the following environment variables in a `.env` file:
```env
NEXTCLOUD_URL=https://your-nextcloud-instance.com
NEXTCLOUD_USER=your_username
NEXTCLOUD_PASSWORD=your_app_password
NEXTCLOUD_BASE_DIR=WebsiteContent # Optional: Root folder for site content
```
## Content Management
To add or update content, manage files in your configured Nextcloud directory:
1. **Create a Page:** Create a folder (e.g., `Travel`).
2. **Add Content:** Inside that folder, create a Markdown file named after the folder (e.g., `Travel.md`).
3. **Add Header Image:** Upload a file named `cover.webp`, `cover.jpg`, or `cover.png` to the folder. This will be used as the header on the page and the tile on the homepage.
4. **Add Assets:** Any other images or files uploaded to the folder can be linked in the Markdown.
## Development
Install dependencies:
```bash
npm run dev
pnpm install
```
# or start the server and open the app in a new browser tab
npm run dev -- --open
Start the development server:
```bash
pnpm run dev
```
## Building
To create a production version of your app:
Build for production (Node.js):
```bash
npm run build
pnpm run build
```
You can preview the production build with `npm run preview`.
The output will be in the `build/` directory, ready to be served by Node.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
## Image Processing
## Resize images
To optimize images before uploading to Nextcloud:
```bash
convert input.png -resize 3000 -quality 80 output.webp
+17 -16
View File
@@ -1,36 +1,37 @@
# Plan: Nextcloud WebDAV Integration
# Plan: Nextcloud WebDAV Integration (COMPLETED)
Transition from static file hosting to a dynamic SvelteKit (Node.js) application that fetches content directly from Nextcloud.
## 1. Infrastructure Shift
- [ ] Replace `@sveltejs/adapter-static` with `@sveltejs/adapter-node`.
- [ ] Update `svelte.config.js` to use the Node adapter.
- [ ] Remove `fallback: undefined` and other static-specific configurations.
- [x] Replace `@sveltejs/adapter-static` with `@sveltejs/adapter-node`.
- [x] Update `svelte.config.js` to use the Node adapter.
- [x] Remove `fallback: undefined` and other static-specific configurations.
## 2. Dependencies
- [ ] Install `webdav` client library.
- [ ] Install `dotenv` (if not already handled by SvelteKit's built-in env support).
- [x] Install `webdav` client library.
- [x] Install `dotenv` (handled by SvelteKit's built-in env support).
## 3. Secure Configuration
- [ ] Set up `.env` with:
- [x] Set up `.env` with:
- `NEXTCLOUD_URL`
- `NEXTCLOUD_USER`
- `NEXTCLOUD_PASSWORD`
- [ ] Use `$env/dynamic/private` to access these in server-side code only.
- [x] Use `$env/dynamic/private` to access these in server-side code only.
## 4. Server-Side Data Fetching
- [ ] Create `src/lib/server/nextcloud.ts` to initialize and export the WebDAV client.
- [ ] Implement `+page.server.ts` for each content route:
- [x] Create `src/lib/server/nextcloud.ts` to initialize and export the WebDAV client.
- [x] Implement `+page.server.ts` for each content route:
- Fetch Markdown content from Nextcloud.
- Return the raw string to the frontend.
- [ ] Update `Markdown.svelte` to receive and render raw strings instead of fetching from a URL on the client.
- [x] Update `Markdown.svelte` to receive and render raw strings instead of fetching from a URL on the client.
## 5. Asset Proxying
- [ ] Create a catch-all server route (e.g., `src/routes/assets/[...path]/+server.ts`).
- [ ] This route will:
- [x] Create a catch-all server route (e.g., `src/routes/assets/[...path]/+server.ts`).
- [x] This route will:
- Authenticate with WebDAV.
- Stream images/files from Nextcloud directly to the browser.
- [ ] Update Markdown rendering logic to rewrite image URLs to point to this proxy route.
- [x] Update Markdown rendering logic to rewrite image URLs to point to this proxy route.
## 6. Optimization (Optional but Recommended)
- [ ] Implement server-side caching (e.g., using a simple TTL cache or Redis) to avoid hitting Nextcloud on every single request.
## 6. Optimization
- [x] Implement server-side caching to avoid hitting Nextcloud on every single request.
- [x] Implement dynamic route discovery to automatically create pages from Nextcloud folders.
+12 -2
View File
@@ -3,16 +3,26 @@
interface Props {
src: string;
alt?: string;
children?: Snippet;
}
let { src, children }: Props = $props();
let { src, alt = "", children }: Props = $props();
const placeholder = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiPjxyZWN0IGZpbGw9IiMzMzMiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48L3N2Zz4=';
let imageSrc = $derived(src || placeholder);
</script>
<div class="grid grid-cols-1 grid-rows-1">
<img
{src}
src={imageSrc}
{alt}
class="row-start-1 col-start-1 pointer-events-none w-full h-96 object-cover"
onerror={(e) => {
const img = e.currentTarget as HTMLImageElement;
img.src = placeholder;
}}
/>
<div class="row-start-1 col-start-1">
{@render children?.()}
+43
View File
@@ -0,0 +1,43 @@
const cache = new Map<string, { timestamp: number; data: any }>();
const DEFAULT_TTL = 1000 * 60 * 15; // 15 minutes default TTL
/**
* Caches the result of a promise-returning function.
* @param key Unique key for the cache entry.
* @param fetcher Function that returns a promise with the data to cache.
* @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> {
const now = Date.now();
const entry = cache.get(key);
if (entry && (now - entry.timestamp < ttl)) {
return entry.data as T;
}
try {
const data = await fetcher();
cache.set(key, { timestamp: now, data });
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.
*/
export function clearCache(key: string) {
cache.delete(key);
}
/**
* Clears the entire cache.
*/
export function clearAllCache() {
cache.clear();
}
+87 -10
View File
@@ -1,21 +1,98 @@
import { createClient } from "webdav";
import { env } from "$env/dynamic/private";
import { withCache } from "./cache";
if (!env.NEXTCLOUD_URL || !env.NEXTCLOUD_USER || !env.NEXTCLOUD_PASSWORD) {
console.warn("Nextcloud environment variables are not fully set. WebDAV client might not work correctly.");
console.warn(
"Nextcloud environment variables are not fully set. WebDAV client might not work correctly.",
);
}
// Construct the standard Nextcloud WebDAV URL: https://example.com/remote.php/dav/files/USERNAME/
// Construct the standard Nextcloud WebDAV URL: https://example.com/remote.php/dav/files/USERNAME/[BASE_DIR/]
const getWebdavUrl = () => {
if (!env.NEXTCLOUD_URL) return "";
const base = env.NEXTCLOUD_URL.endsWith("/") ? env.NEXTCLOUD_URL : `${env.NEXTCLOUD_URL}/`;
return `${base}remote.php/dav/files/${env.NEXTCLOUD_USER}/`;
let url = env.NEXTCLOUD_URL.endsWith("/")
? env.NEXTCLOUD_URL
: `${env.NEXTCLOUD_URL}/`;
url += `remote.php/dav/files/${env.NEXTCLOUD_USER}/`;
if (env.NEXTCLOUD_BASE_DIR) {
// Remove leading/trailing slashes from base dir to ensure clean concatenation
const baseDir = env.NEXTCLOUD_BASE_DIR.replace(/^\/+|\/+$/g, "");
if (baseDir) {
url += `${baseDir}/`;
}
}
return url;
};
export const client = createClient(
getWebdavUrl(),
{
username: env.NEXTCLOUD_USER || "",
password: env.NEXTCLOUD_PASSWORD || ""
export const client = createClient(getWebdavUrl(), {
username: env.NEXTCLOUD_USER || "",
password: env.NEXTCLOUD_PASSWORD || "",
});
export async function getCoverImage(slug: string): Promise<string | null> {
return withCache(`cover:${slug}`, async () => {
try {
const contents = await client.getDirectoryContents("/" + slug);
console.log(contents);
if (!Array.isArray(contents)) return null;
const cover = contents.find(
(item: any) =>
item.type === "file" &&
item.basename.match(/^cover\.(webp|jpg|jpeg|png)$/i),
);
return cover ? `/assets/${slug}/${cover.basename}` : null;
} catch (e) {
console.warn(`Failed to check cover image for ${slug}:`, e);
return null;
}
);
});
}
export async function getPages() {
return withCache("pages", async () => {
try {
const items = await client.getDirectoryContents("/");
if (!Array.isArray(items)) {
return [];
}
const directories = items.filter(
(item: any) =>
item.type === "directory" && !item.basename.startsWith("."),
);
const pages = await Promise.all(
directories.map(async (item: any) => {
const slug = item.basename;
const title = slug.charAt(0).toUpperCase() + slug.slice(1);
const coverImage = await getCoverImage(slug);
return {
slug,
title,
coverImage,
};
}),
);
return pages;
} catch (e) {
console.error("Failed to fetch pages from Nextcloud:", e);
return [];
}
});
}
export async function getPageContent(path: string): Promise<string> {
return withCache(`content:${path}`, async () => {
const content = await client.getFileContents(path, { format: "text" });
return content as string;
});
}
+9
View File
@@ -0,0 +1,9 @@
import type { LayoutServerLoad } from './$types';
import { getPages } from '$lib/server/nextcloud';
export const load: LayoutServerLoad = async () => {
const pages = await getPages();
return {
pages
};
};
+42 -35
View File
@@ -1,41 +1,48 @@
<script lang="ts">
import ImageLinkTile from "$lib/components/ImageLinkTile.svelte";
import ImageLinkTile from "$lib/components/ImageLinkTile.svelte";
import type { PageData } from "./$types";
import mouse from "$lib/images/home/mouse.webp";
import drummachine from "$lib/images/home/drummachine.webp";
import scouts from "$lib/images/home/scouts.webp";
import console_image from "$lib/images/home/console.webp";
import lauch from "$lib/images/home/lauch.webp";
// Import local images to preserve existing design
import mouse from "$lib/images/home/mouse.webp";
import drummachine from "$lib/images/home/drummachine.webp";
import scouts from "$lib/images/home/scouts.webp";
import console_image from "$lib/images/home/console.webp";
import lauch from "$lib/images/home/lauch.webp";
import monitor from "$lib/images/informatik/monitor.webp"; // Using monitor for Informatik if console is not preferred
let { data }: { data: PageData } = $props();
// Map slugs to local images for backward compatibility/styling
const staticImages: Record<string, string> = {
informatik: console_image,
fotos: mouse,
video: lauch,
musig: drummachine,
};
// Helper to resolve image source: Local override -> Nextcloud cover -> Placeholder (handled by ImageTile)
const getTileImage = (page: { slug: string; coverImage: string | null }) => {
return staticImages[page.slug.toLowerCase()] || page.coverImage || "";
};
</script>
<main>
<ImageLinkTile src={console_image} href="/informatik">
<h2 class="text-3xl font-bold text-white hover:underline text-shadow-lg">
Informatik
</h2>
</ImageLinkTile>
<main class="">
{#if data.pages}
{#each data.pages as page}
<ImageLinkTile src={getTileImage(page)} href={`/${page.slug}`}>
<h2
class="text-3xl font-bold text-white hover:underline text-shadow-lg"
>
{page.title}
</h2>
</ImageLinkTile>
{/each}
{/if}
<ImageLinkTile src={scouts} href="https://pfadifrisco.ch/" target="_blank">
<h2 class="text-3xl font-bold text-white hover:underline text-shadow-lg">
Pfadi
</h2>
</ImageLinkTile>
<ImageLinkTile src={mouse} href="/fotos">
<h2 class="text-3xl font-bold text-white hover:underline text-shadow-lg">
Fotos
</h2>
</ImageLinkTile>
<ImageLinkTile src={lauch} href="/video">
<h2 class="text-3xl font-bold text-white hover:underline text-shadow-lg">
Videos
</h2>
</ImageLinkTile>
<ImageLinkTile src={drummachine} href="/musig">
<h2 class="text-3xl font-bold text-white hover:underline text-shadow-lg">
Musig
</h2>
</ImageLinkTile>
<!-- Static/External Links -->
<ImageLinkTile src={scouts} href="https://pfadifrisco.ch/" target="_blank">
<h2 class="text-3xl font-bold text-white hover:underline text-shadow-lg">
Pfadi
</h2>
</ImageLinkTile>
</main>
+27
View File
@@ -0,0 +1,27 @@
import { getCoverImage, getPageContent } from "$lib/server/nextcloud";
import type { PageServerLoad } from "./$types";
import { error } from "@sveltejs/kit";
export const load: PageServerLoad = async ({ params }) => {
const { slug } = params;
// Capitalize the first letter to match the convention: fotos -> Fotos.md
const filename = slug.charAt(0).toUpperCase() + slug.slice(1) + ".md";
const path = `/${slug}/${filename}`;
try {
const [content, coverImage] = await Promise.all([
getPageContent(path),
getCoverImage(slug)
]);
return {
content,
title: slug.charAt(0).toUpperCase() + slug.slice(1),
slug,
coverImage
};
} catch (e) {
console.error(`Error fetching ${filename} from Nextcloud`, e);
throw error(404, "Page not found");
}
};
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import ImageTile from "$lib/components/ImageTile.svelte";
import Markdown from "$lib/components/Markdown.svelte";
import type { PageData } from "./$types";
// Legacy images
import sunne_untergang from "$lib/images/fotos/sunne_untergang.webp";
import monitor from "$lib/images/informatik/monitor.webp";
import plattespiler from "$lib/images/musig/plattespiler.webp";
import jochen from "$lib/images/video/jochen.webp";
let { data }: { data: PageData } = $props();
const legacyHeaders: Record<string, string> = {
fotos: sunne_untergang,
informatik: monitor,
musig: plattespiler,
video: jochen
};
// Fallback image data URI
const placeholder = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiPjxyZWN0IGZpbGw9IiMzMzMiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48L3N2Zz4=';
// Use the cover image found by the server, or legacy local image, or placeholder
let headerImage = $derived(
data.coverImage ||
legacyHeaders[data.slug.toLowerCase()] ||
"" // ImageTile handles empty/null src with placeholder or its own logic
);
</script>
<ImageTile src={headerImage} alt={data.title}>
<h2 class="flex items-center justify-center h-full text-3xl font-bold text-white text-shadow-lg">
{data.title}
</h2>
</ImageTile>
<main class="flex items-center justify-center pt-8 bg-black min-h-screen px-12">
<section class="max-w-screen-md w-full">
<Markdown content={data.content} basePath={`/${data.slug}`} />
</section>
</main>
-16
View File
@@ -1,16 +0,0 @@
import { client } from "$lib/server/nextcloud";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async () => {
try {
const content = await client.getFileContents("/fotos/Fotos.md", { format: "text" });
return {
content: content as string
};
} catch (e) {
console.error("Error fetching Fotos.md from Nextcloud", e);
return {
content: "Failed to load content from Nextcloud."
};
}
};
-21
View File
@@ -1,21 +0,0 @@
<script lang="ts">
import ImageTile from "$lib/components/ImageTile.svelte";
import Markdown from "$lib/components/Markdown.svelte";
import HeadImage from "$lib/images/fotos/sunne_untergang.webp";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<ImageTile src={HeadImage}>
<h2
class="flex items-center justify-center h-full text-3xl font-bold text-white text-shadow-lg"
>
Fotos
</h2>
</ImageTile>
<main class="flex items-center justify-center pt-8 bg-black min-h-screen px-12">
<section class="max-w-screen-md">
<Markdown content={data.content} basePath="/fotos" />
</section>
</main>
-16
View File
@@ -1,16 +0,0 @@
import { client } from "$lib/server/nextcloud";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async () => {
try {
const content = await client.getFileContents("/informatik/Informatik.md", { format: "text" });
return {
content: content as string
};
} catch (e) {
console.error("Error fetching Informatik.md from Nextcloud", e);
return {
content: "Failed to load content from Nextcloud."
};
}
};
-21
View File
@@ -1,21 +0,0 @@
<script lang="ts">
import ImageTile from "$lib/components/ImageTile.svelte";
import Markdown from "$lib/components/Markdown.svelte";
import HeadImage from "$lib/images/informatik/monitor.webp";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<ImageTile src={HeadImage}>
<h2
class="flex items-center justify-center h-full text-3xl font-bold text-white text-shadow-lg"
>
Informatik
</h2>
</ImageTile>
<main class="flex items-center justify-center pt-8 bg-black min-h-screen px-12">
<section class="max-w-screen-md">
<Markdown content={data.content} basePath="/informatik" />
</section>
</main>
-16
View File
@@ -1,16 +0,0 @@
import { client } from "$lib/server/nextcloud";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async () => {
try {
const content = await client.getFileContents("/musig/Musig.md", { format: "text" });
return {
content: content as string
};
} catch (e) {
console.error("Error fetching Musig.md from Nextcloud", e);
return {
content: "Failed to load content from Nextcloud."
};
}
};
-21
View File
@@ -1,21 +0,0 @@
<script lang="ts">
import ImageTile from "$lib/components/ImageTile.svelte";
import Markdown from "$lib/components/Markdown.svelte";
import HeadImage from "$lib/images/musig/plattespiler.webp";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<ImageTile src={HeadImage}>
<h2
class="flex items-center justify-center h-full text-3xl font-bold text-white text-shadow-lg"
>
Musig
</h2>
</ImageTile>
<main class="flex items-center justify-center pt-8 bg-black min-h-screen px-12">
<section class="max-w-screen-md">
<Markdown content={data.content} basePath="/musig" />
</section>
</main>
-16
View File
@@ -1,16 +0,0 @@
import { client } from "$lib/server/nextcloud";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async () => {
try {
const content = await client.getFileContents("/video/Video.md", { format: "text" });
return {
content: content as string
};
} catch (e) {
console.error("Error fetching Video.md from Nextcloud", e);
return {
content: "Failed to load content from Nextcloud."
};
}
};
-21
View File
@@ -1,21 +0,0 @@
<script lang="ts">
import ImageTile from "$lib/components/ImageTile.svelte";
import Markdown from "$lib/components/Markdown.svelte";
import HeadImage from "$lib/images/video/jochen.webp";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<ImageTile src={HeadImage}>
<h2
class="flex items-center justify-center h-full text-3xl font-bold text-white text-shadow-lg"
>
Video Sammlig:
</h2>
</ImageTile>
<main class="flex items-center justify-center pt-8 bg-black min-h-screen px-12">
<section class="max-w-screen-md">
<Markdown content={data.content} basePath="/video" />
</section>
</main>