Compare commits

...

22 Commits

Author SHA1 Message Date
Max Richter
ae266dbdc5 feat: display articles
Some checks failed
Deploy to SFTP Server / build (push) Failing after 1m38s
2025-10-22 17:17:34 +02:00
Max Richter
3a120e32fc fix: show date on herocard closes #2 2025-10-22 16:39:23 +02:00
Max Richter
d9a2f63865 feat: show recipe image on page
Some checks failed
Deploy to SFTP Server / build (push) Failing after 1m31s
2025-10-22 16:24:04 +02:00
Max Richter
24a66940e9 fix: images in remotes
All checks were successful
Deploy to SFTP Server / build (push) Successful in 9m22s
2025-10-22 16:00:21 +02:00
Max Richter
2446629515 chore: remove .zone files
Some checks failed
Deploy to SFTP Server / build (push) Has been cancelled
2025-10-22 16:00:03 +02:00
Max Richter
7048db9d76 fix: make image loading more robust
All checks were successful
Deploy to SFTP Server / build (push) Successful in 6m38s
2025-10-22 14:30:49 +02:00
Max Richter
0db489269b fix: image loading stuff
Some checks failed
Deploy to SFTP Server / build (push) Failing after 3m50s
2025-10-22 14:20:34 +02:00
Max Richter
1c3f136f57 fix: make some changes
All checks were successful
Deploy to SFTP Server / build (push) Successful in 17m9s
2025-10-22 13:48:16 +02:00
Max Richter
038e78f27d fix: handle empty recipeInstructions
Some checks failed
Deploy to SFTP Server / build (push) Failing after 3m4s
2025-10-22 13:42:27 +02:00
Max Richter
93c00e1c7e fix: handle of markdownTextTs receives null
Some checks failed
Deploy to SFTP Server / build (push) Failing after 3m7s
2025-10-22 13:37:29 +02:00
Max Richter
c914ee6719 fix: add some debug logs to git lfs pull
Some checks failed
Deploy to SFTP Server / build (push) Failing after 4m21s
2025-10-22 13:31:43 +02:00
Max Richter
9ca190550d fix: add some debug logs to git lfs pull
Some checks are pending
Deploy to SFTP Server / build (push) Waiting to run
2025-10-22 13:17:58 +02:00
Max Richter
edecf0bf75 fix: some stuff
Some checks failed
Deploy to SFTP Server / build (push) Has been cancelled
2025-10-22 13:14:42 +02:00
Max Richter
a27e9046c0 fix: some stuff
Some checks failed
Deploy to SFTP Server / build (push) Has been cancelled
2025-10-22 12:58:18 +02:00
Max Richter
5ba54fee6e fix: make some changes
Some checks failed
Deploy to SFTP Server / build (push) Failing after 8m2s
2025-10-21 19:38:33 +02:00
Max Richter
06e5126fe0 refactor: use more generic resource system
Some checks failed
Deploy to SFTP Server / build (push) Failing after 7m2s
2025-10-06 00:30:44 +02:00
Max Richter
61251e2c85 update
Some checks failed
Deploy to SFTP Server / build (push) Has been cancelled
2025-10-04 14:07:46 +02:00
Max Richter
a1b8eb22e5 fix: some type errors
Some checks failed
Deploy to SFTP Server / build (push) Failing after 6m48s
2025-10-04 13:39:06 +02:00
Max Richter
246fc3ae44 feat: add some stuff
Some checks failed
Deploy to SFTP Server / build (push) Failing after 7m38s
2025-10-04 13:19:19 +02:00
Max Richter
48f451ceb0 Merge branch 'feat/memorium-go' 2025-10-04 13:07:45 +02:00
Max Richter
fba2337b9c feat: load data from marka 2025-10-04 13:07:11 +02:00
546b36f44f Merge pull request 'feat/memorium-go' (#3) from feat/memorium-go into main
All checks were successful
Deploy to SFTP Server / build (push) Successful in 26m2s
Reviewed-on: max/website#3
2025-07-20 14:17:06 +02:00
57 changed files with 1781 additions and 1852 deletions

View File

@@ -1,12 +1,12 @@
import { defineConfig } from 'astro/config';
import { i18n, filterSitemapByDefaultLocale } from "astro-i18n-aut/integration";
import { defineConfig } from "astro/config";
import { filterSitemapByDefaultLocale, i18n } from "astro-i18n-aut/integration";
import sitemap from "@astrojs/sitemap";
import Icons from 'unplugin-icons/vite'
import mdx from '@astrojs/mdx';
import glsl from 'vite-plugin-glsl';
import Icons from "unplugin-icons/vite";
import mdx from "@astrojs/mdx";
import glsl from "vite-plugin-glsl";
import svelte from "@astrojs/svelte";
import UnoCSS from 'unocss/astro'
import UnoCSS from "unocss/astro";
const defaultLocale = "de";
const locales = {
@@ -14,7 +14,7 @@ const locales = {
de: "de",
};
const DEFAULT_LAYOUT = '@layouts/Post.astro';
const DEFAULT_LAYOUT = "@layouts/Post.astro";
function setDefaultLayout() {
return function(_, file) {
@@ -27,6 +27,9 @@ export default defineConfig({
site: "https://max-richter.dev",
trailingSlash: "never",
prefetch: true,
image: {
remotePatterns: [{ protocol: "https" }],
},
build: {
format: "file",
},
@@ -34,30 +37,30 @@ export default defineConfig({
plugins: [
glsl(),
Icons({
compiler: 'svelte',
compiler: "svelte",
}),
],
server: {
watch: {
// Customize watch behavior to reduce file watchers
ignored: ['**/node_modules/**', '**/dist/**', '**/.git/**'],
usePolling: process.env.NODE_ENV === 'production',
ignored: ["**/node_modules/**", "**/dist/**", "**/.git/**"],
usePolling: process.env.NODE_ENV === "production",
},
},
},
markdown: {
remarkPlugins: [setDefaultLayout]
remarkPlugins: [setDefaultLayout],
},
integrations: [
i18n({
exclude: ["pages/**/*.json.ts", "pages/api/**/*",],
exclude: ["pages/**/*.json.ts", "pages/api/**/*"],
locales,
defaultLocale,
}),
mdx(),
svelte(),
UnoCSS({
injectReset: true
injectReset: true,
}),
sitemap({
i18n: {
@@ -66,5 +69,5 @@ export default defineConfig({
},
filter: filterSitemapByDefaultLocale({ defaultLocale }),
}),
]
],
});

View File

@@ -10,33 +10,33 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/mdx": "^4.3.1",
"@astrojs/svelte": "^7.1.0",
"@astrojs/check": "^0.9.5",
"@astrojs/mdx": "^4.3.7",
"@astrojs/svelte": "^7.2.0",
"@astrojs/tailwind": "^6.0.2",
"astro": "^5.12.0",
"astro": "^5.14.8",
"astro-i18n-aut": "^0.7.3",
"exifreader": "^4.31.1",
"svelte": "^5.36.10",
"svelte-gestures": "^5.1.4",
"tailwindcss": "^4.1.11",
"exifreader": "^4.32.0",
"svelte": "^5.39.8",
"svelte-gestures": "^5.2.2",
"tailwindcss": "^4.1.14",
"thumbhash": "^0.1.1",
"typescript": "^5.8.3"
"typescript": "^5.9.3"
},
"devDependencies": {
"@astrojs/sitemap": "^3.4.1",
"@iconify-json/tabler": "^1.2.19",
"@astrojs/sitemap": "^3.6.0",
"@iconify-json/tabler": "^1.2.23",
"@types/markdown-it": "^14.1.2",
"@unocss/preset-icons": "^66.3.3",
"@unocss/reset": "^66.3.3",
"@unocss/preset-icons": "^66.5.2",
"@unocss/reset": "^66.5.2",
"astro-font": "^1.1.0",
"markdown-it": "^14.1.0",
"ogl": "^1.0.11",
"prettier": "^3.6.2",
"prettier-plugin-astro": "^0.14.1",
"sharp": "^0.34.3",
"unocss": "^66.3.3",
"unplugin-icons": "^22.1.0",
"vite-plugin-glsl": "^1.5.1"
"sharp": "^0.34.4",
"unocss": "^66.5.2",
"unplugin-icons": "^22.4.2",
"vite-plugin-glsl": "^1.5.4"
}
}

2556
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
---
import markdownToText from "@helpers/markdownToText";
import { markdownToText,readDuration } from "@helpers/markdown";
import { Card } from "./card";
import { useTranslatedPath, useTranslations } from "@i18n/utils";
import Image from "@components/Image.astro";
@@ -15,7 +15,7 @@ interface Props {
}
const {
data: { title, cover, icon },
data: { title, cover, icon, date },
collection,
body,
id,
@@ -30,14 +30,15 @@ const link = translatePath(`/${collection}/${id.split("/")[0]}`);
<Card
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">
{(date || body)&& <Card.Metadata date={date} readDuration={readDuration(body)} />}
<Card.Title classes="text-4xl flex items-center gap-2">
{
icon &&
(
icon?.length > 5
? <img class="h-6 w-6" src={icon} />
: <span class="p-r-4 text-md">{icon}</span>
)
icon &&
(icon?.length > 5 ? (
<img class="h-6 w-6" src={icon} />
) : (
<span class="p-r-4 text-md">{icon}</span>
))
}
{title}
</Card.Title>

View File

@@ -1,7 +1,9 @@
---
import type { ImageMetadata } from "astro";
import { Picture as AstroImage } from "astro:assets";
import { inferRemoteSize } from 'astro/assets/utils';
import { generateThumbHash, getExifData } from "@helpers/image";
import sharp from "sharp";
interface Props {
src: ImageMetadata & { fsPath?: string };
alt: string;
@@ -13,6 +15,25 @@ interface Props {
maxWidth?: number;
}
async function checkImage(image: ImageMetadata) {
console.log("Checking image: ", image);
const src = image.src;
try {
if (src.startsWith("/@fs") || src.startsWith("/_astro")) return true;
const res = await inferRemoteSize(src);
if (res.format) {
image.format = res.format;
return true;
}else {
console.log("Failed to load: ", src);
}
return false;
} catch (err) {
console.log("Failed to fetch: ", src);
return false;
}
}
const {
src: image,
loader = true,
@@ -22,9 +43,10 @@ const {
maxWidth,
} = Astro.props;
let thumbhash = hash && image.fsPath ? await generateThumbHash(image) : "";
let thumbhash = hash && await generateThumbHash(image);
const imageOk = await checkImage(image);
let exif = await getExifData(image);
let exif = imageOk && (await getExifData(image));
const sizes = [
{
@@ -45,18 +67,23 @@ const sizes = [
].filter((size) => !maxWidth || size.width <= maxWidth);
---
<AstroImage
src={image}
alt={alt}
data-thumbhash={thumbhash}
data-exif={JSON.stringify(exif)}
pictureAttributes={{
class: `${hash ? "block h-full relative" : ""} ${loader ? "thumb" : ""} ${pictureClass}`,
}}
class={Astro.props.class}
widths={sizes.map((size) => size.width)}
sizes={sizes
.map((size) => `${size.media || "100vw"} ${size.width}px`)
.join(", ")}>
<slot />
</AstroImage>
{
imageOk ? (
<AstroImage
src={image}
alt={alt}
data-thumbhash={thumbhash}
data-exif={JSON.stringify(exif)}
inferSize={true}
pictureAttributes={{
class: `${hash ? "block h-full relative" : ""} ${loader ? "thumb" : ""} ${pictureClass}`,
}}
class={Astro.props.class}
widths={sizes.map((size) => size.width)}
sizes={sizes
.map((size) => `${size.media || "100vw"} ${size.width}px`)
.join(", ")}>
<slot />
</AstroImage>
) : undefined
}

View File

@@ -16,7 +16,7 @@
let progress: number[] = [];
let currentIndex = -1;
const maxZoom = 5;
import { swipe } from "svelte-gestures";
import { useSwipe } from "svelte-gestures";
const mod = (a: number, b: number) => ((a % b) + b) % b;
@@ -232,10 +232,9 @@
{#if currentIndex > -1}
<div
{...useSwipe(handleSwipe)}
class="image"
use:swipe
role="dialog"
on:swipe={handleSwipe}
on:wheel|passive={handleScroll}
on:mousemove={handleMouseMove}
on:pointermove={handlePointerMove}>

View File

@@ -1,5 +1,5 @@
---
import markdownToText from "@helpers/markdownToText";
import { markdownToText } from "@helpers/markdown";
import { useTranslatedPath } from "@i18n/utils";
import type { InferEntrySchema } from "astro:content";

View File

@@ -0,0 +1,30 @@
<script lang="ts">
export let date: string | Date;
export let readDuration: string | undefined;
const toDate = (d: string | Date) =>
typeof d === "string" ? new Date(d) : d;
const iso = (d: string | Date) => {
const v = toDate(d);
return isNaN(v.getTime()) ? "" : v.toISOString();
};
const formatDate = (d: string | Date) =>
new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
}).format(toDate(d));
</script>
<div class="flex gap-5">
{#if date}
<time class="text-sm text-neutral" datetime={iso(date)}
>{formatDate(date)}</time>
{/if}
{#if readDuration}
<div class="text-sm text-neutral">{readDuration} mins read</div>
{/if}
</div>

View File

@@ -1,19 +1,22 @@
import Wrapper from './Wrapper.svelte';
import Image from './Image.svelte';
import Content from './Content.svelte';
import Title from './Title.svelte';
import Description from './Description.svelte';
import ReadMoreButton from './ReadMoreButton.svelte';
import Wrapper from "./Wrapper.svelte";
import Image from "./Image.svelte";
import Content from "./Content.svelte";
import Title from "./Title.svelte";
import Description from "./Description.svelte";
import ReadMoreButton from "./ReadMoreButton.svelte";
import Metadata from "./Metadata.svelte";
const Card = Wrapper as typeof Wrapper & {
Image: typeof Image;
Metadata: typeof Metadata;
Content: typeof Content;
Title: typeof Title;
Description: typeof Description;
ReadMoreButton: typeof ReadMoreButton;
}
};
Card.Image = Image;
Card.Metadata = Metadata;
Card.Content = Content;
Card.Title = Title;
Card.Description = Description;

View File

@@ -0,0 +1,25 @@
---
import * as memorium from "@helpers/memorium";
import { markdownToHtml, markdownToText } from "@helpers/markdown";
import Image from "@components/Image.astro";
const { resource } = Astro.props;
const ingredients = resource?.content?.recipeIngredient || [];
const instructions = resource?.content?.recipeInstructions || [];
---
<h1 class="text-4xl">{resource?.content?.headline}</h1>
<div>
{
resource?.content?.image && (
<Image
hash
src={{ src: memorium.getImageUrl(resource.content.image) }}
alt="Cover for {resource?.content?.name}"
class="rounded-2xl overflow-hidden"
pictureClass="rounded-2xl"
/>
)
}
</div>
<div set:html={markdownToHtml(resource?.content?.articleBody)} />

View File

@@ -0,0 +1,13 @@
---
import Recipe from "./Recipe.astro";
import Article from "./Article.astro";
const { resource } = Astro.props;
const type = resource?.content?._type ?? "unknown";
---
{type === "Recipe" && <Recipe resource={resource} />}
{type === "Article" && <Article resource={resource} />}
{type === "unknown" && <div>
<h3>Unknown resource type</h3>
</div>}

View File

@@ -0,0 +1,32 @@
---
import * as memorium from "@helpers/memorium";
import { markdownToHtml, markdownToText } from "@helpers/markdown";
import Image from "@components/Image.astro";
const { resource } = Astro.props
const ingredients = resource?.content?.recipeIngredient || [];
const instructions = resource?.content?.recipeInstructions || [];
---
<h1 class="text-4xl">{resource?.content?.name}</h1>
<div>
{resource?.content?.image && <Image hash src={{src: memorium.getImageUrl(resource.content.image)}} alt="Cover for {resource?.content?.name}" class="rounded-2xl overflow-hidden" pictureClass="rounded-2xl" />}
</div>
<p>{resource?.content?.description}</p>
<h2 class="text-2xl">Ingredients</h2>
<ul>
{
ingredients.map((ingredient) => (
<li set:html={markdownToHtml(ingredient)}/>
))
}
</ul>
<h2 class="text-2xl">Steps</h2>
<ol>
{
instructions.map((ingredient) => (
<li set:html={markdownToHtml(ingredient)}/>
))
}
</ol>

View File

@@ -1,4 +0,0 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://ezgif.com/gif-to-webm/ezgif-1-af9e72fcc6.gif
HostUrl=https://ezgif.com/save/ezgif-1-78ce3365b7.webm

View File

@@ -1,4 +0,0 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://ezgif.com/gif-to-webm/ezgif-1-74cf771d87.gif
HostUrl=https://ezgif.com/save/ezgif-1-93f790072e.webm

View File

@@ -1,4 +0,0 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://ezgif.com/gif-to-webm/ezgif-1-70f1c50104.gif
HostUrl=https://ezgif.com/save/ezgif-1-28f4d917d4.webm

View File

@@ -1,3 +0,0 @@
[ZoneTransfer]
ZoneId=3
HostUrl=about:internet

Binary file not shown.

After

Width:  |  Height:  |  Size: 1007 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,91 @@
---
date: 2025-10-21
title: "Silvester-Partys: Eine Retrospektive"
draft: true
cover: ./images/cover.png
description: "Eine Übersicht über unsere jährlichen Silvester-Partys, von Gatsby bis Okkult."
tags: ["event", "webdev", "party", "design"]
icon: 🎉
---
import Image from "@components/Image.astro"
import ImageSlider from "@components/ImageSlider.svelte"
import GatsbyPartyScreenshot from "./images/party-placeholder.jpg"
import GatsbyPartyPhoto from "./images/party-placeholder.jpg"
import HarryPotterPartyScreenshot from "./images/party-placeholder.jpg"
import HarryPotterPartyPhoto from "./images/party-placeholder.jpg"
import VenicePartyScreenshot from "./images/party-placeholder.jpg"
import VenicePartyPhoto from "./images/party-placeholder.jpg"
import FashionPartyScreenshot from "./images/party-placeholder.jpg"
import FashionPartyPhoto from "./images/party-placeholder.jpg"
import ImageGallery from "@components/ImageGallery.svelte"
import videoUrl from "./images/eif-teaser.mp4?url"
<ImageGallery client:load/>
Seit 2019 veranstaltet meine WG eine Silvester Party. Dafür haben wir jedes Jahr ein besonderes Motto ausgewählt welches dann die Kostüme und die Dekoration bestimmt.
Ich hab die Parties immer zum Anlass genommen um mich einmal kreativ auszuleben und habe für jede Party mal einfachere mal komplexere Digital Einladungskarten gestaltet.
### 2019-2020: The Great Gatsby
Unsere erste Party stand unter dem Motto "Great Gatsby".
<ImageSlider title="The Great Gatsby" client:load>
<Image src={GatsbyPartyScreenshot} alt="Screenshot der Gatsby Party Visualisierung" />
<Image src={GatsbyPartyPhoto} alt="Foto der Gatsby Party" />
</ImageSlider>
### 2022-2023: Harry Potter und der sprechende Hut
Für den Jahreswechsel 2022-2023 verwandelte sich unsere Wohnung in die Große Halle von Hogwarts. Das Thema war Harry Potter, und wir hatten einen WebGL-basierten sprechenden Hut, der die Gäste interaktiv den Häusern zuordnete. Zusätzlich gab es ein interaktives Harry-Potter-Quiz auf unserer Webseite. Ein visuelles Display zeigte live den Punktestand der einzelnen Häuser an, wobei ein Admin Punkte in Echtzeit hinzufügen oder abziehen konnte.
Seit 2022 nutzen wir ein Google Sheet als Backend für die Partyorganisation. Jeder Gast gibt bei der Anmeldung eine Wahrscheinlichkeit (in Prozent von 0-100) an, wie wahrscheinlich er oder sie teilnehmen wird. Dies ermöglicht uns eine genauere Vorhersage der Gästezahl.
<ImageSlider title="Harry Potter und der sprechende Hut" client:load>
<Image src={HarryPotterPartyScreenshot} alt="Screenshot der Harry Potter Party Webseite" />
<Image src={HarryPotterPartyPhoto} alt="Foto der Harry Potter Party" />
<Image src={import("./images/hp-1-start.png")} alt="Harry Potter Party Start" />
<Image src={import("images/hp-2-start.png")} alt="Harry Potter Party Start 2" />
<Image src={import("images/hp-3-need-name.png")} alt="Harry Potter Party Need Name" />
<Image src={import("images/hp-4-which-house.png")} alt="Harry Potter Party Which House" />
<Image src={import("images/hp-5-select-house.png")} alt="Harry Potter Party Select House" />
<Image src={import("images/hp-6-attendance-probability.png")} alt="Harry Potter Party Attendance Probability" />
<Image src={import("images/hp-7-invitation-card.png")} alt="Harry Potter Party Invitation Card" />
</ImageSlider>
### 2023-2024: Venezianischer Maskenball
Unsere Silvesterparty 2023-2024 entführte unsere Gäste nach Venedig zu einem opulenten Maskenball. Bei der Registrierung auf unserer Webseite erhielt jeder Gast einen einzigartigen Adelstitel und ein passendes Porträt, die dann in einer digitalen Galerie auf der Webseite ausgestellt wurden.
**Organisation:** Auch hier kam unser Google Sheet Backend zum Einsatz, um die Gästeanmeldungen und Wahrscheinlichkeiten zu verwalten.
<ImageSlider title="Venezianischer Maskenball" client:load>
<Image src={VenicePartyScreenshot} alt="Screenshot der Venezianischer Maskenball Galerie" />
<Image src={VenicePartyPhoto} alt="Foto der Venezianischer Maskenball Party" />
<Image src={import("images/venice-1-start.png")} alt="Venice Party Start" />
<Image src={import("images/venice-2-mask.png")} alt="Venice Party Mask" />
<Image src={import("images/venice-3-invitation-test.png")} alt="Venice Party Invitation Test" />
<Image src={import("images/venice-4-generate-portrait.png")} alt="Venice Party Generate Portrait" />
<Image src={import("images/venice-5-portrait.png")} alt="Venice Party Portrait" />
<Image src={import("images/venice-6-gallery.png")} alt="Venice Party Gallery" />
</ImageSlider>
### 2024-2025: Everything is Fashion
Das Motto für 2024-2025 war "Everything is Fashion". Die Party begann mit einem animierten Intro, das die Gäste auf das modische Thema einstimmte.
<video src={videoUrl} controls alt="Fashion Party Teaser Video" />
<ImageSlider title="Everything is Fashion" client:load>
<Image src={FashionPartyScreenshot} alt="Screenshot des Fashion Party Intros" />
<Image src={FashionPartyPhoto} alt="Foto der Fashion Party" />
<Image src={import("images/eif-anim-1.png")} alt="Fashion Party Animation 1" />
<Image src={import("images/eif-anim-2.png")} alt="Fashion Party Animation 2" />
<Image src={import("images/eif-anim-3.png")} alt="Fashion Party Animation 3" />
<Image src={import("images/eif-navigation.png")} alt="Fashion Party Navigation" />
</ImageSlider>
### 2025-2026: Okkult
Für die kommende Silvesterparty 2025-2026 planen wir ein "Okkult"-Thema, das eine mystische und geheimnisvolle Atmosphäre schaffen wird.

View File

@@ -1,26 +1,41 @@
import { rgbaToThumbHash } from "thumbhash";
import ExifReader from 'exifreader';
import ExifReader from "exifreader";
import type { ImageMetadata } from "astro";
import sharp from "sharp";
let s: typeof import("sharp") | undefined;
async function getSharp(): Promise<typeof import("sharp") | undefined> {
if (s) return s;
s = (await import("sharp")).default;
return s;
}
export async function generateThumbHash(image: ImageMetadata & { fsPath?: string }) {
const sharp = await getSharp();
if (!sharp) return;
export async function generateThumbHash(
image: ImageMetadata & { fsPath?: string },
) {
const scaleFactor = 100 / Math.max(image.width, image.height);
const smallWidth = Math.floor(image.width * scaleFactor);
const smallHeight = Math.floor(image.height * scaleFactor);
let smallWidth = Math.floor(image.width * scaleFactor);
let smallHeight = Math.floor(image.height * scaleFactor);
try {
const smallImg = await sharp(image.fsPath)
const imagePath = (image as ImageMetadata & { fsPath: string }).fsPath ??
image.src;
if (!imagePath) return;
let sp;
if (imagePath.startsWith("https://") || imagePath.startsWith("http://")) {
const res = await fetch(imagePath);
if (!res.ok) {
return;
}
sp = sharp(await res.arrayBuffer());
} else {
sp = sharp(imagePath);
}
if (!smallWidth || !smallHeight) {
const meta = await sp.metadata();
const scaleFactor = 100 / Math.max(meta.width, meta.height);
smallWidth = Math.floor(meta.width * scaleFactor);
smallHeight = Math.floor(meta.height * scaleFactor);
}
const smallImg = await sp
.resize(smallWidth, smallHeight)
.withMetadata()
.raw()
@@ -30,10 +45,12 @@ export async function generateThumbHash(image: ImageMetadata & { fsPath?: string
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 ""
console.log(
`Could not generate thumbhash for ${image.fsPath ?? image.src}`,
error,
);
return "";
}
}
const allowedExif = [
@@ -54,12 +71,19 @@ 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 imagePath = (image as ImageMetadata & { fsPath: string }).fsPath ??
image.src;
const buffer = await sharp(imagePath).toBuffer();
if (!imagePath) return undefined;
try {
let buffer: ArrayBuffer;
if (imagePath.startsWith("https://") || imagePath.startsWith("http://")) {
const res = await fetch(imagePath);
buffer = await res.arrayBuffer();
} else {
buffer = await sharp(imagePath).toBuffer() as unknown as ArrayBuffer;
}
const tags = await ExifReader.load(buffer, { async: true });
@@ -74,9 +98,7 @@ export async function getExifData(image: ImageMetadata) {
return hasExif ? out : undefined;
} catch (error) {
console.log(`Error reading EXIF data from ${imagePath}`, error);
return undefined
console.log(`Error reading EXIF data from ${JSON.stringify(image)}`, error);
return undefined;
}
}

45
src/helpers/markdown.ts Normal file
View File

@@ -0,0 +1,45 @@
import MarkdownIt from "markdown-it";
const parser = new MarkdownIt();
export function readDuration(markdown: string): number | undefined {
const words = markdown.split(" ")?.filter(Boolean)?.length;
return words && Math.round(words / 250);
}
export function markdownToHtml(markdown: string): string {
const md = new MarkdownIt({
html: false, // set to true only if you trust the source
linkify: true,
typographer: true,
breaks: true,
});
// Convert -> sanitize
const unsafeHtml = md.render(markdown);
return unsafeHtml;
}
export function markdownToText(markdown: string): string {
if (!markdown) return "";
return parser
.render(markdown)
.split("\n")
.map((str) => str.trim())
.map((str) => {
return str.replace(/<\/?[^>]+(>|$)/g, "").split("\n");
})
.flat()
.filter((str) =>
!str.startsWith("import") &&
!str.startsWith("export") &&
!str.startsWith("#") &&
!str.startsWith("const") &&
!str.startsWith("function") &&
!str.startsWith("export") &&
!str.startsWith("import") &&
!str.startsWith("&lt;") &&
!str.startsWith("let") &&
str.length > 0
)
.join(" ");
}

View File

@@ -1,25 +0,0 @@
import MarkdownIt from 'markdown-it';
const parser = new MarkdownIt();
export default function markdownToText(markdown: string): string {
return parser
.render(markdown)
.split('\n')
.map((str) => str.trim())
.map((str) => {
return str.replace(/<\/?[^>]+(>|$)/g, '').split('\n');
})
.flat()
.filter((str) => !str.startsWith("import")
&& !str.startsWith("export")
&& !str.startsWith("#")
&& !str.startsWith("const")
&& !str.startsWith("function")
&& !str.startsWith("export")
&& !str.startsWith("import")
&& !str.startsWith("&lt;")
&& !str.startsWith("let")
&& str.length > 0
)
.join(' ');
}

View File

@@ -1,10 +1,59 @@
export async function listResource(id: string): Promise<any[]> {
export type MemoriumFile = {
type: "file";
name: string;
path: string;
modTime: string;
mime: string;
size: string;
content: any;
};
export type MemoriumDir = {
type: "dir";
name: string;
path: string;
modTime: string;
mime: string;
size: string;
content: MemoriumEntry[];
};
export type MemoriumEntry = MemoriumFile | MemoriumDir;
export async function listResource(
id: string,
): Promise<MemoriumEntry | undefined> {
const url = `https://marka.max-richter.dev/resources/${id}`;
console.log("Fetching: ", url);
try {
const response = await fetch(
`http://localhost:8080/resources?name=${id}`,
);
return await response.json();
} catch (error) {
return []
const response = await fetch(url);
if (response.ok) {
const json = await response.json();
if (json.type == "dir") {
return {
...json,
content: json.content.filter((res) =>
res.mime === "application/markdown"
),
};
}
return json;
}
} catch (_e) {
console.log("Failed to get: ", url);
return;
}
}
export function getImageUrl(input: string): string {
if (!input) {
return;
}
if (input.startsWith("https://") || input.startsWith("http://")) {
return input;
}
if (input.startsWith("/")) {
return `https://marka.max-richter.dev${input}`;
}
return `https://marka.max-richter.dev/${input}`;
}

View File

@@ -0,0 +1,59 @@
---
import Layout from "@layouts/Layout.astro";
import { useTranslatedPath } from "@i18n/utils";
import ResourceDisplay from "@components/resources/Display.astro";
import * as memorium from "@helpers/memorium";
import { resources as resourceTypes } from "../resources.ts";
import { markdownToText } from "@helpers/markdown";
import Image from "@components/Image.astro";
const { resourceType, resourceName } = Astro?.params;
const path = useTranslatedPath(Astro.url);
export async function getStaticPaths() {
try {
const paths = await Promise.all(
resourceTypes.map(async (resourceType) => {
const resources = await memorium.listResource(resourceType.id);
return resources?.content?.map((res: any) => {
return {
params: {
resourceType: resourceType.id,
resourceName: res.name.replace(/\.md$/, ""),
},
};
});
}),
);
return paths.flat().filter(Boolean);
} catch (err) {
return [];
}
}
const resource = await memorium.listResource(
`${resourceType}/${resourceName}.md`,
);
---
<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("/resources/" + resourceType)}>
<span class="i-tabler-arrow-left"></span> back
</a>
<div class="date opacity-50">
{
resource?.content.datePublished?.toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
}
</div>
</div>
<ResourceDisplay resource={resource} />
</Layout>

View File

@@ -0,0 +1,59 @@
---
import Layout from "@layouts/Layout.astro";
import HeroCard from "@components/HeroCard.astro";
import * as memorium from "@helpers/memorium";
import { resources as resourceTypes } from "../resources.ts";
import { markdownToText } from "@helpers/markdown";
const { resourceType } = Astro.params;
async function safeGetResource(resType) {
try {
return await memorium.listResource(resourceType);
} catch (error) {
return { content: [] };
}
}
const resources = await safeGetResource(resourceType);
export async function getStaticPaths() {
return resourceTypes.map((type: any) => {
return {
params: {
resourceType: type.id,
resourceName: "Recipe",
},
};
});
}
function isValidResource(res) {
if (res?.content?.name) return true;
if (res?.content?.headline) return true;
return false;
}
---
<Layout title="Max Richter">
{
resources.content
.filter((res) => res && res?.content)
.map((resource: any) => (
<HeroCard
post={{
collection: "resources/" + resourceType,
id: resource.name.replace(/\.md$/, ""),
data: {
title: resource.content.name ?? resource.content.headline,
date: resource?.content?.datePublished,
cover: {
src: memorium.getImageUrl(resource.content.image),
},
},
}}
/>
))
}
</Layout>
</Layout>

View File

@@ -1,64 +1,9 @@
---
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: "📺",
},
};
import { resources } from "./resources.ts";
---
<Layout title="Max Richter">
<HeroCard post={wiki} />
<HeroCard post={recipes} />
<HeroCard post={articles} />
<HeroCard post={movies} />
<HeroCard post={series} />
{resources.map((resource) => <HeroCard post={resource} />)}
</Layout>

View File

@@ -1,38 +0,0 @@
---
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

@@ -1,27 +0,0 @@
---
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

@@ -1,71 +0,0 @@
---
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

@@ -1,23 +0,0 @@
---
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,60 @@
const collection = "resources";
type Resource = {
id: string;
collection: string;
data: {
title: string;
icon: string;
};
};
// const wiki = {
// id: "wiki",
// collection,
// body: "My knowledge base",
// data: {
// title: "Wiki",
// icon: "🧠",
// },
// };
const articles = {
id: "articles",
collection,
data: {
title: "Articles",
icon: "📰",
},
};
const recipes = {
id: "recipes",
collection,
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: "📺",
// },
// };
export const resources: Resource[] = [recipes, articles];

View File

@@ -1,58 +0,0 @@
---
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

@@ -1,27 +0,0 @@
---
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>

View File

@@ -1,5 +1,10 @@
import { vitePreprocess } from '@astrojs/svelte';
import { vitePreprocess } from "@astrojs/svelte";
export default {
preprocess: vitePreprocess(),
}
compilerOptions: {
experimental: {
async: true,
},
},
};

View File

@@ -11,7 +11,8 @@
"baseUrl": ".",
"types": [
"vite-plugin-glsl/ext",
"unplugin-icons/types"
"unplugin-icons/types",
"svelte-gestures/globals"
],
"paths": {
"@components/*": [