From c937be1274f68933710f1e881a68fa6589155a03 Mon Sep 17 00:00:00 2001 From: schreifuchs Date: Wed, 21 Jan 2026 18:54:29 +0100 Subject: [PATCH] feat: hidden pages --- GEMINI.md | 13 +- README.md | 5 +- TODO_NEXTCLOUD.md | 37 ----- messages/de.json | 13 +- messages/en.json | 13 +- project.inlang/.gitignore | 1 + project.inlang/project_id | 1 + project.inlang/settings.json | 27 ++-- src/lib/server/nextcloud.ts | 4 +- .../[[lang=lang]]/[slug]/+page.server.ts | 52 ++++--- src/routes/assets/[...path]/+server.ts | 132 ++++++++++-------- 11 files changed, 154 insertions(+), 144 deletions(-) delete mode 100644 TODO_NEXTCLOUD.md create mode 100644 project.inlang/.gitignore create mode 100644 project.inlang/project_id diff --git a/GEMINI.md b/GEMINI.md index b99c76c..56f8d27 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,11 +1,11 @@ # Project Context: Schreifuchs.ch ## Overview + This project is a personal portfolio website for "Schreifuchs", featuring sections for Photography (Fotos), Informatics (Informatik), Music (Musig), and Video. It is currently being transitioned from a static site to a dynamic Node.js site powered by Nextcloud via WebDAV. -- [Nextcloud Integration Plan](./TODO_NEXTCLOUD.md) - ## Tech Stack + - **Framework:** Svelte 5 (using Runes) & SvelteKit - **Language:** TypeScript - **Styling:** Tailwind CSS (with custom text-shadow plugin) @@ -14,6 +14,7 @@ This project is a personal portfolio website for "Schreifuchs", featuring sectio - **Markdown:** `@ts-stack/markdown` for content rendering ## Directory Structure + - `src/routes/`: File-based routing. - `src/routes/fotos`, `informatik`, `musig`, `video`: Feature-specific pages. - `src/lib/`: Shared utilities and components. @@ -23,12 +24,13 @@ This project is a personal portfolio website for "Schreifuchs", featuring sectio - Contains Markdown content files (`Fotos.md`, etc.) and their associated attachments. ## Key Conventions + - **Svelte 5:** Use Runes (`$state`, `$derived`, `$props`, etc.) for reactivity. - **Styling:** Use Tailwind utility classes. The font family is configured to 'Outfit'. -- **Content:** Content is largely driven by Markdown files located in `static/` which are fetched and parsed. -- **Static Generation:** The site is built as a static site outputting to the `build/` directory. +- **Content:** Content is largely driven by Markdown files located on Nextcloud which are fetched and parsed. ## Development Scripts + - `npm run dev`: Start development server. - `npm run build`: Build for production. - `npm run check`: Run Svelte and TypeScript checks. @@ -59,4 +61,5 @@ You MUST use this tool whenever writing Svelte code before sending it to the use ### 4. playground-link Generates a Svelte Playground link with the provided code. -After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project. \ No newline at end of file +After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project. + diff --git a/README.md b/README.md index c77f279..02fb76a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ The site is built with **Svelte 5** and **SvelteKit**, and it is powered by a dy ## Features - **Dynamic Content:** Pages are automatically discovered based on the folder structure in Nextcloud. +- **Hidden Sites:** Folders starting with `_` are excluded from the navigation menu but remain accessible via direct links. - **Markdown Driven:** Content for each page is fetched from a corresponding Markdown file. +- **Image Optimization:** Automatic image resizing via Nextcloud's Preview API for optimized delivery and `srcset` support. - **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. @@ -36,7 +38,8 @@ NEXTCLOUD_BASE_DIR=WebsiteContent # Optional: Root folder for site content 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`). + - **Hidden Sites:** To hide a page from the navigation menu, prefix the folder name with an underscore (e.g., `_Secret`). It will still be accessible at `/secret`. +2. **Add Content:** Inside that folder, create a Markdown file named after the folder (e.g., `Travel.md` or `Secret.md` for `_Secret`). 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. diff --git a/TODO_NEXTCLOUD.md b/TODO_NEXTCLOUD.md deleted file mode 100644 index 5c6c4b4..0000000 --- a/TODO_NEXTCLOUD.md +++ /dev/null @@ -1,37 +0,0 @@ -# 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 -- [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 -- [x] Install `webdav` client library. -- [x] Install `dotenv` (handled by SvelteKit's built-in env support). - -## 3. Secure Configuration -- [x] Set up `.env` with: - - `NEXTCLOUD_URL` - - `NEXTCLOUD_USER` - - `NEXTCLOUD_PASSWORD` -- [x] Use `$env/dynamic/private` to access these in server-side code only. - -## 4. Server-Side Data Fetching -- [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. -- [x] Update `Markdown.svelte` to receive and render raw strings instead of fetching from a URL on the client. - -## 5. Asset Proxying -- [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. -- [x] Update Markdown rendering logic to rewrite image URLs to point to this proxy route. - -## 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/messages/de.json b/messages/de.json index 96f5c06..9759149 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1,7 +1,8 @@ { - "$schema": "https://inlang.com/schema/message-format-plugin", - "hello_world": "Hallo Welt", - "pfadi": "Pfadi", - "site_title": "Schreifuchs.ch", - "site_description": "Portfolio von Niklas - Fotografie, Informatik, Musik und Video." -} \ No newline at end of file + "$schema": "https://inlang.com/schema/message-format-plugin", + "hello_world": "Hallo Welt", + "pfadi": "Pfadi", + "site_title": "Schreifuchs.ch", + "site_description": "Portfolio von Schreifuchs - Fotografie, Informatik und Musik." +} + diff --git a/messages/en.json b/messages/en.json index 8d644bc..a769a1b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,7 +1,8 @@ { - "$schema": "https://inlang.com/schema/message-format-plugin", - "hello_world": "Hello World", - "pfadi": "Scouts", - "site_title": "Schreifuchs.ch", - "site_description": "Portfolio of Niklas - Photography, Informatics, Music and Video." -} \ No newline at end of file + "$schema": "https://inlang.com/schema/message-format-plugin", + "hello_world": "Hello World", + "pfadi": "Scouts", + "site_title": "Schreifuchs.ch", + "site_description": "Portfolio of Schreifuchs - Photography, Informatics and Music ." +} + diff --git a/project.inlang/.gitignore b/project.inlang/.gitignore new file mode 100644 index 0000000..5e46596 --- /dev/null +++ b/project.inlang/.gitignore @@ -0,0 +1 @@ +cache \ No newline at end of file diff --git a/project.inlang/project_id b/project.inlang/project_id new file mode 100644 index 0000000..136ed81 --- /dev/null +++ b/project.inlang/project_id @@ -0,0 +1 @@ +aa922b989658d017b7a6296093cef3dac23a39ddc1afa6cf7ea24c049a284b56 \ No newline at end of file diff --git a/project.inlang/settings.json b/project.inlang/settings.json index 6639297..dcca6d2 100644 --- a/project.inlang/settings.json +++ b/project.inlang/settings.json @@ -1,15 +1,14 @@ { - "$schema": "https://inlang.com/schema/project-settings", - "sourceLanguageTag": "de", - "languageTags": [ - "de", - "en" - ], - "modules": [ - "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@latest/dist/index.js", - "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js" - ], - "plugin.inlang.messageFormat": { - "pathPattern": "./messages/{languageTag}.json" - } -} \ No newline at end of file + "$schema": "https://inlang.com/schema/project-settings", + "telemetry": "off", + "sourceLanguageTag": "de", + "languageTags": ["de", "en"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@latest/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{languageTag}.json" + } +} + diff --git a/src/lib/server/nextcloud.ts b/src/lib/server/nextcloud.ts index 6d80ce6..4e57c6c 100644 --- a/src/lib/server/nextcloud.ts +++ b/src/lib/server/nextcloud.ts @@ -64,7 +64,9 @@ export async function getPages(lang: string = 'de') { const directories = items.filter( (item: any) => - item.type === "directory" && !item.basename.startsWith("."), + item.type === "directory" && + !item.basename.startsWith(".") && + !item.basename.startsWith("_"), ); const pages = await Promise.all( diff --git a/src/routes/[[lang=lang]]/[slug]/+page.server.ts b/src/routes/[[lang=lang]]/[slug]/+page.server.ts index df2f131..dcaa1e9 100644 --- a/src/routes/[[lang=lang]]/[slug]/+page.server.ts +++ b/src/routes/[[lang=lang]]/[slug]/+page.server.ts @@ -6,44 +6,64 @@ export const load: PageServerLoad = async ({ params }) => { const { slug, lang } = params; const language = lang || 'de'; - // Capitalize the first letter to match the convention: fotos -> Fotos.md - const baseName = slug.charAt(0).toUpperCase() + slug.slice(1); - const filename = language === 'de' ? `${baseName}.md` : `${baseName}.${language}.md`; - const path = `/${slug}/${filename}`; + // Try both the regular slug and the hidden (_slug) folder + const slugsToTry = [slug, `_${slug}`]; + let rawContent = ""; + let finalSlug = slug; + let baseName = ""; + let filename = ""; + + for (const s of slugsToTry) { + const cleanSlug = s.startsWith('_') ? s.slice(1) : s; + baseName = cleanSlug.charAt(0).toUpperCase() + cleanSlug.slice(1); + + filename = language === 'de' ? `${baseName}.md` : `${baseName}.${language}.md`; + const path = `/${s}/${filename}`; - try { - let rawContent; try { - rawContent = await getPageContent(path); + try { + rawContent = await getPageContent(path); + finalSlug = s; + break; // Found it! + } catch (e) { + if (language !== 'de') { + console.warn(`Localized content ${filename} not found in ${s}, falling back to German.`); + rawContent = await getPageContent(`/${s}/${baseName}.md`); + finalSlug = s; + break; // Found fallback + } else { + throw e; // Try next slug variant + } + } } catch (e) { - if (language !== 'de') { - console.warn(`Localized content ${filename} not found, falling back to German.`); - rawContent = await getPageContent(`/${slug}/${baseName}.md`); - } else { - throw e; + if (s === slugsToTry[slugsToTry.length - 1]) { + console.error(`Error fetching page from Nextcloud for slug ${slug}`, e); + throw error(404, "Page not found"); } } + } + try { // Extract the first header (e.g., # Title or ## Title) const headerMatch = rawContent.match(/^#{1,6}\s+(.+)$/m); - const title = headerMatch ? headerMatch[1] : baseName; + const title = headerMatch ? headerMatch[1] : (finalSlug.startsWith('_') ? finalSlug.slice(1) : finalSlug); // Remove the first header from content so it's not rendered twice const content = headerMatch ? rawContent.replace(headerMatch[0], "").trim() : rawContent; - const coverImage = await getCoverImage(slug); + const coverImage = await getCoverImage(finalSlug); return { content, title, - slug, + slug: finalSlug, coverImage, lang: language }; } catch (e) { - console.error(`Error fetching ${filename} from Nextcloud`, e); + console.error(`Error processing content for ${finalSlug}`, e); throw error(404, "Page not found"); } }; \ No newline at end of file diff --git a/src/routes/assets/[...path]/+server.ts b/src/routes/assets/[...path]/+server.ts index 5080a11..d196a69 100644 --- a/src/routes/assets/[...path]/+server.ts +++ b/src/routes/assets/[...path]/+server.ts @@ -17,67 +17,83 @@ export const GET: RequestHandler = async ({ params, url }) => { let buffer: ArrayBuffer; let contentType: string; - if (width || height) { - // Use Nextcloud Preview API - const baseUrl = env.NEXTCLOUD_URL?.endsWith("/") ? env.NEXTCLOUD_URL : `${env.NEXTCLOUD_URL}/`; - - let fullPath = path; - if (env.NEXTCLOUD_BASE_DIR) { - const baseDir = env.NEXTCLOUD_BASE_DIR.replace(/^\/+|\/+$/g, ""); - fullPath = baseDir ? `${baseDir}/${path}` : path; - } - - const previewUrl = new URL(`${baseUrl}index.php/core/preview`); - // Ensure leading slash for the file parameter - 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"); - - console.log(`Fetching preview: ${previewUrl.toString()} for file: ${fullPath}`); - - try { - const response = await fetch(previewUrl.toString(), { - headers: { - "Authorization": `Basic ${auth}` - } - }); - - if (response.ok) { - buffer = await response.arrayBuffer(); - contentType = response.headers.get("Content-Type") || "image/png"; - - return new Response(buffer, { - headers: { - "Content-Type": contentType, - "Cache-Control": "public, max-age=86400", - "Vary": "Accept-Encoding" - } - }); - } else { - console.warn(`Nextcloud preview failed (${response.status} ${response.statusText}) for ${fullPath}. Falling back to original.`); - } - } catch (fetchError) { - console.error(`Fetch error during preview request for ${fullPath}:`, fetchError); - } + // Try both original path and hidden variant if applicable + const pathsToTry = [path]; + if (!path.startsWith('_')) { + pathsToTry.push(`_${path}`); } - // Fetch original via WebDAV (Fallback or no resize requested) - console.log(`Fetching original file via WebDAV: /${path}`); - const data = await client.getFileContents("/" + path); - buffer = data as ArrayBuffer; - contentType = mime.lookup(path) || "application/octet-stream"; - - return new Response(buffer, { - headers: { - "Content-Type": contentType, - "Cache-Control": "public, max-age=86400", - "Vary": "Accept-Encoding" + let response: Response | null = null; + let lastError: any = null; + + for (const currentPath of pathsToTry) { + try { + if (width || height) { + // Use Nextcloud Preview API + const baseUrl = env.NEXTCLOUD_URL?.endsWith("/") ? env.NEXTCLOUD_URL : `${env.NEXTCLOUD_URL}/`; + + let fullPath = currentPath; + if (env.NEXTCLOUD_BASE_DIR) { + const baseDir = env.NEXTCLOUD_BASE_DIR.replace(/^\/+|\/+$/g, ""); + fullPath = baseDir ? `${baseDir}/${currentPath}` : currentPath; + } + + const isJpg = fullPath.match(/\.(jpg|jpeg)$/i); + const previewExtension = isJpg ? 'jpg' : 'png'; + const previewUrl = new URL(`${baseUrl}index.php/core/preview.${previewExtension}`); + // Ensure leading slash for the file parameter + 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"); + + console.log(`Fetching preview: ${previewUrl.toString()} for file: ${fullPath}`); + + const ncResponse = await fetch(previewUrl.toString(), { + headers: { + "Authorization": `Basic ${auth}` + } + }); + + if (ncResponse.ok) { + buffer = await ncResponse.arrayBuffer(); + contentType = ncResponse.headers.get("Content-Type") || "image/png"; + + return new Response(buffer, { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=86400", + "Vary": "Accept-Encoding" + } + }); + } else { + console.warn(`Nextcloud preview failed (${ncResponse.status}) for ${fullPath}.`); + } + } + + // Fetch original via WebDAV (Fallback or no resize requested) + console.log(`Fetching original file via WebDAV: /${currentPath}`); + const data = await client.getFileContents("/" + currentPath); + buffer = data as ArrayBuffer; + contentType = mime.lookup(currentPath) || "application/octet-stream"; + + 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 } - }); + } + + throw lastError || new Error("Asset not found"); } catch (e) { console.error(`Error fetching asset from Nextcloud: ${path}`, e); throw error(404, "Asset not found");