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: -

-
-
-
- -
-