Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4e8baabdc | ||
|
|
ab3c8982e2 | ||
|
|
df9e6e95cb | ||
|
|
2154d6cfa8 | ||
|
|
9002f13f80 | ||
|
|
c937be1274 |
@@ -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.
|
||||
@@ -60,3 +62,4 @@ You MUST use this tool whenever writing Svelte code before sending it to the use
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
+6
-5
@@ -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."
|
||||
"$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."
|
||||
}
|
||||
|
||||
|
||||
+6
-5
@@ -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."
|
||||
"$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 ."
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@inlang/paraglide-js": "^1.11.0",
|
||||
"@inlang/paraglide-sveltekit": "^0.16.1",
|
||||
"@ts-stack/markdown": "^1.5.0",
|
||||
"lru-cache": "^11.2.4",
|
||||
"mime-types": "^3.0.2",
|
||||
"svelte-preprocess": "^6.0.2",
|
||||
"webdav": "^5.8.0"
|
||||
|
||||
Generated
+9
@@ -20,6 +20,9 @@ importers:
|
||||
'@ts-stack/markdown':
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.0
|
||||
lru-cache:
|
||||
specifier: ^11.2.4
|
||||
version: 11.2.4
|
||||
mime-types:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
@@ -1269,6 +1272,10 @@ packages:
|
||||
resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==}
|
||||
|
||||
@@ -2988,6 +2995,8 @@ snapshots:
|
||||
|
||||
lru-cache@10.2.2: {}
|
||||
|
||||
lru-cache@11.2.4: {}
|
||||
|
||||
magic-string@0.30.10:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.4.15
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
cache
|
||||
@@ -0,0 +1 @@
|
||||
aa922b989658d017b7a6296093cef3dac23a39ddc1afa6cf7ea24c049a284b56
|
||||
@@ -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"
|
||||
}
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -2,10 +2,18 @@
|
||||
<html lang="%paraglide.lang%" dir="%paraglide.textDirection%">
|
||||
<head>
|
||||
<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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -55,11 +55,10 @@
|
||||
<style global>
|
||||
.markdown {
|
||||
color: white;
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.markdown h1 {
|
||||
font-size: 2rem;
|
||||
@@ -80,6 +79,8 @@
|
||||
|
||||
.markdown img {
|
||||
max-height: 90vh;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.markdown table,
|
||||
|
||||
+63
-19
@@ -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 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.
|
||||
@@ -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 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);
|
||||
export async function withCache<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
ttl = DEFAULT_TTL,
|
||||
): Promise<T> {
|
||||
const entry = cache.get(key);
|
||||
|
||||
if (entry && (now - entry.timestamp < ttl)) {
|
||||
return entry.data as T;
|
||||
}
|
||||
if (entry) {
|
||||
return entry 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;
|
||||
try {
|
||||
const data = await fetcher();
|
||||
if (data) {
|
||||
cache.set(key, data, { ttl: ttl });
|
||||
}
|
||||
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);
|
||||
cache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the entire cache.
|
||||
*/
|
||||
export function clearAllCache() {
|
||||
cache.clear();
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+40
-31
@@ -1,40 +1,49 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
import type { Snippet } from 'svelte';
|
||||
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
||||
import { i18n } from '$lib/i18n';
|
||||
import { languageTag } from '$lib/paraglide/runtime';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import "../app.css";
|
||||
import type { Snippet } from "svelte";
|
||||
import { ParaglideJS } from "@inlang/paraglide-sveltekit";
|
||||
import { i18n } from "$lib/i18n";
|
||||
import { languageTag } from "$lib/paraglide/runtime";
|
||||
import { page } from "$app/state";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
let { children }: { children?: Snippet } = $props();
|
||||
let { children }: { children?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.site_title()}</title>
|
||||
<meta name="description" content={m.site_description()} />
|
||||
<title>{m.site_title()}</title>
|
||||
<meta name="description" content={m.site_description()} />
|
||||
</svelte:head>
|
||||
|
||||
<ParaglideJS {i18n}>
|
||||
<header class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start">
|
||||
<a href="/" class="text-white text-2xl">schreifuchs.ch</a>
|
||||
<nav class="flex gap-2 p-2">
|
||||
<a
|
||||
href="/"
|
||||
hreflang="de"
|
||||
data-no-translate
|
||||
class="text-white hover:underline {languageTag() === 'de' ? 'font-bold' : ''}"
|
||||
>
|
||||
DE
|
||||
</a>
|
||||
<a
|
||||
href="/en"
|
||||
hreflang="en"
|
||||
data-no-translate
|
||||
class="text-white hover:underline {languageTag() === 'en' ? 'font-bold' : ''}"
|
||||
>
|
||||
EN
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
{@render children?.()}
|
||||
<header
|
||||
class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start"
|
||||
>
|
||||
<a href={i18n.resolveRoute("/")} class="text-white text-2xl"
|
||||
>schreifuchs.ch</a
|
||||
>
|
||||
<nav class="flex gap-2 p-2">
|
||||
<a
|
||||
href={i18n.resolveRoute(i18n.route(page.url.pathname), "de")}
|
||||
hreflang="de"
|
||||
data-no-translate
|
||||
class="text-white hover:underline {languageTag() === 'de'
|
||||
? 'font-bold'
|
||||
: ''}"
|
||||
>
|
||||
DE
|
||||
</a>
|
||||
<a
|
||||
href={i18n.resolveRoute(i18n.route(page.url.pathname), "en")}
|
||||
hreflang="en"
|
||||
data-no-translate
|
||||
class="text-white hover:underline {languageTag() === 'en'
|
||||
? 'font-bold'
|
||||
: ''}"
|
||||
>
|
||||
EN
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
{@render children?.()}
|
||||
</ParaglideJS>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
// 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 || "";
|
||||
return page.coverImage || staticImages[page.slug.toLowerCase()] || "";
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -47,3 +47,4 @@
|
||||
</h2>
|
||||
</ImageLinkTile>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -1,85 +1,136 @@
|
||||
// src/routes/assets/[...path]/+server.ts
|
||||
import { client } from "$lib/server/nextcloud";
|
||||
import { error } from "@sveltejs/kit";
|
||||
import type { RequestHandler } from "./$types";
|
||||
import mime from "mime-types";
|
||||
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 }) => {
|
||||
const { path } = params;
|
||||
const width = url.searchParams.get("w");
|
||||
const height = url.searchParams.get("h");
|
||||
const { path } = params;
|
||||
const width = url.searchParams.get("w");
|
||||
const height = url.searchParams.get("h");
|
||||
|
||||
if (!path) {
|
||||
throw error(400, "Path is required");
|
||||
}
|
||||
if (!path) {
|
||||
throw error(400, "Path is required");
|
||||
}
|
||||
|
||||
try {
|
||||
let buffer: ArrayBuffer;
|
||||
let contentType: string;
|
||||
// 1. Create a unique cache key for this specific asset variation
|
||||
const cacheKey = `asset:${path}?w=${width ?? ""}&h=${height ?? ""}`;
|
||||
|
||||
if (width || height) {
|
||||
// Use Nextcloud Preview API
|
||||
const baseUrl = env.NEXTCLOUD_URL?.endsWith("/") ? env.NEXTCLOUD_URL : `${env.NEXTCLOUD_URL}/`;
|
||||
const ttl = path.toString().includes("cover") ? DEFAULT_TTL : EXTENDED_TTL;
|
||||
|
||||
let fullPath = path;
|
||||
if (env.NEXTCLOUD_BASE_DIR) {
|
||||
const baseDir = env.NEXTCLOUD_BASE_DIR.replace(/^\/+|\/+$/g, "");
|
||||
fullPath = baseDir ? `${baseDir}/${path}` : path;
|
||||
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
|
||||
|
||||
const pathsToTry = [path];
|
||||
if (!path.startsWith("_")) {
|
||||
pathsToTry.push(`_${path}`);
|
||||
}
|
||||
let lastError: any = null;
|
||||
|
||||
for (const currentPath of pathsToTry) {
|
||||
try {
|
||||
// A. Try Nextcloud Preview (Resizing)
|
||||
if (width || height) {
|
||||
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}`,
|
||||
);
|
||||
|
||||
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) {
|
||||
const buffer = await ncResponse.arrayBuffer();
|
||||
const contentType =
|
||||
ncResponse.headers.get("Content-Type") || "image/png";
|
||||
|
||||
// Return the raw data to be cached
|
||||
return { buffer, contentType };
|
||||
} else {
|
||||
console.warn(
|
||||
`Nextcloud preview failed (${ncResponse.status}) for ${fullPath}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
// 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";
|
||||
|
||||
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);
|
||||
}
|
||||
// Return the raw data to be cached
|
||||
return { buffer, contentType };
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
continue; // Try next path variant
|
||||
}
|
||||
}
|
||||
|
||||
// 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";
|
||||
throw lastError || new Error("Asset not found");
|
||||
// --- END OF FETCH LOGIC ---
|
||||
},
|
||||
ttl,
|
||||
);
|
||||
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
"Vary": "Accept-Encoding"
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Error fetching asset from Nextcloud: ${path}`, e);
|
||||
throw error(404, "Asset not found");
|
||||
}
|
||||
// 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");
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user