Compare commits

...

15 Commits

Author SHA1 Message Date
Max Richter
f84704d20b chore: pnpm up 2025-07-20 14:15:44 +02:00
Max Richter
666b618a73 WIP 2025-07-20 13:37:29 +02:00
Max Richter
b32ca7d65e feat: improve some error messages
All checks were successful
Deploy to SFTP Server / build (push) Successful in 4m6s
2025-05-14 19:48:55 +02:00
Max Richter
c74b314b1e feat: cleanup some stuff
All checks were successful
Deploy to SFTP Server / build (push) Successful in 4m14s
2025-05-14 19:42:28 +02:00
Max Richter
50ce8b3ff7 feat: log imagePath when failed to generate Thumbhash 2025-05-14 19:37:24 +02:00
Max Richter
8e293c204d feat: some updates
All checks were successful
Deploy to SFTP Server / build (push) Successful in 17m5s
2025-05-14 19:23:59 +02:00
Max Richter
972c2382f3 chore: pnpm up
Some checks failed
Deploy to SFTP Server / build (push) Failing after 4m53s
2025-05-14 19:06:35 +02:00
Max Richter
e0543f2a58 chore: pnpm up
Some checks failed
Deploy to SFTP Server / build (push) Failing after 4m48s
2025-05-14 19:00:59 +02:00
Max Richter
3d78b9e56c chore: pnpm up 2025-05-14 19:00:59 +02:00
Max Richter
84e56f2668 fix(ci): increase ulimit in ci 2025-05-14 19:00:59 +02:00
Max Richter
8af8db0714 fix: dont try to use icons as img src 2025-05-14 19:00:58 +02:00
59eeadd4b3 feat: add icons to posts and photos to featured posts
All checks were successful
Deploy to SFTP Server / build (push) Successful in 4m33s
2025-02-23 14:31:14 +01:00
6aa6ddabb0 feat: add opentags
All checks were successful
Deploy to SFTP Server / build (push) Successful in 4m0s
2025-02-18 16:23:51 +01:00
19a703367d fix: some errors
All checks were successful
Deploy to SFTP Server / build (push) Successful in 21m8s
2025-02-16 15:53:02 +01:00
aa333eb2cd feat: translate some more posts 2025-02-16 15:50:34 +01:00
39 changed files with 2349 additions and 2356 deletions

View File

@@ -53,7 +53,6 @@ jobs:
- name: 🏗️ Build site
run: |
# Install dependencies, build, and generate site output
pnpm i && pnpm build
- name: 🔑 Configure rclone

View File

@@ -17,13 +17,12 @@ const locales = {
const DEFAULT_LAYOUT = '@layouts/Post.astro';
function setDefaultLayout() {
return function (_, file) {
return function(_, file) {
const { frontmatter } = file.data.astro;
if (!frontmatter.layout) frontmatter.layout = DEFAULT_LAYOUT;
};
}
// https://astro.build/config
export default defineConfig({
site: "https://max-richter.dev",
trailingSlash: "never",
@@ -38,6 +37,13 @@ export default defineConfig({
compiler: 'svelte',
}),
],
server: {
watch: {
// Customize watch behavior to reduce file watchers
ignored: ['**/node_modules/**', '**/dist/**', '**/.git/**'],
usePolling: process.env.NODE_ENV === 'production',
},
},
},
markdown: {
remarkPlugins: [setDefaultLayout]

View File

@@ -11,32 +11,32 @@
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/mdx": "^4.0.8",
"@astrojs/svelte": "^7.0.4",
"@astrojs/tailwind": "^6.0.0",
"astro": "^5.3.0",
"@astrojs/mdx": "^4.3.1",
"@astrojs/svelte": "^7.1.0",
"@astrojs/tailwind": "^6.0.2",
"astro": "^5.12.0",
"astro-i18n-aut": "^0.7.3",
"exifreader": "^4.26.1",
"svelte": "^5.20.1",
"svelte-gestures": "^5.1.3",
"tailwindcss": "^4.0.6",
"exifreader": "^4.31.1",
"svelte": "^5.36.10",
"svelte-gestures": "^5.1.4",
"tailwindcss": "^4.1.11",
"thumbhash": "^0.1.1",
"typescript": "^5.7.3"
"typescript": "^5.8.3"
},
"devDependencies": {
"@astrojs/sitemap": "^3.2.1",
"@iconify-json/tabler": "^1.2.16",
"@astrojs/sitemap": "^3.4.1",
"@iconify-json/tabler": "^1.2.19",
"@types/markdown-it": "^14.1.2",
"@unocss/preset-icons": "^65.5.0",
"@unocss/reset": "^65.5.0",
"astro-font": "^1.0.0",
"@unocss/preset-icons": "^66.3.3",
"@unocss/reset": "^66.3.3",
"astro-font": "^1.1.0",
"markdown-it": "^14.1.0",
"ogl": "^1.0.11",
"prettier": "^3.5.1",
"prettier": "^3.6.2",
"prettier-plugin-astro": "^0.14.1",
"sharp": "^0.33.5",
"unocss": "^65.5.0",
"unplugin-icons": "^22.0.0",
"vite-plugin-glsl": "^1.3.1"
"sharp": "^0.34.3",
"unocss": "^66.3.3",
"unplugin-icons": "^22.1.0",
"vite-plugin-glsl": "^1.5.1"
}
}

2827
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
onlyBuiltDependencies:
- esbuild
- exifreader
- sharp

View File

@@ -31,7 +31,14 @@ const link = translatePath(`/${collection}/${id.split("/")[0]}`);
classes={`grid gradient border-1 border-neutral overflow-hidden ${cover ? "grid-rows-[200px_1fr] xs:grid-rows-none xs:grid-cols-[1fr_200px]" : ""}`}>
<Card.Content classes="px-8 py-7 order-last xs:order-first">
<Card.Title classes="text-4xl flex items-center gap-2">
{icon && <img src={icon} class="h-6 w-6" />}
{
icon &&
(
icon?.length > 5
? <img class="h-6 w-6" src={icon} />
: <span class="p-r-4 text-md">{icon}</span>
)
}
{title}
</Card.Title>
<Card.Description>
@@ -45,7 +52,7 @@ const link = translatePath(`/${collection}/${id.split("/")[0]}`);
<Image
hash
loader={false}
src={cover}
src={cover as ImageMetadata}
alt={"cover for " + title}
class="right-0 h-full object-cover object-center rounded-none border-l border-neutral"
/>

View File

@@ -3,7 +3,7 @@ import type { ImageMetadata } from "astro";
import { Picture as AstroImage } from "astro:assets";
import { generateThumbHash, getExifData } from "@helpers/image";
interface Props {
src: ImageMetadata;
src: ImageMetadata & { fsPath?: string };
alt: string;
pictureClass?: string;
class?: string;
@@ -22,7 +22,7 @@ const {
maxWidth,
} = Astro.props;
let thumbhash = hash ? await generateThumbHash(image) : "";
let thumbhash = hash && image.fsPath ? await generateThumbHash(image) : "";
let exif = await getExifData(image);

View File

@@ -0,0 +1,13 @@
---
interface Props {
title: string;
cover: string;
}
---
<meta property="og:title" content={Astro.props.title} />
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:image" content={Astro.props.cover} />
<meta property="og:description" content="Max Richters personal blog" />
<meta property="og:site_name" content="Max Richter" />

View File

@@ -24,8 +24,8 @@ const paths = [
text: t("nav.photos"),
},
{
link: translatePath("/videos"),
text: t("nav.videos"),
link: translatePath("/resources"),
text: t("nav.resources"),
},
];
---

View File

@@ -21,7 +21,13 @@ const { post } = Astro.props;
<a href={tp(`/${post.collection}/${post.id.split("/")[0]}`)}>
<h2
class="text-2xl flex gap-2 items-center line-clamp text-ellipsis overflow-hidden">
{post.data.icon && <img src={post.data.icon} class="h-6" />}
{
post.data.icon?.length > 3 ? (
<img src={post.data.icon} class="h-6" />
) : post.data.icon?.length ? (
<span>{post.data.icon}</span>
) : null
}
{post.data.title}
</h2>
<p class="text-ellipsis overflow-hidden line-clamp-2">

View File

@@ -2,6 +2,9 @@
title: "Erasmus Valencia"
date: 2022-09-02
cover: ./images/MAX_8218 - MAX_8230.jpg
toc: true
icon: 🍊
tags: ["valencia", "erasmus"]
---
import Image from "@components/Image.astro"

View File

@@ -3,6 +3,8 @@ title: "Erasmus Valencia"
date: 2022-09-02
cover: ./images/MAX_8218 - MAX_8230.jpg
toc: true
icon: 🍊
tags: ["valencia", "erasmus"]
---
import Image from "@components/Image.astro"

View File

@@ -3,7 +3,9 @@ title: Madeira
date: 2025-02-16
license: "CC-BY-SA:4.0"
comments: true
icon: 🏝️
cover: ./images/MAX_0603.jpg
tags: ["madeira", "travel"]
---
import Image from "@components/Image.astro";

View File

@@ -3,7 +3,9 @@ title: Madeira
date: 2025-02-16
license: "CC-BY-SA:4.0"
comments: true
icon: 🏝️
cover: ./images/MAX_0603.jpg
tags: ["madeira", "travel"]
---
import Image from "@components/Image.astro";

View File

@@ -3,6 +3,8 @@ title: "Peaks of the Balkans"
date: 2024-06-19
cover: ./images/MAX_9861.jpg
license: "CC-BY-SA:4.0"
icon: 🏔️
tags: ["balkans", "travel"]
comments: true
---

View File

@@ -3,6 +3,8 @@ title: "Peaks of the Balkans"
date: 2024-06-19
cover: ./images/MAX_9861.jpg
license: "CC-BY-SA:4.0"
icon: 🏔️
tags: ["balkans", "hiking", "travel"]
comments: true
---

View File

@@ -0,0 +1,63 @@
---
title: "Invoice"
date: 2023-08-21
cover: ./images/bg.jpg
icon: "/projects/invoice.svg"
tags: ["sveltekit", "unocss", "prisma", "sqlite"]
toc: true
links:
[
["live", "https://invoice.app.max-richter.dev"],
["git", "https://git.max-richter.dev/max/invoice"],
]
---
import bg from './images/bg.jpg'
import invoices from './images/invoices.png'
import customers from './images/customers.png'
import editCustomers from './images/edit-customer.png'
import editProfile from './images/edit-profile.png'
import overview from './images/overview.png'
import ImageSlider from '@components/ImageSlider.svelte'
import Image from '@components/Image.astro'
# Introduction
In my free time, I like to take on small jobs, such as deliveries, setups, and pickups for others.
An unavoidable part of these tasks is creating invoices in PDF format. Initially, I followed a manual process and created my first invoices in Figma. But as programmers often say:
> Why do something manually in 5 minutes when I can automate it in 24 hours?
This thought led to my latest hobby project **"Invoice."**
<ImageSlider title="Invoice Screens" client:load>
<Image src={invoices} alt="Invoices" />
<Image src={customers} alt="Customers" />
<Image src={editCustomers} alt="Edit Customers" />
<Image src={editProfile} alt="Edit Profile" />
<Image src={overview} alt="Overview" />
</ImageSlider>
# Development
During development, I always kept the principle 'K.I.S.S.' in mind: Keep it simple, stupid. For this project, that meant choosing "boring" but well-known technologies:
## [🚀 SvelteKit](https://kit.svelte.dev)
For an efficient and reactive user interface.
## [🎨 UNOcss](https://unocss.dev/)
The faster alternative to Tailwind.
## [🌍 TypesafeI18n](https://github.com/ivanhofer/typesafe-i18n)
To enable multilingual support without complex logic.
## [🛠️ Prisma](https://prisma.io)
As a database access layer for smooth data management.
## [🗃️ SQLite](https://www.sqlite.org/index.html)
As a reliable backend, ideal for small projects.
## [📄 Playwright](https://playwright.dev)
For generating PDFs.

View File

@@ -0,0 +1,129 @@
---
title: "K.A.R.L"
date: 2021-04-01
cover: ./images/Indicatrices_of_Distortion.png
license: "CC-BY-SA:4.0"
featured: true
toc: true
icon: /projects/karl/favicon.png
links:
[
["live", "https://max-richter.dev/karl"],
["git", "https://git.max-richter.dev/max/karl"],
]
---
import Crosswalk from "./images/crosswalk.jpg"
import CrosswalkMask from "./images/crosswalk_mask.png"
import Image from "@components/Image.astro"
import Distorion from "./images/Indicatrices_of_Distortion.png"
import ImageGallery from "@components/ImageGallery.svelte"
<ImageGallery client:load/>
*[Header by Justin Kunimune - Own work, CC BY-SA 4.0](https://commons.wikimedia.org/w/index.php?curid=66467577*)*
> K.A.R.L is a web app that helps to divide 360-degree panoramas into sections (sky, ground, trees, etc.) and then determine the proportion of each section in the overall image.
# Introduction
The project emerged from a collaboration with two friends. One of them is in the conceptual phase of his bachelor's thesis (Geography), which deals with the impact of vegetation on urban climate. To this end, he conducted albedo measurements at various locations in Cologne—essentially measuring "how much light comes from the sky, and how much of it is reflected by the ground." To put these measurements into context, he took 360-degree panoramas at each measurement location, which look something like this:
<Image src={Crosswalk} alt="Image of a crosswalk" caption="Image from hdrihaven.com" />
He then needed data on what percentage of the view was occupied by vegetation, sky, and ground. To measure this, he manually created a segmentation map in Gimp, which looks something like this:
<Image src={CrosswalkMask} alt="Segmentation map" />
# Problem Statement
If we naively count the pixels of each color and use that to calculate a percentage distribution, we encounter the classic distortion problem that humanity has faced for centuries with maps. Spheres do not like being represented in two dimensions, which always leads to distortions, as visualized in the following image.
<Image src={Distorion} alt="Indicatrices of Distortion" caption="by Justin Kunimune - Own work, CC BY-SA 4.0" />
Fortunately, this distortion only occurs in width, so we need a formula that provides a weight for the height of a pixel to compensate for this distortion. After many attempts, we arrived at this formula:
```javascript
/*
height: height of the image in pixels
calibrationFactor: 1.333, somehow this works, don't ask why, we don't either
y: y position of the pixel
*/
const pixelValue = Math.cos(
(((360 / height ** 2) * y ** 2 + (-360 / height) * y + 90) / 360) *
(2 + calibrationFactor) *
Math.PI
)
```
This formula is actually designed to calculate the distance between two longitudes for a given latitude, but it works very well for our purposes.
Here are some of the first attempts in Desmos (fantastic tool, by the way):
<iframe src="https://www.desmos.com/calculator/52ph4thjah?embed"/>
# Technologies
I wanted to build a web app that theoretically works completely offline. Therefore, after the initial page load, no further data is sent; everything runs in the browser. This is challenging for an application that processes many pixels and requires a lot of performance since browser resources are limited. The following technologies helped to keep the user experience reasonably smooth:
## Canvas
When working with pixels in the browser, you can't really avoid Canvas2D. Its programmatic interface makes it ideal for this job.
## Web Workers
Normally, all operations of a website run in a single thread in the browser. This means that a computationally intensive task can bring the entire site to a halt. Web Workers provide a solution by allowing arbitrary code to run in a separate thread, though communication with the main thread can be tricky.
K.A.R.L uses two Web Workers: the `pixel-worker` handles segmentation map analysis and the fill tool, while the `ai-worker` executes TensorFlow code.
## TensorFlow
Why manually color segments when the computer can do it automatically? That was my thought, so I integrated TensorFlow with the [ADE20K](https://groups.csail.mit.edu/vision/datasets/ADE20K/) dataset. This network is excellent at recognizing trees, ground, concrete, and sky. In the editor view, this function is hidden under the "AI" button on the right.
## IndexDB
When it comes to storing large amounts of data (especially images) locally in the browser, IndexedDB is the best option *(yes, localStorage with Base64 images could work too, but it's limited to 5MB in some browsers)*. However, the IndexedDB API is one of the [most confusing browser APIs out there](https://nolanlawson.github.io/offlinefirst-2016-03/#/27), so I use [idb](npmjs.com/package/idb), a fantastic small wrapper library for IndexedDB that also supports Promises.
# Interesting Features...
## Flood Fill Algorithm
After manually segmenting several panoramas using the tools, I noticed that I often just painted regions with similar colors. This gave me the idea to build a fill tool similar to Photoshop, but much better.
The tool works roughly as follows:
1. The user clicks on the image with the fill tool.
2. The x/y coordinates of the click and all pixels of the image are sent to a Web Worker.
3. The Web Worker creates a grayscale image where each pixel value represents the spatial and color distance to the clicked region.
4. The image is sent back.
5. The canvas code then determines whether individual pixels should be filled based on their values.
## Svelte Bindings
I always found it complicated to manage state across multiple components. A good example is the editor, which consists of three separate components (Toolbar, Topbar, PaintArea), all of which need to know which color and tool are currently active. One could store this state globally and let each component access it, but in reality, the active tool is only relevant in the editor context. So now, this state resides in the editor component and is passed down to its subcomponents via binding. It looks something like this:
```svelte
<!-- Editor.svelte -->
<script lang="ts">
import TopBar from "./TopBar.svelte";
let activeColor = "ff0000";
let _activeColor = localStorage.getItem("activeColor");
if (_activeColor) {
activeColor = _activeColor;
}
</script>
<TopBar bind:activeColor/>
<!-- TopBar.svelte -->
<script>
export let activeColor = "ff0000";
</script>
{{activeColor}}
```
## Svelte Stores
For some other cases, stores work really well, such as for toasts/modals. This allows the toast state (the small messages at the bottom left) to be managed in a single component without having to distribute it across multiple components.

View File

@@ -1,18 +1,133 @@
---
title: "Plantarium"
date: 2022-08-31
date: 2022-08-31T20:46:26+02:00
cover: ./images/plantarium.png
featured: true
links: [["website", "https://plant.max-richter.com"], ["git", "https://github.com/jim-fx/plantarium"]]
draft: true
links: [["website", "https://plant.max-richter.dev"], ["git", "https://github.com/jim-fx/plantarium"]]
tags: ["Web", "3D", "Svelte", "Node Systems"]
icon: /projects/plantarium/favicon.svg
draft: false
toc: true
---
One of the projects I spent the most time with. As a Waldorf student and village kid, a bit cliché, I know, but I've always been passionate about plants, their structure and aesthetics. I'm also an absolute [Blender](https://blender.org) nerd and from these two interests Plantarium was born.
# Introduction
Plantarium is a tool that allows users to generate procedural plants. The first prototype was finished within two weeks of intensive work and looked something like this:
Plantarium is the intersection of two things I find fascinating: plants and 3D modeling.
It is a web app that allows users to create and export plants.
Users define the plants appearance through a node system, and Plantarium generates a 3D model from it.
<img src="images/page01-0.jpg"/>
<Image src={plantarium_screenshot} alt="Plantarium Screenshot"/>
# The Beginnings and Challenges
The interface was divided into 4 different levels, "Stems", "Branches", "Leaves" and "Import/Export". The idea was not bad because parts of the interface belonged to parts of the plant and you could quickly find the right settings if you wanted to change something. This way of designing the interface limits the freedom of the user(s) to generate the plants they want. Also, the interface code was unattractive, to say the least. It worked but it was not fun to work on. Also, I was mixing UI code with code that generated the plant.
The first prototype was ready within two weeks of intense work and looked something like this:
import ImageSlider from "@components/ImageSlider.svelte"
import Leaves from "./_components/Leaves.svelte"
import Image from "@components/Image.astro"
import page01_0 from "./images/page01-0.jpg"
import page01_1 from "./images/page01-1.jpg"
import page01_2 from "./images/page01-2.jpg"
import page01_3 from "./images/page01-3.jpg"
import page01_5 from "./images/page01-5.jpg"
import page01_6 from "./images/page01-6.jpg"
import screenshot_geometry_nodes from "./images/screenshot-geometry-nodes.jpg"
import screenshot_houdini from "./images/screenshot-houdini.jpg"
import screenshot_unreal from "./images/screenshot-unreal.jpg"
import screenshot_davinci from "./images/screenshot-davinci.jpg"
import plantarium_screenshot from "./images/plantarium-screenshot.png"
import Plantarium from "./images/plantarium.png"
import ImageGallery from "@components/ImageGallery.svelte"
<ImageGallery client:load/>
<Leaves client:load/>
<ImageSlider title="First Prototype" client:load>
<Image src={page01_0} alt="Stem Page"/>
<Image src={page01_1} alt="Branches Page"/>
<Image src={page01_2} alt="Leaves Page"/>
<Image src={page01_3} alt="Import/Export Page"/>
<Image src={page01_5} alt="Design of UI Components"/>
<Image src={page01_6} alt="Data Flow inside App"/>
</ImageSlider>
Not bad, but as with prototypes, there was still a lot to improve.
So heres a brief history of the biggest changes so far:
# Refactors
## Svelte Rewrite
The first prototype was handwritten, untested JavaScript.
Thats like trying to build a car out of toothpicks and duct tape.
You might get it to work, and you might even be proud of your creation,
but if you revisit it a week later—or worse, try to improve it—it quickly becomes a nightmare.
So, it was time for every web developers favorite activity:
figuring out whats currently the hottest thing in web development.
Once you pick one of the thousands of possible technologies,
you make it your personal religion and defend it with fanatical devotion.
Easy, right?
So, I went with the simplest, fastest, and overall best framework: ["Svelte"](https://svelte.dev).
In essence, frameworks help turn an apps data (e.g., usernames, login status) into HTML and CSS,
which the browser then displays.
### Node Systems
The interface was divided into four different sections: "Stem," "Branches," "Leaves," and "Import/Export."
The idea wasnt bad since each part of the plant had its own view,
making it easy to find the right settings when making changes.
However, this structure severely limited users.
You could only build plants following the Stem → Branch → Leaf model.
But what if you wanted multiple layers of branches or different leaves for stems and branches?
This model didnt allow for that.
So, as they say:
"Back to the drawing board."
I already had a solution in mind,
but I wasnt sure if I had the skills to implement it.
The solution? **Node Systems!**
Hmm, whats that?
<div class="center my-4">
![Node Systems](./images/nodes.svg)
</div>
Node systems consist of nodes and connections.
Nodes have inputs and outputs, which can be connected.
In the example graphic, we have two `input-color` nodes that each produce a color,
and a `mix-color` node that blends them.
The cool thing is that this system is highly flexible.
You can program nodes like `generate-stem`, `generate-branches`, or `add-leaves`.
<ImageSlider title="Examples of Node Systems" client:load>
<Image src={screenshot_geometry_nodes} alt="Blender uses nodes to create geometry"/>
<Image src={screenshot_houdini} alt="Houdini uses nodes for VFX/simulations"/>
<Image src={screenshot_unreal} alt="Unreal uses nodes for game logic"/>
<Image src={screenshot_davinci} alt="DaVinci uses nodes for VFX"/>
</ImageSlider>
### Svelte-Kit Rewrite
# Architecture
After experimenting with different approaches, I settled on an event-based architecture.
This fits well with node systems since individual components can communicate via events.
<div class="center my-4">
![Architecture](./images/architecture.svg)
</div>
# Design
# Future
# Conclusion
Currently, the architecture looks something like this:

View File

@@ -1,4 +1,4 @@
---
title: Videos
title: Resources
menu: nav
---

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ async function getSharp(): Promise<typeof import("sharp") | undefined> {
return s;
}
export async function generateThumbHash(image: { width: number, height: number }) {
export async function generateThumbHash(image: ImageMetadata & { fsPath?: string }) {
const sharp = await getSharp();
if (!sharp) return;
@@ -19,7 +19,7 @@ export async function generateThumbHash(image: { width: number, height: number }
const smallWidth = Math.floor(image.width * scaleFactor);
const smallHeight = Math.floor(image.height * scaleFactor);
//@ts-ignore
try {
const smallImg = await sharp(image.fsPath)
.resize(smallWidth, smallHeight)
.withMetadata()
@@ -29,6 +29,11 @@ export async function generateThumbHash(image: { width: number, height: number }
const buffer = rgbaToThumbHash(smallWidth, smallHeight, smallImg);
return Buffer.from(buffer).toString("base64");
} catch (error) {
console.log(`Could not generate thumbhash for ${image.fsPath}`, error)
return ""
}
}
const allowedExif = [
@@ -48,10 +53,15 @@ const allowedExif = [
];
export async function getExifData(image: ImageMetadata) {
if (image.format === "svg") return undefined; // SVGs don't have EXIF data")
const sharp = await getSharp();
if (!sharp) return;
const imagePath = (image as ImageMetadata & { fsPath: string }).fsPath;
try {
const tags = await ExifReader.load((image as ImageMetadata & { fsPath: string }).fsPath, { async: true });
const buffer = await sharp(imagePath).toBuffer();
const tags = await ExifReader.load(buffer, { async: true });
const out: Record<string, any> = {};
let hasExif = false;
@@ -65,7 +75,7 @@ export async function getExifData(image: ImageMetadata) {
return hasExif ? out : undefined;
} catch (error) {
console.log("Error reading EXIF data", error);
console.log(`Error reading EXIF data from ${imagePath}`, error);
return undefined
}

10
src/helpers/memorium.ts Normal file
View File

@@ -0,0 +1,10 @@
export async function listResource(id: string): Promise<any[]> {
try {
const response = await fetch(
`http://localhost:8080/resources?name=${id}`,
);
return await response.json();
} catch (error) {
return []
}
}

View File

@@ -20,7 +20,7 @@ export const ui = {
'home.subtitle': 'Trained Media Designer, Blender Nerd, Developer and Hardware Tinkerer.',
'nav.blog': 'Blog',
'nav.projects': 'Projects',
'nav.videos': 'Videos',
'nav.resources': 'Resources',
'nav.photos': 'Photos',
'toc.title': 'Table of Contents',
"resume": "Resume",
@@ -38,7 +38,7 @@ export const ui = {
'home.subtitle': 'Ausgebildeter Mediengestalter, Blender Nerd, Entwickler und Hardware Bastler.',
'nav.blog': 'Blog',
'nav.projects': 'Projekte',
'nav.videos': 'Videos',
'nav.resources': 'Resources',
'nav.photos': 'Fotos',
"resume": "Lebenslauf",
'toc.title': 'Inhaltsverzeichnis',

View File

@@ -6,10 +6,12 @@ import { useTranslations } from "@i18n/utils";
interface Props {
title: string;
width?: "full" | "compact";
image?: string;
}
const t = useTranslations(Astro.url);
const { title } = Astro.props;
import "./theme.css";
import "./global.css";
---
@@ -21,8 +23,13 @@ import "./global.css";
<meta name="description" content="Astro description" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="props" content={JSON.stringify(Astro.props)} />
<link rel="sitemap" href="/sitemap-index.xml" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script defer src="https://umami.max-richter.dev/script.js" data-website-id="bf418035-fd77-48c6-bc0c-f7f5e2bc6522"></script>
<script
is:inline
defer
src="https://umami.max-richter.dev/script.js"
data-website-id="bf418035-fd77-48c6-bc0c-f7f5e2bc6522"></script>
<AstroFont
config={[
{
@@ -56,7 +63,7 @@ import "./global.css";
]}
/>
<meta name="generator" content={Astro.generator} />
<!-- <meta http-equiv="refresh" content="0;url=/" /> -->
<slot name="meta-tags" />
<title>{title}</title>
<script is:inline>
(function () {

View File

@@ -9,20 +9,20 @@ type Props = MarkdownLayoutProps<{
date: Date;
links?: string[][];
toc?: boolean;
cover?: string;
}>;
const { frontmatter, headings } = Astro.props;
const t = useTranslations(Astro.url);
const { title, url, date: dateString, links, toc } = frontmatter;
const { title, url, date: dateString, links, toc, cover } = frontmatter;
const collection = url?.split("/")[2];
const date = new Date(dateString);
const path = useTranslatedPath(Astro.url);
//@ts-ignore
const backlinkContent = t(`nav.${collection}`).toLowerCase();
const date = new Date(dateString);
const path = useTranslatedPath(Astro.url);
---
<Layout title={title}>
<Layout title={title} image={cover}>
<div class="top-info flex items-center place-content-between m-y-2">
<a class="flex items-center gap-1 opacity-50" href={path("/" + collection)}
><span class="i-tabler-arrow-left"></span>

View File

@@ -2,6 +2,7 @@
import { getCollection, render } from "astro:content";
import { getLocale } from "astro-i18n-aut";
import { filterCollection, parseSlug } from "@i18n/utils";
import MetaTags from "@components/MetaTags.astro";
const locale = getLocale(Astro.url);
@@ -31,4 +32,5 @@ if (!page) {
const { Content } = await render(page);
---
<MetaTags title={page.data.title} cover={page.data.cover?.src} />
<Content />

View File

@@ -33,9 +33,17 @@ const posts = filterCollection(
await getCollection("blog"),
getLocale(Astro.url),
);
const photos = filterCollection(
await getCollection("photos"),
getLocale(Astro.url),
);
const list = [...posts, ...photos];
list.sort((a, b) => {
return a.data.date > b.data.date ? -1 : 1;
});
const featuredPost = posts.find((post) => post.data?.featured);
const otherPosts = posts.filter((post) => featuredPost !== post).slice(0, 3);
const featuredPost = list.find((post) => post.data?.featured);
const otherPosts = list.filter((post) => featuredPost !== post).slice(0, 3);
---
<Layout title="Max Richter">

View File

@@ -2,6 +2,7 @@
import { getCollection, render } from "astro:content";
import { getLocale } from "astro-i18n-aut";
import { filterCollection, parseSlug } from "@i18n/utils";
import MetaTags from "@components/MetaTags.astro";
const locale = getLocale(Astro.url);
@@ -19,7 +20,6 @@ export async function getStaticPaths() {
const pages = await getCollection("photos");
const page = filterCollection(pages, locale).find((page) => {
const [slug] = parseSlug(page.id);
console.log({ slug, id: page.id, params: Astro.params.slug, locale });
return slug === Astro.params.slug;
});
@@ -32,4 +32,5 @@ if (!page) {
const { Content } = await render(page);
---
<MetaTags title={page.data.title} cover={page.data.cover?.src} />
<Content />

View File

@@ -2,6 +2,7 @@
import { getCollection, render } from "astro:content";
import { getLocale } from "astro-i18n-aut";
import { filterCollection, parseSlug } from "@i18n/utils";
import MetaTags from "@components/MetaTags.astro";
const locale = getLocale(Astro.url);
@@ -31,4 +32,5 @@ if (!page) {
const { Content } = await render(page);
---
<MetaTags title={page.data.title} cover={page.data.cover?.src} />
<Content />

View File

@@ -0,0 +1,64 @@
---
import Layout from "@layouts/Layout.astro";
import HeroCard from "@components/HeroCard.astro";
const collection = "resources";
const wiki = {
id: "wiki",
collection,
body: "My knowledge base",
data: {
title: "Wiki",
icon: "🧠",
},
};
const articles = {
id: "articles",
collection,
body: "Articles saved",
data: {
title: "Articles",
icon: "📰",
},
};
const recipes = {
id: "recipes",
collection,
body: "Recipes",
data: {
title: "Recipes",
icon: "🍲",
},
};
const movies = {
id: "movies",
collection,
body: "Movies",
data: {
title: "Movies",
icon: "🎥",
},
};
const series = {
id: "series",
collection,
body: "Series",
data: {
title: "Series",
icon: "📺",
},
};
---
<Layout title="Max Richter">
<HeroCard post={wiki} />
<HeroCard post={recipes} />
<HeroCard post={articles} />
<HeroCard post={movies} />
<HeroCard post={series} />
</Layout>

View File

@@ -0,0 +1,38 @@
---
import Layout from "@layouts/Layout.astro";
import * as memorium from "@helpers/memorium";
export async function getStaticPaths() {
const movieReviews = await memorium.listResource("Media/movies/*");
const paths = movieReviews.map((review: any) => {
return {
params: {
movieName: review.identifier
.replace("Media/movies/", "")
.replace(/\.md$/, ""),
},
};
});
return paths;
}
const reviews = await memorium.listResource(
//@ts-ignore
`Media/movies/${Astro.params.movieName}.md`,
);
if (reviews.length === 0) {
return new Response(null, {
status: 404,
statusText: "Not found",
});
}
const review = reviews[0];
---
<Layout title="Max Richter">
<h1>{review.itemReviewed?.name}</h1>
<p>{review.reviewBody}</p>
<!-- <pre><code>{JSON.stringify(review, null, 2)}</code></pre> -->
</Layout>

View File

@@ -0,0 +1,27 @@
---
import Layout from "@layouts/Layout.astro";
import HeroCard from "@components/HeroCard.astro";
import * as memorium from "@helpers/memorium";
const movieReviews = await memorium.listResource("Media/movies/*");
---
<Layout title="Max Richter">
{
movieReviews.map((review: any) => (
<HeroCard
post={{
collection: "resources/movies",
id: review.identifier
.replace("Media/movies/", "")
.replace(/\.md$/, ""),
data: {
title: review.itemReviewed.name,
description: review.reviewBody,
},
body: review.reviewBody,
}}
/>
))
}
</Layout>

View File

@@ -0,0 +1,71 @@
---
import Layout from "@layouts/Layout.astro";
import { useTranslatedPath } from "@i18n/utils";
import markdownToText from "@helpers/markdownToText";
import * as memorium from "@helpers/memorium";
const path = useTranslatedPath(Astro.url);
const collection = "resources/recipes";
export async function getStaticPaths() {
const recipes = await memorium.listResource("Recipes/*");
const paths = recipes.map((recipe: any) => {
return {
params: {
recipeName: recipe.identifier
.replace("Recipes/", "")
.replace(/\.md$/, ""),
},
};
});
return paths;
}
const recipes = await memorium.listResource(
//@ts-ignore
`Recipes/${Astro.params.recipeName}.md`,
);
if (recipes.length === 0) {
return new Response(null, {
status: 404,
statusText: "Not found",
});
}
const recipe = recipes[0];
---
<Layout title="Max Richter">
<div class="top-info flex items-center place-content-between m-y-2">
<a class="flex items-center gap-1 opacity-50" href={path("/" + collection)}>
<span class="i-tabler-arrow-left"></span> back
</a>
<div class="date opacity-50">
{
recipe.date?.toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
}
</div>
</div>
<h1>{recipe.name}</h1>
<h3>Ingredients</h3>
<ol>
{
recipe.recipeIngredient?.map((ingredient: any) => (
<li>{markdownToText(ingredient)}</li>
))
}
</ol>
<h3>Instructions</h3>
<p>{recipe.recipeInstructions}</p>
</Layout>

View File

@@ -0,0 +1,23 @@
---
import Layout from "@layouts/Layout.astro";
import HeroCard from "@components/HeroCard.astro";
import * as memorium from "@helpers/memorium";
const recipes = await memorium.listResource("Recipes/*");
---
<Layout title="Max Richter">
{
recipes.map((recipe: any) => (
<HeroCard
post={{
collection: "resources/recipes",
id: recipe.identifier.replace("Recipes/", "").replace(/\.md$/, ""),
data: {
title: recipe.name,
},
}}
/>
))
}
</Layout>

View File

@@ -0,0 +1,58 @@
---
import Layout from "@layouts/Layout.astro";
import { useTranslatedPath } from "@i18n/utils";
import * as memorium from "@helpers/memorium";
const collection = "resources/series";
const path = useTranslatedPath(Astro.url);
export async function getStaticPaths() {
const seriesReviews = await memorium.listResource("Media/series/*");
const paths = seriesReviews.map((review: any) => {
return {
params: {
seriesName: review.identifier
.replace("Media/series/", "")
.replace(/\.md$/, ""),
},
};
});
return paths;
}
const reviews = await memorium.listResource(
//@ts-ignore
`Media/series/${Astro.params.seriesName}.md`,
);
if (reviews.length === 0) {
return new Response(null, {
status: 404,
statusText: "Not found",
});
}
const review = reviews[0];
---
<Layout title="Max Richter">
<div class="top-info flex items-center place-content-between m-y-2">
<a class="flex items-center gap-1 opacity-50" href={path("/" + collection)}>
<span class="i-tabler-arrow-left"></span> back
</a>
<div class="date opacity-50">
{
review.date?.toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
}
</div>
</div>
<h1>{review.itemReviewed?.name}</h1>
<p>{review.reviewBody}</p>
</Layout>

View File

@@ -0,0 +1,27 @@
---
import Layout from "@layouts/Layout.astro";
import HeroCard from "@components/HeroCard.astro";
import * as memorium from "@helpers/memorium";
const seriesReviewes = await memorium.listResource("Media/series/*");
---
<Layout title="Max Richter">
{
seriesReviewes.map((review: any) => (
<HeroCard
post={{
collection: "resources/series",
id: review.identifier
.replace("Media/series/", "")
.replace(/\.md$/, ""),
data: {
title: review.itemReviewed.name,
description: review.reviewBody,
},
body: review.reviewBody,
}}
/>
))
}
</Layout>

13
src/pages/robots.txt.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro';
const getRobotsTxt = (sitemapURL: URL) => `
User-agent: *
Allow: /
Sitemap: ${sitemapURL.href}
`;
export const GET: APIRoute = ({ site }) => {
const sitemapURL = new URL('sitemap-index.xml', site);
return new Response(getRobotsTxt(sitemapURL));
};

View File

@@ -1,10 +1,10 @@
// uno.config.ts
import { defineConfig, presetUno } from 'unocss'
import { defineConfig, presetWind3 } from 'unocss'
import presetIcons from '@unocss/preset-icons'
export default defineConfig({
presets: [
presetUno(),
presetWind3(),
presetIcons(),
],
shortcuts: {