diff --git a/README.md b/README.md
index 1b3fbb9..c77f279 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,73 @@
-# 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
-```
+```
\ No newline at end of file
diff --git a/TODO_NEXTCLOUD.md b/TODO_NEXTCLOUD.md
index fcce75c..5c6c4b4 100644
--- a/TODO_NEXTCLOUD.md
+++ b/TODO_NEXTCLOUD.md
@@ -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.
\ No newline at end of file
diff --git a/src/lib/components/ImageTile.svelte b/src/lib/components/ImageTile.svelte
index 10d4e03..3f8ac13 100644
--- a/src/lib/components/ImageTile.svelte
+++ b/src/lib/components/ImageTile.svelte
@@ -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);

{
+ const img = e.currentTarget as HTMLImageElement;
+ img.src = placeholder;
+ }}
/>
{@render children?.()}
diff --git a/src/lib/server/cache.ts b/src/lib/server/cache.ts
new file mode 100644
index 0000000..b839855
--- /dev/null
+++ b/src/lib/server/cache.ts
@@ -0,0 +1,43 @@
+const cache = new Map();
+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(key: string, fetcher: () => Promise, ttl = DEFAULT_TTL): Promise {
+ 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();
+}
diff --git a/src/lib/server/nextcloud.ts b/src/lib/server/nextcloud.ts
index 41f8472..6167017 100644
--- a/src/lib/server/nextcloud.ts
+++ b/src/lib/server/nextcloud.ts
@@ -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 {
+ 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 {
+ return withCache(`content:${path}`, async () => {
+ const content = await client.getFileContents(path, { format: "text" });
+ return content as string;
+ });
+}
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts
new file mode 100644
index 0000000..bd28f8e
--- /dev/null
+++ b/src/routes/+layout.server.ts
@@ -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
+ };
+};
\ No newline at end of file
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 59da3bd..e397977 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,41 +1,48 @@
-
-
-
- Informatik
-
-
+
+ {#if data.pages}
+ {#each data.pages as page}
+
+
+ {page.title}
+
+
+ {/each}
+ {/if}
-
-
- Pfadi
-
-
-
-
-
- Fotos
-
-
-
-
-
- Videos
-
-
-
-
-
- Musig
-
-
+
+
+
+ Pfadi
+
+
diff --git a/src/routes/[slug]/+page.server.ts b/src/routes/[slug]/+page.server.ts
new file mode 100644
index 0000000..a4bca47
--- /dev/null
+++ b/src/routes/[slug]/+page.server.ts
@@ -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");
+ }
+};
\ No newline at end of file
diff --git a/src/routes/[slug]/+page.svelte b/src/routes/[slug]/+page.svelte
new file mode 100644
index 0000000..e8f7d0a
--- /dev/null
+++ b/src/routes/[slug]/+page.svelte
@@ -0,0 +1,42 @@
+
+
+
+
+ {data.title}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/routes/fotos/+page.server.ts b/src/routes/fotos/+page.server.ts
deleted file mode 100644
index e91093d..0000000
--- a/src/routes/fotos/+page.server.ts
+++ /dev/null
@@ -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."
- };
- }
-};
diff --git a/src/routes/fotos/+page.svelte b/src/routes/fotos/+page.svelte
deleted file mode 100644
index aa30fbb..0000000
--- a/src/routes/fotos/+page.svelte
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
- Fotos
-
-
-
-
-
diff --git a/src/routes/informatik/+page.server.ts b/src/routes/informatik/+page.server.ts
deleted file mode 100644
index 75a94a4..0000000
--- a/src/routes/informatik/+page.server.ts
+++ /dev/null
@@ -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."
- };
- }
-};
diff --git a/src/routes/informatik/+page.svelte b/src/routes/informatik/+page.svelte
deleted file mode 100644
index c59319c..0000000
--- a/src/routes/informatik/+page.svelte
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
- Informatik
-
-
-
-
-
diff --git a/src/routes/musig/+page.server.ts b/src/routes/musig/+page.server.ts
deleted file mode 100644
index 2141390..0000000
--- a/src/routes/musig/+page.server.ts
+++ /dev/null
@@ -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."
- };
- }
-};
diff --git a/src/routes/musig/+page.svelte b/src/routes/musig/+page.svelte
deleted file mode 100644
index b895e8c..0000000
--- a/src/routes/musig/+page.svelte
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
- Musig
-
-
-
-
-
diff --git a/src/routes/video/+page.server.ts b/src/routes/video/+page.server.ts
deleted file mode 100644
index 51a8185..0000000
--- a/src/routes/video/+page.server.ts
+++ /dev/null
@@ -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."
- };
- }
-};
diff --git a/src/routes/video/+page.svelte b/src/routes/video/+page.svelte
deleted file mode 100644
index 881f3e6..0000000
--- a/src/routes/video/+page.svelte
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
- Video Sammlig:
-
-
-
-
-