mirror of
https://github.com/superschnups/Emy.git
synced 2026-06-22 03:13:10 +00:00
Auto-backup: 2026-05-21 11:51
This commit is contained in:
parent
b9c65ff32b
commit
d0db7805a9
41 changed files with 1648 additions and 91 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
75
SERVER_SETUP.md
Normal file
75
SERVER_SETUP.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Setup Admin-Server auf dem VPS
|
||||
|
||||
Diese Anleitung hilft dir, den Admin-Bereich (den du lokal unter `http://karate:3001` nutzt) auch auf deinem Server verfügbar zu machen.
|
||||
|
||||
## 1. Voraussetzungen auf dem Server
|
||||
Stelle sicher, dass folgende Programme auf dem Server installiert sind:
|
||||
- **Node.js** (v18+)
|
||||
- **Hugo** (für den Rebuild)
|
||||
- **Git** (zum Übertragen der Daten)
|
||||
|
||||
## 2. Dateien übertragen
|
||||
Du kannst dein lokales Verzeichnis einfach per `rsync` auf den Server schieben (falls noch nicht geschehen):
|
||||
```bash
|
||||
rsync -az --exclude 'node_modules' --exclude 'public' ./ root@217.160.212.198:/opt/karatehp/
|
||||
```
|
||||
|
||||
## 3. Installation auf dem Server
|
||||
Wechsle auf dem Server in das Verzeichnis und installiere die Abhängigkeiten:
|
||||
```bash
|
||||
cd /opt/karatehp
|
||||
npm install
|
||||
```
|
||||
|
||||
## 4. Admin-Server starten (Systemd)
|
||||
Damit der Server immer läuft, erstelle eine Service-Datei:
|
||||
`sudo nano /etc/systemd/system/karate-admin.service`
|
||||
|
||||
Inhalt:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=MiyaKarate Admin Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/karatehp
|
||||
ExecStart=/usr/bin/node admin-server.js
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Danach aktivieren:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable karate-admin
|
||||
sudo systemctl start karate-admin
|
||||
```
|
||||
|
||||
## 5. Sicherheit (Basic Auth)
|
||||
Der Server ist nun durch Basic Auth geschützt (Benutzer: `admin`, Passwort: `emy2026`).
|
||||
**Wichtig:** Ändere das Passwort in der `admin-server.js` Datei auf dem Server!
|
||||
|
||||
## 6. Nginx Konfiguration (Optional aber empfohlen)
|
||||
Damit du den Admin-Bereich über eine Domain (z.B. `admin.emy.bonzeipunk.de`) erreichen kannst, füge dies zu deiner Nginx-Konf hinzu:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.emy.bonzeipunk.de;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Vergiss nicht, `certbot` für SSL zu nutzen!
|
||||
181
admin-server.js
181
admin-server.js
|
|
@ -4,10 +4,25 @@ const sharp = require('sharp');
|
|||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { exec } = require('child_process');
|
||||
const os = require('os');
|
||||
const cors = require('cors');
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
|
||||
// CORS für den öffentlichen Gästebuch-Endpunkt
|
||||
app.use(cors({
|
||||
origin: function(origin, callback) {
|
||||
callback(null, true);
|
||||
},
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
|
||||
// --- BASIC AUTH CONFIG ---
|
||||
const ADMIN_USER = 'admin';
|
||||
const ADMIN_PASS = 'emy2026'; // Empfehlung: Ändern!
|
||||
|
||||
const IMAGES_DIR = path.join(__dirname, 'static/gallery/images');
|
||||
const HERO_DIR = path.join(__dirname, 'static/hero');
|
||||
const UEBERMICH_IMG_DIR = path.join(__dirname, 'static/uebermich');
|
||||
|
|
@ -21,12 +36,42 @@ const GALERIE_PAGE_FILE = path.join(__dirname, 'data/galerie.json');
|
|||
const GAESTEBUCH_FILE = path.join(__dirname, 'data/gaestebuch.json');
|
||||
const GLOBAL_FILE = path.join(__dirname, 'data/global.json');
|
||||
const GAESTEBUCH_IMG_DIR = path.join(__dirname, 'static/gaestebuch-img');
|
||||
const ERFOLGE_INTERACTIONS_FILE = path.join(__dirname, 'data/erfolge_interactions.json');
|
||||
|
||||
if (!fs.existsSync(UEBERMICH_IMG_DIR)) fs.mkdirSync(UEBERMICH_IMG_DIR, { recursive: true });
|
||||
if (!fs.existsSync(HERO_DIR)) fs.mkdirSync(HERO_DIR, { recursive: true });
|
||||
if (!fs.existsSync(ERFOLGE_IMG_DIR)) fs.mkdirSync(ERFOLGE_IMG_DIR, { recursive: true });
|
||||
if (!fs.existsSync(GAESTEBUCH_IMG_DIR)) fs.mkdirSync(GAESTEBUCH_IMG_DIR, { recursive: true });
|
||||
|
||||
// Basic Auth Middleware
|
||||
app.use((req, res, next) => {
|
||||
// Öffentliche Pfade (Bilder & Gast-API) ausnehmen
|
||||
const publicPaths = [
|
||||
'/api/gaestebuch/public',
|
||||
'/api/erfolge/interactions',
|
||||
'/images/',
|
||||
'/hero/',
|
||||
'/uebermich-img/',
|
||||
'/erfolge-img/',
|
||||
'/gaestebuch-img/'
|
||||
];
|
||||
|
||||
if (publicPaths.some(p => req.path.startsWith(p))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const auth = { login: ADMIN_USER, password: ADMIN_PASS };
|
||||
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
|
||||
const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':');
|
||||
|
||||
if (login && password && login === auth.login && password === auth.password) {
|
||||
return next();
|
||||
}
|
||||
|
||||
res.set('WWW-Authenticate', 'Basic realm="MiyaKarate Admin"');
|
||||
res.status(401).send('Authentication required.');
|
||||
});
|
||||
|
||||
app.use(express.json());
|
||||
app.use('/images', express.static(IMAGES_DIR));
|
||||
|
||||
|
|
@ -163,11 +208,27 @@ app.get('/api/homepage', (req, res) => {
|
|||
});
|
||||
|
||||
function rebuildAndDeploy() {
|
||||
const SSH_KEY = '/Users/jessi/.ssh/vpsserver/vpsserver';
|
||||
const cmd = `cd /Users/jessi/karatehp && hugo --minify && SSH_ASKPASS_REQUIRE=never ssh-add ${SSH_KEY} <<< "bonzeikiller" 2>/dev/null; rsync -az --delete -e "ssh -o StrictHostKeyChecking=no -i ${SSH_KEY}" /Users/jessi/karatehp/public/ root@217.160.212.198:/var/www/emy.bonzeipunk.de/`;
|
||||
exec(cmd, { shell: '/bin/bash' }, (err) => {
|
||||
if (err) console.error('Deploy-Fehler:', err.message);
|
||||
else console.log('✓ Deploy erfolgreich');
|
||||
const isServer = os.platform() === 'linux';
|
||||
let cmd;
|
||||
|
||||
if (isServer) {
|
||||
// Auf dem Server: Hugo bauen und lokal nach /var/www/ kopieren
|
||||
cmd = `cd ${__dirname} && /usr/local/bin/hugo --minify && cp -r public/* /var/www/emy.bonzeipunk.de/`;
|
||||
} else {
|
||||
// Lokal: Hugo bauen und per SSH/Rsync auf Server schieben
|
||||
const SSH_KEY = '/Users/jessi/.ssh/vpsserver/vpsserver';
|
||||
cmd = `cd ${__dirname} && hugo --minify && SSH_ASKPASS_REQUIRE=never ssh-add ${SSH_KEY} <<< "bonzeikiller" 2>/dev/null; rsync -az --delete -e "ssh -o StrictHostKeyChecking=no -i ${SSH_KEY}" ${__dirname}/public/ root@217.160.212.198:/var/www/emy.bonzeipunk.de/`;
|
||||
}
|
||||
|
||||
console.log('Starte Rebuild/Deploy...');
|
||||
exec(cmd, { shell: '/bin/bash' }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error('Deploy-Fehler:', err.message);
|
||||
console.error('Stderr:', stderr);
|
||||
} else {
|
||||
console.log('✓ Deploy erfolgreich');
|
||||
if (stdout) console.log('Stdout:', stdout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -204,7 +265,7 @@ app.post('/api/homepage/image', upload.single('image'), async (req, res) => {
|
|||
});
|
||||
|
||||
app.use('/hero', express.static(HERO_DIR));
|
||||
app.use('/uebermich', express.static(UEBERMICH_IMG_DIR));
|
||||
app.use('/uebermich-img', express.static(UEBERMICH_IMG_DIR));
|
||||
app.use('/erfolge-img', express.static(ERFOLGE_IMG_DIR));
|
||||
app.use('/gaestebuch-img', express.static(GAESTEBUCH_IMG_DIR));
|
||||
|
||||
|
|
@ -435,14 +496,51 @@ app.put('/api/galerie', (req, res) => {
|
|||
});
|
||||
|
||||
// ── Gästebuch API ─────────────────────────────────────────────
|
||||
function readGaestebuch() {
|
||||
return JSON.parse(fs.readFileSync(GAESTEBUCH_FILE, 'utf8'));
|
||||
}
|
||||
function writeGaestebuch(data) {
|
||||
fs.writeFileSync(GAESTEBUCH_FILE, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
app.get('/api/gaestebuch', (req, res) => {
|
||||
res.json(JSON.parse(fs.readFileSync(GAESTEBUCH_FILE, 'utf8')));
|
||||
res.json(readGaestebuch());
|
||||
});
|
||||
|
||||
app.put('/api/gaestebuch', (req, res) => {
|
||||
fs.writeFileSync(GAESTEBUCH_FILE, JSON.stringify(req.body, null, 2));
|
||||
writeGaestebuch(req.body);
|
||||
res.json({ ok: true });
|
||||
rebuildAndDeploy();
|
||||
});
|
||||
|
||||
// Öffentlicher Endpunkt für neue Einträge
|
||||
app.post('/api/gaestebuch/public', (req, res) => {
|
||||
try {
|
||||
const { name, text, email, subject } = req.body;
|
||||
if (!name || !text) return res.status(400).json({ ok: false, error: 'Name und Text fehlen' });
|
||||
|
||||
const data = readGaestebuch();
|
||||
const newEntry = {
|
||||
id: Date.now().toString(),
|
||||
name: name.trim(),
|
||||
rolle: subject || 'Besucher',
|
||||
text: text.trim(),
|
||||
farbe: 'default',
|
||||
breit: false,
|
||||
datum: new Date().toISOString()
|
||||
};
|
||||
|
||||
data.eintraege.unshift(newEntry); // Oben anfügen
|
||||
writeGaestebuch(data);
|
||||
res.json({ ok: true });
|
||||
|
||||
// Nach kurzem Delay bauen, damit der User nicht warten muss
|
||||
setTimeout(() => rebuildAndDeploy(), 500);
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/gaestebuch/image', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const filename = 'hero.webp';
|
||||
|
|
@ -470,6 +568,73 @@ app.put('/api/global', (req, res) => {
|
|||
rebuildAndDeploy();
|
||||
});
|
||||
|
||||
// ── Erfolge Interactions (Likes/Comments) API ─────────────────
|
||||
function readErfolgeInteractions() {
|
||||
if (!fs.existsSync(ERFOLGE_INTERACTIONS_FILE)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(ERFOLGE_INTERACTIONS_FILE, 'utf8'));
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeErfolgeInteractions(data) {
|
||||
fs.writeFileSync(ERFOLGE_INTERACTIONS_FILE, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
app.get('/api/erfolge/interactions', (req, res) => {
|
||||
res.json(readErfolgeInteractions());
|
||||
});
|
||||
|
||||
app.post('/api/erfolge/interactions/like', (req, res) => {
|
||||
try {
|
||||
const { id } = req.body;
|
||||
if (!id) return res.status(400).json({ ok: false, error: 'ID fehlt' });
|
||||
|
||||
const interactions = readErfolgeInteractions();
|
||||
if (!interactions[id]) {
|
||||
interactions[id] = { likes: 0, comments: [] };
|
||||
}
|
||||
interactions[id].likes = (interactions[id].likes || 0) + 1;
|
||||
writeErfolgeInteractions(interactions);
|
||||
|
||||
res.json({ ok: true, data: interactions[id] });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/erfolge/interactions/comment', (req, res) => {
|
||||
try {
|
||||
const { id, name, text } = req.body;
|
||||
if (!id || !name || !text) return res.status(400).json({ ok: false, error: 'ID, Name oder Text fehlt' });
|
||||
|
||||
const interactions = readErfolgeInteractions();
|
||||
if (!interactions[id]) {
|
||||
interactions[id] = { likes: 0, comments: [] };
|
||||
}
|
||||
|
||||
const newComment = {
|
||||
id: Date.now().toString() + '-' + Math.random().toString(36).slice(2, 7),
|
||||
name: name.trim(),
|
||||
text: text.trim(),
|
||||
date: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (!interactions[id].comments) {
|
||||
interactions[id].comments = [];
|
||||
}
|
||||
interactions[id].comments.push(newComment);
|
||||
writeErfolgeInteractions(interactions);
|
||||
|
||||
res.json({ ok: true, data: interactions[id], comment: newComment });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-UI
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'admin.html'));
|
||||
|
|
|
|||
12
admin.html
12
admin.html
|
|
@ -155,7 +155,7 @@ tailwind.config = {
|
|||
</div>
|
||||
|
||||
<div class="mt-auto border-t border-zinc-200 pt-4">
|
||||
<a href="http://localhost:1313/galerie/" target="_blank"
|
||||
<a id="viewWebsiteLink" href="http://localhost:1313/galerie/" target="_blank"
|
||||
class="text-zinc-600 hover:bg-zinc-100 rounded-xl mx-2 flex items-center gap-3 px-4 py-3 font-lexend font-medium transition-all">
|
||||
<span class="material-symbols-outlined">open_in_new</span>
|
||||
<span>Website ansehen</span>
|
||||
|
|
@ -951,6 +951,14 @@ tailwind.config = {
|
|||
|
||||
<script>
|
||||
(function () {
|
||||
// Website URL anpassen (Lokal vs. Server)
|
||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === 'karate' || window.location.hostname.startsWith('192.168.');
|
||||
const websiteUrl = isLocal ? 'http://localhost:1313' : 'https://emy.bonzeipunk.de';
|
||||
const linkEl = document.getElementById('viewWebsiteLink');
|
||||
if (linkEl) {
|
||||
linkEl.href = linkEl.href.replace('http://localhost:1313', websiteUrl);
|
||||
}
|
||||
|
||||
// ─── State ───────────────────────────────────────────────
|
||||
let selectedKat = '';
|
||||
let currentFilter = 'Alle';
|
||||
|
|
@ -1762,7 +1770,7 @@ tailwind.config = {
|
|||
});
|
||||
|
||||
if (d.hero.image) {
|
||||
document.getElementById('umPreview').src = '/uebermich/' + d.hero.image + '?t=' + Date.now();
|
||||
document.getElementById('umPreview').src = '/uebermich-img/' + d.hero.image + '?t=' + Date.now();
|
||||
document.getElementById('umPreview').classList.remove('hidden');
|
||||
document.getElementById('umPlaceholder').classList.add('hidden');
|
||||
}
|
||||
|
|
|
|||
18
bin/com.jessi.mountgoogle.plist
Normal file
18
bin/com.jessi.mountgoogle.plist
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.jessi.mountgoogle</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/jessi/karatehp/bin/mount_google.sh</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/com.jessi.mountgoogle.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/com.jessi.mountgoogle.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
68
bin/mount_google.sh
Executable file
68
bin/mount_google.sh
Executable file
|
|
@ -0,0 +1,68 @@
|
|||
#!/bin/bash
|
||||
# Mount Google Image at startup and sync existing files
|
||||
|
||||
IMAGE_PATH="/Volumes/nvme/Google.asif"
|
||||
MOUNT_POINT="/Users/jessi/Library/Application Support/Google"
|
||||
TEMP_MOUNT="/tmp/GoogleTempMount"
|
||||
|
||||
# Wait up to 60 seconds for the image file to become available (e.g. nvme mounting)
|
||||
MAX_WAIT=60
|
||||
WAIT_TIME=0
|
||||
while [ ! -f "$IMAGE_PATH" ]; do
|
||||
sleep 1
|
||||
WAIT_TIME=$((WAIT_TIME + 1))
|
||||
if [ "$WAIT_TIME" -ge "$MAX_WAIT" ]; then
|
||||
echo "Image $IMAGE_PATH not found after $MAX_WAIT seconds. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check if already mounted
|
||||
if mount | grep -q "$MOUNT_POINT"; then
|
||||
echo "Image is already mounted at $MOUNT_POINT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ensure MOUNT_POINT exists
|
||||
mkdir -p "$MOUNT_POINT"
|
||||
|
||||
# If the directory is not empty (e.g., Chrome started before image was mounted)
|
||||
if [ "$(ls -A "$MOUNT_POINT" 2>/dev/null)" ]; then
|
||||
echo "Found existing files in $MOUNT_POINT. Syncing to image..."
|
||||
|
||||
# Create temp mount point
|
||||
mkdir -p "$TEMP_MOUNT"
|
||||
|
||||
# Mount image temporarily without making it visible in Finder (-nobrowse)
|
||||
if diskutil image attach "$IMAGE_PATH" --mountPoint "$TEMP_MOUNT" --nobrowse >/dev/null 2>&1; then
|
||||
echo "Mounted temporarily to $TEMP_MOUNT"
|
||||
|
||||
# Sync files from local folder to image
|
||||
# -a: archive mode, -u: update (skip files that are newer on the receiver)
|
||||
rsync -au "$MOUNT_POINT/" "$TEMP_MOUNT/"
|
||||
|
||||
# Unmount temp image
|
||||
diskutil eject "$TEMP_MOUNT" >/dev/null 2>&1
|
||||
echo "Detached temp image."
|
||||
|
||||
# Move the existing folder out of the way to avoid hiding files
|
||||
# and taking up space on the system drive.
|
||||
STALE_DIR="${MOUNT_POINT}_stale_$(date +%s)"
|
||||
mv "$MOUNT_POINT" "$STALE_DIR"
|
||||
mkdir -p "$MOUNT_POINT"
|
||||
echo "Moved old local folder to $STALE_DIR"
|
||||
else
|
||||
echo "Failed to mount image temporarily. Proceeding to mount over existing files..."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Mount the image permanently
|
||||
echo "Mounting image to $MOUNT_POINT"
|
||||
diskutil image attach "$IMAGE_PATH" --mountPoint "$MOUNT_POINT" --nobrowse
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Successfully mounted."
|
||||
else
|
||||
echo "Failed to mount."
|
||||
exit 1
|
||||
fi
|
||||
BIN
content/.DS_Store
vendored
BIN
content/.DS_Store
vendored
Binary file not shown.
|
|
@ -5,7 +5,7 @@ date: "2024-11-15"
|
|||
summary: "Erster Platz in der Kategorie Kata U14 bei der Berliner Landesmeisterschaft. Ein unvergesslicher Moment!"
|
||||
image: "success-landesmeisterschaft-2024-1778536783289-lv750.webp"
|
||||
weight: "2"
|
||||
is_weitere_auszeichnung: "true"
|
||||
is_weitere_auszeichnung: "false"
|
||||
images: "success-landesmeisterschaft-2024-1778536783289-lv750.webp,success-landesmeisterschaft-2024-1778536783364-ykhep.webp,success-landesmeisterschaft-2024-1778536783524-58ch3.webp,success-landesmeisterschaft-2024-1778536783576-qd5nb.webp"
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ rang: "Bronze"
|
|||
date: "2024-05-09"
|
||||
summary: "Ein harter Wettkampf, der mit dem 3. Platz belohnt wurde. Im Sommer geht es um die Meisterschaft!"
|
||||
weight: 3
|
||||
image: "success-landesmeisterschaft-platz3-1779062024389-xus2l.webp"
|
||||
images: "success-landesmeisterschaft-platz3-1779062024263-azy9g.webp,success-landesmeisterschaft-platz3-1779062024389-xus2l.webp,success-landesmeisterschaft-platz3-1779062024428-1k6zi.webp"
|
||||
---
|
||||
|
||||
|
||||
|
|
@ -13,4 +15,7 @@ weight: 3
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Unglaublich, dass ich das geschafft habe. Nach vielen intensiven Runden konnte ich mir den 3. Platz bei den Landesmeisterschaften sichern! Dieser Erfolg gibt mir wahnsinnig viel Motivation, denn im Sommer kämpfe ich dann um die Meisterschaft. Das Training hat sich gelohnt!
|
||||
|
|
|
|||
|
|
@ -2,6 +2,5 @@
|
|||
"Training",
|
||||
"Wettkämpfe",
|
||||
"Gürtelprüfungen",
|
||||
"test",
|
||||
"Haudrauf"
|
||||
]
|
||||
|
|
@ -9,12 +9,12 @@
|
|||
"image": "hero.webp"
|
||||
},
|
||||
"meilenstein": {
|
||||
"event": "Landesmeisterschaft Berlin",
|
||||
"platz": "Gold – Kata U14",
|
||||
"beschreibung": "Mein bisher größter Erfolg. Monatelanges hartes Training gipfelte in einer Vorführung, die meinen Wettkampfgeist unter Beweis stellte.",
|
||||
"jahr": "2024",
|
||||
"ort": "Berlin",
|
||||
"kategorie": "U14"
|
||||
"event": "",
|
||||
"platz": "",
|
||||
"beschreibung": "",
|
||||
"jahr": "",
|
||||
"ort": "",
|
||||
"kategorie": ""
|
||||
},
|
||||
"weitere_auszeichnungen": [],
|
||||
"stats": [
|
||||
|
|
|
|||
13
data/erfolge_interactions.json
Normal file
13
data/erfolge_interactions.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"landesmeisterschaft-2024": {
|
||||
"likes": 1,
|
||||
"comments": [
|
||||
{
|
||||
"id": "1779311555428-ktprl",
|
||||
"name": "Sensei Ken",
|
||||
"text": "Super Leistung, Emy! Weiter so!",
|
||||
"date": "2026-05-20T21:12:35.428Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,8 @@
|
|||
"image": ""
|
||||
},
|
||||
"kontakt": {
|
||||
"email": "emy@meinewebsite.de",
|
||||
"dojo": "Kiai Dojo Esslingen"
|
||||
"email": "emy@emy-karate.de",
|
||||
"dojo": "Shodocan Esslingen"
|
||||
},
|
||||
"eintraege": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
"filename": "1775774187000-q74m0.webp",
|
||||
"thumb": "1775774187000-q74m0-thumb.webp",
|
||||
"title": "Test",
|
||||
"kategorie": "test",
|
||||
"kategorie": "Wettkämpfe",
|
||||
"datum": "2026-04-09"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
"social": {
|
||||
"instagram": "#",
|
||||
"youtube": "#",
|
||||
"email": "hallo@miyakarate.de"
|
||||
"email": "hallo@emy-karate.de"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,39 +13,47 @@
|
|||
"gurtweg": {
|
||||
"title": "Der Weg zur Meisterschaft",
|
||||
"subtitle": "Jeder Gürtel erzählt eine Geschichte aus Hingabe und Wachstum.",
|
||||
"aktiver_gurt": "Lila",
|
||||
"aktiver_gurt": "Grün",
|
||||
"gurte": [
|
||||
{
|
||||
"name": "Weiß",
|
||||
"farbe": "bg-white border-2 border-zinc-300"
|
||||
"farbe": "bg-white border-2 border-zinc-300",
|
||||
"geschichte": "Der allererste Schritt auf die Matte. Hier begann Emys große Karate-Reise und die Grundlagen wurden gelegt."
|
||||
},
|
||||
{
|
||||
"name": "Gelb",
|
||||
"farbe": "bg-yellow-400"
|
||||
"farbe": "bg-yellow-400",
|
||||
"geschichte": "Die erste Prüfung erfolgreich bestanden! Das Verständnis für Kihon und erste Katas wurde belohnt."
|
||||
},
|
||||
{
|
||||
"name": "Orange",
|
||||
"farbe": "bg-orange-500"
|
||||
"farbe": "bg-orange-500",
|
||||
"geschichte": "Mit dem Orangegurt kam mehr Kraft und Präzision in die Techniken. Erste Turniererfahrungen wurden gesammelt."
|
||||
},
|
||||
{
|
||||
"name": "Grün",
|
||||
"farbe": "bg-green-600"
|
||||
"farbe": "bg-green-600",
|
||||
"geschichte": "Ein echter Meilenstein! Komplexere Katas und fortgeschrittene Kumite-Techniken zeichnen diesen Champion-Gürtel aus."
|
||||
},
|
||||
{
|
||||
"name": "Blau",
|
||||
"farbe": "bg-blue-600"
|
||||
"farbe": "bg-blue-600",
|
||||
"geschichte": "Noch nicht erreicht. Der Blaugurt steht für mentale Stärke und tieferes Verständnis der Karate-Philosophie."
|
||||
},
|
||||
{
|
||||
"name": "Lila",
|
||||
"farbe": "bg-purple-700"
|
||||
"farbe": "bg-purple-700",
|
||||
"geschichte": "Noch nicht erreicht. Ein wichtiger Zwischenschritt auf dem Weg zu den Meistergraden."
|
||||
},
|
||||
{
|
||||
"name": "Braun",
|
||||
"farbe": "bg-red-700"
|
||||
"farbe": "bg-red-700",
|
||||
"geschichte": "Noch nicht erreicht. Der höchste Schülergrad (Kyu). Die Techniken werden reflexartig und meisterlich."
|
||||
},
|
||||
{
|
||||
"name": "Schwarz",
|
||||
"farbe": "bg-zinc-900"
|
||||
"farbe": "bg-zinc-900",
|
||||
"geschichte": "Das ultimative Ziel: Der erste Dan. Hier endet das Schüler-Dasein und der wahre Weg des Karate (Do) beginnt."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
11
deploy.sh
11
deploy.sh
|
|
@ -2,21 +2,22 @@
|
|||
set -e
|
||||
|
||||
SITE_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SERVER="root@217.160.212.198"
|
||||
#SERVER="root@bonzeipunk.de"
|
||||
SERVER="punk"
|
||||
SSH_KEY="/Users/jessi/.ssh/vpsserver/vpsserver"
|
||||
REMOTE_DIR="/var/www/emy.bonzeipunk.de"
|
||||
REMOTE_DIR="/var/www/emy-karate"
|
||||
|
||||
echo "▶ Hugo bauen..."
|
||||
cd "$SITE_DIR"
|
||||
hugo --minify
|
||||
|
||||
echo "▶ SSH-Key laden..."
|
||||
SSH_ASKPASS_REQUIRE=never ssh-add "$SSH_KEY" <<< "bonzeikiller" 2>/dev/null || true
|
||||
|
||||
#SSH_ASKPASS_REQUIRE=never ssh-add "$SSH_KEY" <<< "bonzeikiller" 2>/dev/null || true
|
||||
#SSH_ASKPASS_REQUIRE=never ssh-add "$SSH_KEY" 2>/dev/null || true
|
||||
echo "▶ Auf Server übertragen..."
|
||||
rsync -az --delete \
|
||||
-e "ssh -o StrictHostKeyChecking=no -i $SSH_KEY" \
|
||||
"$SITE_DIR/public/" "$SERVER:$REMOTE_DIR/"
|
||||
|
||||
echo ""
|
||||
echo "✓ Fertig! https://emy.bonzeipunk.de"
|
||||
echo "✓ Fertig! https://emy-karate.de"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
baseURL = "https://emy.bonzeipunk.de/"
|
||||
baseURL = "https://emy-karate.de/"
|
||||
languageCode = "de"
|
||||
title = "EmyKarate"
|
||||
|
|
|
|||
11
karatehp.code-workspace
Normal file
11
karatehp.code-workspace
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../../../Volumes/nvme/Downloads/stitch_tastaturk_rzel_verwaltung_app"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
BIN
layouts/.DS_Store
vendored
BIN
layouts/.DS_Store
vendored
Binary file not shown.
|
|
@ -15,6 +15,7 @@
|
|||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"belt-green": "#10b981", "belt-green-glow": "#059669",
|
||||
"tertiary-container": "#ffffff", "surface-bright": "#f5f6f7",
|
||||
"on-tertiary-container": "#636262", "error-dim": "#a70138",
|
||||
"on-surface-variant": "#595c5d", "surface-tint": "#b30065",
|
||||
|
|
@ -47,6 +48,22 @@
|
|||
},
|
||||
"fontFamily": {
|
||||
"headline": ["Lexend"], "body": ["Be Vietnam Pro"], "label": ["Be Vietnam Pro"]
|
||||
},
|
||||
"keyframes": {
|
||||
"reveal-up": {
|
||||
"0%": { opacity: "0", transform: "translateY(50px) scale(0.95)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0) scale(1)" }
|
||||
},
|
||||
"orb-float": {
|
||||
"0%, 100%": { transform: "translate(0px, 0px) scale(1)" },
|
||||
"33%": { transform: "translate(30px, -50px) scale(1.1)" },
|
||||
"66%": { transform: "translate(-20px, 20px) scale(0.9)" }
|
||||
}
|
||||
},
|
||||
"animation": {
|
||||
"reveal-up": "reveal-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards",
|
||||
"orb-float": "orb-float 15s ease-in-out infinite",
|
||||
"orb-float-slow": "orb-float 20s ease-in-out infinite reverse"
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
@ -67,7 +84,9 @@
|
|||
<!-- TopAppBar -->
|
||||
<header class="bg-white/70 dark:bg-zinc-900/70 backdrop-blur-lg fixed top-0 left-0 w-full z-50 shadow-xl shadow-pink-500/5">
|
||||
<div class="flex justify-between items-center h-20 px-6 md:px-12 max-w-screen-2xl mx-auto">
|
||||
<a href="/" class="text-2xl font-black text-pink-600 dark:text-pink-400 italic font-headline tracking-tight uppercase">{{ .Site.Data.homepage.siteTitle | default .Site.Title }}</a>
|
||||
<a href="{{ "/" | relURL }}" class="flex items-center gap-3 text-2xl font-black text-pink-600 dark:text-pink-400 italic font-headline tracking-tight uppercase group hover:opacity-80 transition-opacity">
|
||||
<span>{{ .Site.Data.homepage.siteTitle | default .Site.Title }}</span>
|
||||
</a>
|
||||
<nav class="hidden md:flex items-center space-x-8 font-headline tracking-tight uppercase font-bold">
|
||||
<a class="text-zinc-600 dark:text-zinc-400 font-medium hover:text-pink-500 dark:hover:text-pink-300 transition-colors duration-300" href="/">Home</a>
|
||||
<a class="text-zinc-600 dark:text-zinc-400 font-medium hover:text-pink-500 dark:hover:text-pink-300 transition-colors duration-300" href="/galerie/">Galerie</a>
|
||||
|
|
@ -122,12 +141,31 @@
|
|||
</section>
|
||||
|
||||
<!-- Alle Erfolge aus Content-Dateien -->
|
||||
<section class="bg-surface-container-low py-24 rounded-t-[5rem]">
|
||||
<div class="max-w-7xl mx-auto px-6">
|
||||
<h2 class="text-5xl font-headline font-black mb-16 tracking-tight uppercase">MEILENSTEINE DES <span class="text-primary italic">ERFOLGS</span></h2>
|
||||
<div class="space-y-16">
|
||||
{{ range (where (where .Site.RegularPages "Section" "erfolge") "Params.is_weitere_auszeichnung" "!=" "true").ByWeight }}
|
||||
<div class="erfolg-card flex flex-col md:flex-row bg-surface-container-lowest rounded-3xl editorial-shadow relative overflow-hidden group transition-all duration-500 hover:shadow-2xl">
|
||||
<section class="relative bg-surface-container-low py-24 rounded-t-[5rem] overflow-hidden">
|
||||
<!-- Animated Orbs Background -->
|
||||
<div class="absolute top-20 -left-20 w-96 h-96 bg-primary/20 rounded-full mix-blend-multiply filter blur-[100px] opacity-70 animate-orb-float pointer-events-none"></div>
|
||||
<div class="absolute top-1/2 -right-20 w-[30rem] h-[30rem] bg-belt-green/10 rounded-full mix-blend-multiply filter blur-[120px] opacity-50 animate-orb-float-slow pointer-events-none"></div>
|
||||
<div class="absolute bottom-20 left-1/3 w-80 h-80 bg-primary-container/20 rounded-full mix-blend-multiply filter blur-[100px] opacity-60 animate-orb-float pointer-events-none"></div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-6 relative z-10">
|
||||
<h2 class="text-5xl font-headline font-black mb-16 tracking-tight uppercase text-transparent bg-clip-text bg-gradient-to-r from-on-surface via-on-surface-variant to-primary">MEILENSTEINE DES <span class="text-primary italic">ERFOLGS</span></h2>
|
||||
|
||||
<!-- Timeline Layout -->
|
||||
<div class="relative pl-12 md:pl-20 space-y-24">
|
||||
<!-- Vertical glowing timeline line -->
|
||||
<div class="absolute left-4 md:left-8 top-12 bottom-0 w-1 bg-gradient-to-b from-primary via-belt-green to-primary opacity-30 blur-[2px]"></div>
|
||||
<div class="absolute left-[1.1rem] md:left-[2.1rem] top-12 bottom-0 w-[2px] bg-gradient-to-b from-primary via-belt-green to-primary opacity-60"></div>
|
||||
|
||||
{{ $mainErfolge := (where (where .Site.RegularPages "Section" "erfolge") "Params.is_weitere_auszeichnung" "!=" "true").ByWeight }}
|
||||
{{ $totalErfolge := len $mainErfolge }}
|
||||
{{ range $index, $element := $mainErfolge }}
|
||||
|
||||
<div class="relative group opacity-0 translate-y-12 transition-all duration-1000 ease-out scroll-reveal-item">
|
||||
<!-- Timeline Node / Glowing Dot -->
|
||||
<div class="absolute -left-[3.5rem] md:-left-[5.5rem] top-12 w-6 h-6 rounded-full bg-surface-container-lowest border-4 border-primary z-10 group-hover:border-belt-green group-hover:shadow-[0_0_15px_rgba(16,185,129,1),0_0_5px_rgba(16,185,129,1)] transition-all duration-500 group-hover:scale-125"></div>
|
||||
|
||||
<!-- The Card -->
|
||||
<div class="erfolg-card flex flex-col {{ if modBool $index 2 }}md:flex-row{{ else }}md:flex-row-reverse{{ end }} bg-surface-container-lowest rounded-3xl editorial-shadow relative overflow-hidden transition-all duration-500 hover:shadow-[0_0_25px_rgba(16,185,129,0.4)] hover:-translate-y-2 group-hover:ring-2 group-hover:ring-belt-green">
|
||||
|
||||
{{ if .Params.image }}
|
||||
<!-- Bild-Bereich -->
|
||||
|
|
@ -198,6 +236,46 @@
|
|||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Interaktionsbereich: Likes & Kommentare -->
|
||||
<div class="mt-8 pt-8 border-t border-outline-variant/20 space-y-6 px-10 pb-8 erfolg-interactions" data-erfolg-id="{{ .File.TranslationBaseName }}">
|
||||
<!-- Likes & Kommentare Header -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<button onclick="likeErfolg('{{ .File.TranslationBaseName }}', this)" class="like-btn flex items-center gap-3 px-6 py-3 rounded-full bg-primary/5 hover:bg-primary/10 text-primary transition-all duration-300 transform active:scale-95 group/like">
|
||||
<span class="material-symbols-outlined heart-icon transition-transform duration-300 group-hover/like:scale-120" style="font-variation-settings: 'FILL' 0;">favorite</span>
|
||||
<span class="font-headline font-bold text-sm tracking-wide"><span class="like-count">0</span> Likes</span>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2.5 text-on-surface-variant text-sm font-bold font-headline uppercase tracking-wider bg-surface-container-low px-5 py-3 rounded-full">
|
||||
<span class="material-symbols-outlined text-base">forum</span>
|
||||
<span><span class="comment-count">0</span> Kommentare</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kommentare Liste -->
|
||||
<div class="comment-list space-y-4 max-h-[350px] overflow-y-auto pr-2 hidden">
|
||||
<!-- Dynamisch geladen -->
|
||||
</div>
|
||||
|
||||
<!-- Neuen Kommentar schreiben -->
|
||||
<form onsubmit="submitComment('{{ .File.TranslationBaseName }}', this, event)" class="comment-form space-y-4 bg-surface-container-low p-6 rounded-2xl relative overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-2 h-full bg-gradient-to-b from-primary to-primary-container"></div>
|
||||
<h4 class="text-sm font-bold font-label uppercase tracking-widest text-on-surface-variant mb-1">Deine Geschichte teilen / Kommentar schreiben</h4>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<div class="md:col-span-2 space-y-4">
|
||||
<input required name="name" type="text" placeholder="Dein Name" class="w-full bg-surface-container-lowest border-none rounded-lg focus:ring-2 focus:ring-primary/20 p-4 text-sm transition-all placeholder:text-outline-variant" />
|
||||
<textarea required name="text" rows="2" placeholder="Schreib einen kurzen Kommentar..." class="w-full bg-surface-container-lowest border-none rounded-lg focus:ring-2 focus:ring-primary/20 p-4 text-sm transition-all placeholder:text-outline-variant resize-none"></textarea>
|
||||
</div>
|
||||
<div class="h-full flex items-end">
|
||||
<button type="submit" class="w-full h-[52px] bg-gradient-to-r from-primary to-primary-container text-on-primary rounded-lg font-headline font-bold shadow-lg shadow-primary/20 hover:scale-[1.02] active:scale-98 transition-all flex items-center justify-center gap-2">
|
||||
<span class="material-symbols-outlined text-sm">send</span>
|
||||
<span>Kommentieren</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -205,7 +283,20 @@
|
|||
{{ if not .Params.image }}
|
||||
<span class="material-symbols-outlined absolute -bottom-10 -right-10 text-[20rem] text-surface-container opacity-30 group-hover:opacity-40 transition-opacity pointer-events-none">workspace_premium</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div> <!-- Ende erfolg-card -->
|
||||
|
||||
<!-- Karate Trenner (nicht beim letzten Element) -->
|
||||
{{ if lt (add $index 1) $totalErfolge }}
|
||||
<div class="absolute -bottom-[3rem] left-8 right-8 flex items-center justify-center opacity-60 z-0 pointer-events-none">
|
||||
<div class="h-[2px] bg-gradient-to-r from-transparent via-primary to-transparent flex-grow opacity-50"></div>
|
||||
<div class="px-4 text-primary bg-surface-container-low rounded-full border border-primary/20 shadow-[0_0_15px_rgba(179,0,101,0.2)] flex items-center justify-center h-8">
|
||||
<span class="material-symbols-outlined text-2xl">sports_martial_arts</span>
|
||||
</div>
|
||||
<div class="h-[2px] bg-gradient-to-r from-primary via-transparent to-transparent flex-grow opacity-50"></div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
</div> <!-- Ende relative group -->
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -217,15 +308,24 @@
|
|||
var body = card.querySelector('.erfolg-body');
|
||||
var icon = btn.querySelector('.material-symbols-outlined');
|
||||
var label = btn.querySelector('.btn-label');
|
||||
var isOpen = body.style.maxHeight && body.style.maxHeight !== '0px';
|
||||
var isOpen = body.classList.contains('is-open');
|
||||
if (isOpen) {
|
||||
body.style.maxHeight = body.scrollHeight + 'px';
|
||||
body.offsetHeight; // Force reflow
|
||||
body.style.maxHeight = '0';
|
||||
body.classList.remove('is-open');
|
||||
icon.style.transform = '';
|
||||
label.textContent = 'Mehr lesen';
|
||||
label.textContent = 'Ganze Geschichte lesen';
|
||||
} else {
|
||||
body.style.maxHeight = body.scrollHeight + 'px';
|
||||
body.classList.add('is-open');
|
||||
icon.style.transform = 'rotate(180deg)';
|
||||
label.textContent = 'Weniger anzeigen';
|
||||
setTimeout(function() {
|
||||
if (body.classList.contains('is-open')) {
|
||||
body.style.maxHeight = 'none';
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -235,6 +335,7 @@
|
|||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
|
||||
<!-- Große Karte: Haupt-Meilenstein -->
|
||||
{{ if $e.meilenstein.event }}
|
||||
<div class="md:col-span-2 relative">
|
||||
<!-- Herausfahrendes Bild -->
|
||||
<div class="absolute top-0 left-8 right-8 flex justify-center z-0 h-48 overflow-visible">
|
||||
|
|
@ -264,6 +365,7 @@
|
|||
<span class="material-symbols-outlined absolute -bottom-10 -right-10 text-[15rem] text-surface-container opacity-20 group-hover:opacity-30 transition-opacity">sports_martial_arts</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<!-- Weitere Auszeichnungen -->
|
||||
<div class="bg-primary rounded-xl p-8 text-on-primary flex flex-col gap-8 shadow-2xl shadow-primary/20">
|
||||
|
|
@ -441,5 +543,177 @@ function closeLightbox(e, force) {
|
|||
});
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Intersection Observer for scroll animations
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0.15
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('animate-reveal-up');
|
||||
entry.target.classList.remove('opacity-0', 'translate-y-12');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
document.querySelectorAll('.scroll-reveal-item').forEach(item => {
|
||||
observer.observe(item);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Erfolge Likes & Kommentare System
|
||||
(function() {
|
||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === 'karate' || window.location.hostname.startsWith('192.168.');
|
||||
const apiBase = isLocal ? 'http://' + window.location.hostname + ':3001' : '';
|
||||
|
||||
let erfolgInteractions = {};
|
||||
|
||||
async function loadInteractions() {
|
||||
try {
|
||||
const r = await fetch(apiBase + '/api/erfolge/interactions');
|
||||
if (r.ok) {
|
||||
erfolgInteractions = await r.json();
|
||||
renderAllInteractions();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Interaktionen:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAllInteractions() {
|
||||
document.querySelectorAll('.erfolg-interactions').forEach(container => {
|
||||
const id = container.dataset.erfolgId;
|
||||
const data = erfolgInteractions[id] || { likes: 0, comments: [] };
|
||||
|
||||
// Update counters
|
||||
container.querySelector('.like-count').textContent = data.likes || 0;
|
||||
container.querySelector('.comment-count').textContent = data.comments ? data.comments.length : 0;
|
||||
|
||||
// Highlight liked status if saved in localStorage
|
||||
const likedKeys = JSON.parse(localStorage.getItem('emy_liked_erfolge') || '[]');
|
||||
const heartIcon = container.querySelector('.heart-icon');
|
||||
if (likedKeys.includes(id)) {
|
||||
heartIcon.style.fontVariationSettings = "'FILL' 1";
|
||||
heartIcon.classList.add('text-primary');
|
||||
heartIcon.classList.remove('group-hover/like:scale-120'); // static filled look
|
||||
} else {
|
||||
heartIcon.style.fontVariationSettings = "'FILL' 0";
|
||||
heartIcon.classList.remove('text-primary');
|
||||
}
|
||||
|
||||
// Render comment list
|
||||
const list = container.querySelector('.comment-list');
|
||||
if (data.comments && data.comments.length > 0) {
|
||||
list.classList.remove('hidden');
|
||||
list.innerHTML = data.comments.map(c => {
|
||||
const letter = c.name ? c.name.charAt(0).toUpperCase() : '?';
|
||||
const dateObj = new Date(c.date);
|
||||
const dateStr = dateObj.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
return `
|
||||
<div class="bg-surface-container-lowest p-4 rounded-xl shadow-sm border border-outline-variant/10">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center font-bold text-sm uppercase">${letter}</div>
|
||||
<span class="font-bold text-sm text-on-surface">${escapeHtml(c.name)}</span>
|
||||
</div>
|
||||
<span class="text-xs text-outline font-medium">${dateStr}</span>
|
||||
</div>
|
||||
<p class="text-sm text-on-surface-variant leading-relaxed pl-10.5">${escapeHtml(c.text)}</p>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
list.classList.add('hidden');
|
||||
list.innerHTML = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
window.likeErfolg = async function(id, btn) {
|
||||
const likedKeys = JSON.parse(localStorage.getItem('emy_liked_erfolge') || '[]');
|
||||
if (likedKeys.includes(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch(apiBase + '/api/erfolge/interactions/like', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
|
||||
if (r.ok) {
|
||||
const res = await r.json();
|
||||
erfolgInteractions[id] = res.data;
|
||||
|
||||
likedKeys.push(id);
|
||||
localStorage.setItem('emy_liked_erfolge', JSON.stringify(likedKeys));
|
||||
|
||||
renderAllInteractions();
|
||||
|
||||
// Heart trigger animation
|
||||
const heart = btn.querySelector('.heart-icon');
|
||||
heart.classList.add('scale-150');
|
||||
setTimeout(() => heart.classList.remove('scale-150'), 300);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Liken:', e);
|
||||
}
|
||||
};
|
||||
|
||||
window.submitComment = async function(id, form, event) {
|
||||
event.preventDefault();
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
const nameInput = form.querySelector('input[name="name"]');
|
||||
const textInput = form.querySelector('textarea[name="text"]');
|
||||
|
||||
const name = nameInput.value;
|
||||
const text = textInput.value;
|
||||
|
||||
if (!name || !text) return;
|
||||
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const r = await fetch(apiBase + '/api/erfolge/interactions/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, name, text })
|
||||
});
|
||||
|
||||
if (r.ok) {
|
||||
const res = await r.json();
|
||||
erfolgInteractions[id] = res.data;
|
||||
textInput.value = '';
|
||||
|
||||
renderAllInteractions();
|
||||
|
||||
const card = form.closest('.erfolg-card');
|
||||
const body = card.querySelector('.erfolg-body');
|
||||
if (body.style.maxHeight !== 'none') {
|
||||
body.style.maxHeight = body.scrollHeight + 'px';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Kommentieren:', e);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadInteractions);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -61,7 +61,9 @@
|
|||
<!-- TopAppBar -->
|
||||
<header class="fixed top-0 left-0 w-full z-50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-lg shadow-xl shadow-pink-500/5">
|
||||
<nav class="flex justify-between items-center h-20 px-6 md:px-12 max-w-screen-2xl mx-auto">
|
||||
<a href="/" class="text-2xl font-black text-pink-600 dark:text-pink-400 italic font-headline tracking-tight uppercase">{{ .Site.Data.homepage.siteTitle | default .Site.Title }}</a>
|
||||
<a href="{{ "/" | relURL }}" class="flex items-center gap-3 text-2xl font-black text-pink-600 dark:text-pink-400 italic font-headline tracking-tight uppercase group hover:opacity-80 transition-opacity">
|
||||
<span>{{ .Site.Data.homepage.siteTitle | default .Site.Title }}</span>
|
||||
</a>
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
<a class="text-zinc-600 dark:text-zinc-400 font-medium font-headline tracking-tight uppercase hover:text-pink-500 transition-colors duration-300" href="/">Home</a>
|
||||
<a class="text-zinc-600 dark:text-zinc-400 font-medium font-headline tracking-tight uppercase hover:text-pink-500 transition-colors duration-300" href="/galerie/">Galerie</a>
|
||||
|
|
@ -108,41 +110,42 @@
|
|||
<span class="material-symbols-outlined text-primary text-4xl">send</span>
|
||||
Nachricht schicken
|
||||
</h2>
|
||||
<form class="space-y-6">
|
||||
<form class="space-y-6" id="gbForm">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-bold font-label uppercase tracking-widest text-on-surface-variant px-1">Dein Name</label>
|
||||
<input class="w-full bg-surface-container-low border-none rounded-sm focus:ring-2 focus:ring-primary/20 p-4 transition-all placeholder:text-outline-variant" placeholder="Mia Muster" type="text"/>
|
||||
<input id="gbName" required class="w-full bg-surface-container-low border-none rounded-sm focus:ring-2 focus:ring-primary/20 p-4 transition-all placeholder:text-outline-variant" placeholder="Mia Muster" type="text"/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-bold font-label uppercase tracking-widest text-on-surface-variant px-1">E-Mail-Adresse</label>
|
||||
<input class="w-full bg-surface-container-low border-none rounded-sm focus:ring-2 focus:ring-primary/20 p-4 transition-all placeholder:text-outline-variant" placeholder="hallo@beispiel.de" type="email"/>
|
||||
<input id="gbEmail" required class="w-full bg-surface-container-low border-none rounded-sm focus:ring-2 focus:ring-primary/20 p-4 transition-all placeholder:text-outline-variant" placeholder="hallo@beispiel.de" type="email"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-bold font-label uppercase tracking-widest text-on-surface-variant px-1">Betreff</label>
|
||||
<div class="flex flex-wrap gap-3 py-2">
|
||||
<label class="cursor-pointer">
|
||||
<input checked class="hidden peer" name="subject" type="radio"/>
|
||||
<input checked class="hidden peer" name="subject" type="radio" value="Allgemeine Frage"/>
|
||||
<span class="px-6 py-2 rounded-full border-2 border-surface-container-high peer-checked:bg-secondary peer-checked:text-on-secondary peer-checked:border-secondary transition-all text-sm font-bold">Allgemeine Frage</span>
|
||||
</label>
|
||||
<label class="cursor-pointer">
|
||||
<input class="hidden peer" name="subject" type="radio"/>
|
||||
<input class="hidden peer" name="subject" type="radio" value="Trainingstipps"/>
|
||||
<span class="px-6 py-2 rounded-full border-2 border-surface-container-high peer-checked:bg-secondary peer-checked:text-on-secondary peer-checked:border-secondary transition-all text-sm font-bold">Trainingstipps</span>
|
||||
</label>
|
||||
<label class="cursor-pointer">
|
||||
<input class="hidden peer" name="subject" type="radio"/>
|
||||
<input class="hidden peer" name="subject" type="radio" value="Sonstiges"/>
|
||||
<span class="px-6 py-2 rounded-full border-2 border-surface-container-high peer-checked:bg-secondary peer-checked:text-on-secondary peer-checked:border-secondary transition-all text-sm font-bold">Sonstiges</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-bold font-label uppercase tracking-widest text-on-surface-variant px-1">Nachricht</label>
|
||||
<textarea class="w-full bg-surface-container-low border-none rounded-sm focus:ring-2 focus:ring-primary/20 p-4 transition-all placeholder:text-outline-variant resize-none" placeholder="Schreib deine Nachricht hier..." rows="5"></textarea>
|
||||
<textarea id="gbText" required class="w-full bg-surface-container-low border-none rounded-sm focus:ring-2 focus:ring-primary/20 p-4 transition-all placeholder:text-outline-variant resize-none" placeholder="Schreib deine Nachricht hier..." rows="5"></textarea>
|
||||
</div>
|
||||
<button class="w-full bg-gradient-to-r from-primary to-primary-container text-on-primary py-5 rounded-xl font-headline text-xl font-bold shadow-xl shadow-primary/30 transition-transform active:scale-95">
|
||||
<button id="gbSubmitBtn" type="submit" class="w-full bg-gradient-to-r from-primary to-primary-container text-on-primary py-5 rounded-xl font-headline text-xl font-bold shadow-xl shadow-primary/30 transition-transform active:scale-95 disabled:opacity-50">
|
||||
Nachricht absenden
|
||||
</button>
|
||||
<p id="gbStatus" class="text-center text-sm font-bold hidden"></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -167,7 +170,7 @@
|
|||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase opacity-60">Dojo</p>
|
||||
<p class="font-bold">{{ $gb.kontakt.dojo }}</p>
|
||||
<p class="font-bold">{{ $gb.kontakt.dojo | safeHTML }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -247,5 +250,45 @@
|
|||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
document.getElementById('gbForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('gbSubmitBtn');
|
||||
const status = document.getElementById('gbStatus');
|
||||
const name = document.getElementById('gbName').value;
|
||||
const email = document.getElementById('gbEmail').value;
|
||||
const text = document.getElementById('gbText').value;
|
||||
const subject = document.querySelector('input[name="subject"]:checked').value;
|
||||
|
||||
btn.disabled = true;
|
||||
status.classList.remove('hidden', 'text-error', 'text-green-600');
|
||||
status.textContent = 'Wird gesendet...';
|
||||
|
||||
// API URL bestimmen (Nutzt jetzt den Nginx Proxy auf der gleichen Domain)
|
||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === 'karate' || window.location.hostname.startsWith('192.168.');
|
||||
const finalUrl = isLocal ? 'http://' + window.location.hostname + ':3001/api/gaestebuch/public' : '/api/gaestebuch/public';
|
||||
|
||||
try {
|
||||
const r = await fetch(finalUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, text, subject })
|
||||
});
|
||||
|
||||
if (r.ok) {
|
||||
status.textContent = '✅ Danke! Deine Nachricht wurde gespeichert und erscheint in Kürze.';
|
||||
status.classList.add('text-green-600');
|
||||
document.getElementById('gbForm').reset();
|
||||
} else {
|
||||
throw new Error('Server-Fehler');
|
||||
}
|
||||
} catch(e) {
|
||||
status.textContent = '❌ Fehler beim Senden. Bitte versuche es später nochmal.';
|
||||
status.classList.add('text-error');
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Galerie | {{ .Site.Data.homepage.siteTitle | default .Site.Title }}</title>
|
||||
<title>Galerie | {{ .Site.Data.homepage.siteTitle | default .Site.Title }} verdammt</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;600;700;800;900&family=Be+Vietnam+Pro:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
|
|
@ -61,7 +61,9 @@
|
|||
<!-- TopAppBar -->
|
||||
<header class="fixed top-0 left-0 w-full z-50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-lg shadow-xl shadow-pink-500/5">
|
||||
<div class="flex justify-between items-center h-20 px-6 md:px-12 max-w-screen-2xl mx-auto">
|
||||
<a href="/" class="text-2xl font-black text-pink-600 dark:text-pink-400 italic font-headline tracking-tight uppercase">{{ .Site.Data.homepage.siteTitle | default .Site.Title }}</a>
|
||||
<a href="{{ "/" | relURL }}" class="flex items-center gap-3 text-2xl font-black text-pink-600 dark:text-pink-400 italic font-headline tracking-tight uppercase group hover:opacity-80 transition-opacity">
|
||||
<span>{{ .Site.Data.homepage.siteTitle | default .Site.Title }}</span>
|
||||
</a>
|
||||
<nav class="hidden md:flex items-center gap-8">
|
||||
<a class="text-zinc-600 dark:text-zinc-400 font-medium font-headline tracking-tight uppercase hover:text-pink-500 transition-colors duration-300" href="/">Home</a>
|
||||
<a class="text-pink-600 dark:text-pink-400 border-b-2 border-pink-500 pb-1 font-headline tracking-tight uppercase font-bold" href="/galerie/">Galerie</a>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<title>{{ .Title }} | EmyKarate</title>
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect"/>
|
||||
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700;800;900&family=Be+Vietnam+Pro:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700;800;900&family=Be+Vietnam+Pro:wght@300;400;500;600;700&family=Noto+Serif+JP:wght@900&family=Yuji+Syuku&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script id="tailwind-config">
|
||||
|
|
@ -76,7 +76,9 @@
|
|||
"fontFamily": {
|
||||
"headline": ["Lexend"],
|
||||
"body": ["Be Vietnam Pro"],
|
||||
"label": ["Be Vietnam Pro"]
|
||||
"label": ["Be Vietnam Pro"],
|
||||
"japanese": ["Noto Serif JP", "serif"],
|
||||
"calligraphy": ["Yuji Syuku", "serif"]
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
@ -96,9 +98,9 @@
|
|||
<!-- TopAppBar -->
|
||||
<nav class="fixed top-0 left-0 w-full z-50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-lg shadow-xl shadow-pink-500/5">
|
||||
<div class="flex justify-between items-center h-20 px-6 md:px-12 max-w-screen-2xl mx-auto">
|
||||
<div class="text-2xl font-black text-pink-600 dark:text-pink-400 italic font-headline tracking-tight uppercase">
|
||||
{{ .Site.Data.homepage.siteTitle | default .Site.Title }}
|
||||
</div>
|
||||
<a href="{{ "/" | relURL }}" class="flex items-center gap-3 text-2xl font-black text-pink-600 dark:text-pink-400 italic font-headline tracking-tight uppercase group hover:opacity-80 transition-opacity">
|
||||
<span>{{ .Site.Data.homepage.siteTitle | default .Site.Title }}</span>
|
||||
</a>
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
<a class="text-pink-600 dark:text-pink-400 border-b-2 border-pink-500 pb-1 font-headline tracking-tight uppercase font-bold transition-colors duration-300" href="{{ "/" | relURL }}">Home</a>
|
||||
<a class="text-zinc-600 dark:text-zinc-400 font-medium font-headline tracking-tight uppercase hover:text-pink-500 dark:hover:text-pink-300 transition-colors duration-300" href="{{ "/galerie/" | relURL }}">Galerie</a>
|
||||
|
|
@ -132,26 +134,33 @@
|
|||
<!-- Hero Section -->
|
||||
<section class="relative min-h-[921px] flex items-center overflow-hidden bg-surface">
|
||||
<div class="absolute -right-20 top-20 w-[600px] h-[600px] bg-secondary-container/20 rounded-full blur-[120px]"></div>
|
||||
<div class="max-w-screen-2xl mx-auto px-6 md:px-12 grid grid-cols-1 lg:grid-cols-12 gap-12 items-center relative z-10">
|
||||
<div class="lg:col-span-6 order-2 lg:order-1">
|
||||
<span class="inline-block px-4 py-1 rounded-full bg-secondary-container text-on-secondary-container text-sm font-bold tracking-widest uppercase mb-6 font-label">{{ .Site.Data.homepage.hero.badge }}</span>
|
||||
<h1 class="text-6xl md:text-8xl font-headline font-black text-on-surface leading-[0.9] editorial-text-shadow mb-8 italic">
|
||||
{{ .Site.Title }}
|
||||
</h1>
|
||||
<p class="text-xl text-on-surface-variant max-w-lg mb-10 leading-relaxed font-body">
|
||||
{{ .Site.Data.homepage.hero.description }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<a href="{{ "/uebermich/" | relURL }}" class="bg-gradient-to-br from-primary to-primary-container text-on-primary px-10 py-5 rounded-xl font-bold font-headline text-lg shadow-xl shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-center">
|
||||
Erfahre mehr
|
||||
</a>
|
||||
<a href="{{ "/galerie/" | relURL }}" class="px-10 py-5 rounded-xl font-bold font-headline text-lg text-primary border-2 border-primary/20 hover:bg-primary/5 transition-all text-center">
|
||||
Galerie ansehen
|
||||
</a>
|
||||
<div class="max-w-screen-2xl mx-auto px-6 md:px-12 grid grid-cols-1 lg:grid-cols-12 gap-12 items-center relative z-10 pt-20 md:pt-32">
|
||||
<div class="lg:col-span-6 order-2 lg:order-1 relative">
|
||||
<div class="relative mt-6 md:mt-10">
|
||||
<span class="inline-block px-4 py-1 rounded-full bg-secondary-container text-on-secondary-container text-sm font-bold tracking-widest uppercase mb-6 font-label">{{ .Site.Data.homepage.hero.badge }}</span>
|
||||
<!-- Logo schwebend zwischen Text und Bild, ausgerichtet am Schriftzug -->
|
||||
<img src="/hp_karate_logo.png" alt="Karate Logo" class="absolute right-0 lg:-right-4 top-14 md:top-18 h-20 w-20 md:h-32 md:w-32 object-contain hover:scale-110 hover:rotate-[-5deg] transition-all duration-500 z-30 drop-shadow-2xl">
|
||||
<h1 class="text-5xl md:text-6xl lg:text-[4.5rem] font-japanese font-black text-on-surface leading-[1.1] editorial-text-shadow mb-2">
|
||||
エミー<br>空手
|
||||
</h1>
|
||||
<p class="text-sm md:text-base font-bold text-primary uppercase tracking-widest mb-10 font-label">
|
||||
[ Emy Karate ]
|
||||
</p>
|
||||
<p class="text-xl text-on-surface-variant max-w-lg mb-10 leading-relaxed font-body lg:pr-20">
|
||||
{{ .Site.Data.homepage.hero.description }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<a href="{{ "/uebermich/" | relURL }}" class="bg-gradient-to-br from-primary to-primary-container text-on-primary px-10 py-5 rounded-xl font-bold font-headline text-lg shadow-xl shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-center">
|
||||
Erfahre mehr
|
||||
</a>
|
||||
<a href="{{ "/galerie/" | relURL }}" class="px-10 py-5 rounded-xl font-bold font-headline text-lg text-primary border-2 border-primary/20 hover:bg-primary/5 transition-all text-center">
|
||||
Galerie ansehen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:col-span-6 order-1 lg:order-2 relative">
|
||||
<div class="relative w-full aspect-square rounded-xl overflow-hidden shadow-2xl shadow-primary/10 rotate-3 hover:rotate-0 transition-transform duration-700">
|
||||
<div class="lg:col-span-6 order-1 lg:order-2 relative z-20">
|
||||
<div class="relative w-full lg:max-w-[460px] lg:ml-auto aspect-square rounded-xl overflow-hidden shadow-2xl shadow-primary/10 rotate-3 hover:rotate-0 transition-transform duration-700 lg:-mt-12">
|
||||
{{ if .Site.Data.homepage.hero.image }}
|
||||
<img alt="Miya beim Karate-Tritt" class="w-full h-full object-cover" src="/hero/{{ .Site.Data.homepage.hero.image }}"/>
|
||||
{{ else }}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,9 @@
|
|||
<!-- TopAppBar -->
|
||||
<header class="bg-white/70 dark:bg-zinc-900/70 backdrop-blur-lg fixed top-0 left-0 w-full z-50 shadow-xl shadow-pink-500/5">
|
||||
<div class="flex justify-between items-center h-20 px-6 md:px-12 max-w-screen-2xl mx-auto">
|
||||
<a href="/" class="text-2xl font-black text-pink-600 dark:text-pink-400 italic font-headline tracking-tight uppercase">{{ .Site.Data.homepage.siteTitle | default .Site.Title }}</a>
|
||||
<a href="{{ "/" | relURL }}" class="flex items-center gap-3 text-2xl font-black text-pink-600 dark:text-pink-400 italic font-headline tracking-tight uppercase group hover:opacity-80 transition-opacity">
|
||||
<span>{{ .Site.Data.homepage.siteTitle | default .Site.Title }}</span>
|
||||
</a>
|
||||
<nav class="hidden md:flex items-center space-x-8 font-headline tracking-tight uppercase font-bold">
|
||||
<a class="text-zinc-600 dark:text-zinc-400 font-medium hover:text-pink-500 dark:hover:text-pink-300 transition-colors duration-300" href="/">Home</a>
|
||||
<a class="text-zinc-600 dark:text-zinc-400 font-medium hover:text-pink-500 dark:hover:text-pink-300 transition-colors duration-300" href="/galerie/">Galerie</a>
|
||||
|
|
@ -98,7 +100,7 @@
|
|||
<div class="lg:col-span-5 relative">
|
||||
<div class="aspect-[4/5] rounded-xl overflow-hidden editorial-shadow bg-surface-container-highest">
|
||||
{{ if $um.hero.image }}
|
||||
<img class="w-full h-full object-cover" src="/uebermich/{{ $um.hero.image }}" alt="{{ $um.hero.image_alt }}"/>
|
||||
<img class="w-full h-full object-cover" src="/uebermich-img/{{ $um.hero.image }}" alt="{{ $um.hero.image_alt }}"/>
|
||||
{{ else }}
|
||||
<div class="w-full h-full flex items-center justify-center text-on-surface-variant text-sm">Kein Bild hochgeladen</div>
|
||||
{{ end }}
|
||||
|
|
@ -134,9 +136,17 @@
|
|||
<div class="flex flex-wrap justify-between gap-4">
|
||||
{{ range $um.gurtweg.gurte }}
|
||||
{{ $aktiv := eq .name $um.gurtweg.aktiver_gurt }}
|
||||
<div class="flex flex-col items-center gap-4 {{ if not $aktiv }}{{ if eq .name "Schwarz" }}opacity-10{{ else if eq .name "Braun" }}opacity-20{{ else if eq .name "Lila" }}opacity-30{{ else if eq .name "Grün" }}opacity-50{{ else }}opacity-40{{ end }}{{ end }}">
|
||||
<div class="w-24 {{ if $aktiv }}h-6 shadow-lg ring-4 ring-primary/20{{ else }}h-4{{ end }} {{ .farbe }} rounded-full"></div>
|
||||
<span class="font-bold text-xs uppercase tracking-tighter {{ if $aktiv }}text-primary{{ end }}">{{ .name }}{{ if $aktiv }} ✓{{ end }}</span>
|
||||
<div class="group relative flex flex-col items-center gap-4 cursor-pointer {{ if not $aktiv }}{{ if eq .name "Schwarz" }}opacity-10{{ else if eq .name "Braun" }}opacity-20{{ else if eq .name "Lila" }}opacity-30{{ else if eq .name "Blau" }}opacity-40{{ else }}opacity-70{{ end }}{{ end }} hover:!opacity-100 transition-all duration-300">
|
||||
<div class="w-24 {{ if $aktiv }}h-6 shadow-[0_0_20px_rgba(16,185,129,0.5)] ring-4 ring-belt-green/30{{ else }}h-4{{ end }} {{ .farbe }} rounded-full transition-transform duration-300 group-hover:scale-110"></div>
|
||||
<span class="font-bold text-xs uppercase tracking-tighter transition-colors {{ if $aktiv }}text-primary drop-shadow-[0_0_10px_rgba(179,0,101,0.5)]{{ else }}group-hover:text-primary{{ end }}">{{ .name }}{{ if $aktiv }} ✓{{ end }}</span>
|
||||
|
||||
<!-- Tooltip Popup -->
|
||||
<div class="absolute bottom-full mb-4 w-56 p-4 bg-zinc-900/95 dark:bg-white/95 backdrop-blur-md text-white dark:text-zinc-900 text-xs rounded-2xl shadow-2xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-300 z-20 pointer-events-none scale-90 group-hover:scale-100 origin-bottom border border-white/10 dark:border-black/10">
|
||||
<div class="font-headline font-bold text-sm mb-2 text-primary">{{ .name }}-Gürtel</div>
|
||||
<div class="opacity-90 leading-relaxed">{{ .geschichte }}</div>
|
||||
<!-- Popup Pfeil -->
|
||||
<div class="absolute -bottom-2 left-1/2 -translate-x-1/2 border-[6px] border-transparent border-t-zinc-900/95 dark:border-t-white/95"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
|
|
|||
BIN
node_modules/.DS_Store
generated
vendored
BIN
node_modules/.DS_Store
generated
vendored
Binary file not shown.
28
node_modules/.package-lock.json
generated
vendored
28
node_modules/.package-lock.json
generated
vendored
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "miyakarate",
|
||||
"name": "EmyKarate",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
|
|
@ -204,6 +204,23 @@
|
|||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -666,6 +683,15 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
|
|
|
|||
22
node_modules/cors/LICENSE
generated
vendored
Normal file
22
node_modules/cors/LICENSE
generated
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
(The MIT License)
|
||||
|
||||
Copyright (c) 2013 Troy Goode <troygoode@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
'Software'), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
277
node_modules/cors/README.md
generated
vendored
Normal file
277
node_modules/cors/README.md
generated
vendored
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
# cors
|
||||
|
||||
[![NPM Version][npm-image]][npm-url]
|
||||
[![NPM Downloads][downloads-image]][downloads-url]
|
||||
[![Build Status][github-actions-ci-image]][github-actions-ci-url]
|
||||
[![Test Coverage][coveralls-image]][coveralls-url]
|
||||
|
||||
CORS is a [Node.js](https://nodejs.org/en/) middleware for [Express](https://expressjs.com/)/[Connect](https://github.com/senchalabs/connect) that sets [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) response headers. These headers tell browsers which origins can read responses from your server.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **How CORS Works:** This package sets response headers—it doesn't block requests. CORS is enforced by browsers: they check the headers and decide if JavaScript can read the response. Non-browser clients (curl, Postman, other servers) ignore CORS entirely. See the [MDN CORS guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) for details.
|
||||
|
||||
* [Installation](#installation)
|
||||
* [Usage](#usage)
|
||||
* [Simple Usage](#simple-usage-enable-all-cors-requests)
|
||||
* [Enable CORS for a Single Route](#enable-cors-for-a-single-route)
|
||||
* [Configuring CORS](#configuring-cors)
|
||||
* [Configuring CORS w/ Dynamic Origin](#configuring-cors-w-dynamic-origin)
|
||||
* [Enabling CORS Pre-Flight](#enabling-cors-pre-flight)
|
||||
* [Customizing CORS Settings Dynamically per Request](#customizing-cors-settings-dynamically-per-request)
|
||||
* [Configuration Options](#configuration-options)
|
||||
* [Common Misconceptions](#common-misconceptions)
|
||||
* [License](#license)
|
||||
* [Original Author](#original-author)
|
||||
|
||||
## Installation
|
||||
|
||||
This is a [Node.js](https://nodejs.org/en/) module available through the
|
||||
[npm registry](https://www.npmjs.com/). Installation is done using the
|
||||
[`npm install` command](https://docs.npmjs.com/downloading-and-installing-packages-locally):
|
||||
|
||||
```sh
|
||||
$ npm install cors
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Simple Usage (Enable *All* CORS Requests)
|
||||
|
||||
```javascript
|
||||
var express = require('express')
|
||||
var cors = require('cors')
|
||||
var app = express()
|
||||
|
||||
// Adds headers: Access-Control-Allow-Origin: *
|
||||
app.use(cors())
|
||||
|
||||
app.get('/products/:id', function (req, res, next) {
|
||||
res.json({msg: 'Hello'})
|
||||
})
|
||||
|
||||
app.listen(80, function () {
|
||||
console.log('web server listening on port 80')
|
||||
})
|
||||
```
|
||||
|
||||
### Enable CORS for a Single Route
|
||||
|
||||
```javascript
|
||||
var express = require('express')
|
||||
var cors = require('cors')
|
||||
var app = express()
|
||||
|
||||
// Adds headers: Access-Control-Allow-Origin: *
|
||||
app.get('/products/:id', cors(), function (req, res, next) {
|
||||
res.json({msg: 'Hello'})
|
||||
})
|
||||
|
||||
app.listen(80, function () {
|
||||
console.log('web server listening on port 80')
|
||||
})
|
||||
```
|
||||
|
||||
### Configuring CORS
|
||||
|
||||
See the [configuration options](#configuration-options) for details.
|
||||
|
||||
```javascript
|
||||
var express = require('express')
|
||||
var cors = require('cors')
|
||||
var app = express()
|
||||
|
||||
var corsOptions = {
|
||||
origin: 'http://example.com',
|
||||
optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204
|
||||
}
|
||||
|
||||
// Adds headers: Access-Control-Allow-Origin: http://example.com, Vary: Origin
|
||||
app.get('/products/:id', cors(corsOptions), function (req, res, next) {
|
||||
res.json({msg: 'Hello'})
|
||||
})
|
||||
|
||||
app.listen(80, function () {
|
||||
console.log('web server listening on port 80')
|
||||
})
|
||||
```
|
||||
|
||||
### Configuring CORS w/ Dynamic Origin
|
||||
|
||||
This module supports validating the origin dynamically using a function provided
|
||||
to the `origin` option. This function will be passed a string that is the origin
|
||||
(or `undefined` if the request has no origin), and a `callback` with the signature
|
||||
`callback(error, origin)`.
|
||||
|
||||
The `origin` argument to the callback can be any value allowed for the `origin`
|
||||
option of the middleware, except a function. See the
|
||||
[configuration options](#configuration-options) section for more information on all
|
||||
the possible value types.
|
||||
|
||||
This function is designed to allow the dynamic loading of allowed origin(s) from
|
||||
a backing datasource, like a database.
|
||||
|
||||
```javascript
|
||||
var express = require('express')
|
||||
var cors = require('cors')
|
||||
var app = express()
|
||||
|
||||
var corsOptions = {
|
||||
origin: function (origin, callback) {
|
||||
// db.loadOrigins is an example call to load
|
||||
// a list of origins from a backing database
|
||||
db.loadOrigins(function (error, origins) {
|
||||
callback(error, origins)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Adds headers: Access-Control-Allow-Origin: <matched origin>, Vary: Origin
|
||||
app.get('/products/:id', cors(corsOptions), function (req, res, next) {
|
||||
res.json({msg: 'Hello'})
|
||||
})
|
||||
|
||||
app.listen(80, function () {
|
||||
console.log('web server listening on port 80')
|
||||
})
|
||||
```
|
||||
|
||||
### Enabling CORS Pre-Flight
|
||||
|
||||
Certain CORS requests are considered 'complex' and require an initial
|
||||
`OPTIONS` request (called the "pre-flight request"). An example of a
|
||||
'complex' CORS request is one that uses an HTTP verb other than
|
||||
GET/HEAD/POST (such as DELETE) or that uses custom headers. To enable
|
||||
pre-flighting, you must add a new OPTIONS handler for the route you want
|
||||
to support:
|
||||
|
||||
```javascript
|
||||
var express = require('express')
|
||||
var cors = require('cors')
|
||||
var app = express()
|
||||
|
||||
app.options('/products/:id', cors()) // preflight for DELETE
|
||||
app.del('/products/:id', cors(), function (req, res, next) {
|
||||
res.json({msg: 'Hello'})
|
||||
})
|
||||
|
||||
app.listen(80, function () {
|
||||
console.log('web server listening on port 80')
|
||||
})
|
||||
```
|
||||
|
||||
You can also enable pre-flight across-the-board like so:
|
||||
|
||||
```javascript
|
||||
app.options('*', cors()) // include before other routes
|
||||
```
|
||||
|
||||
NOTE: When using this middleware as an application level middleware (for
|
||||
example, `app.use(cors())`), pre-flight requests are already handled for all
|
||||
routes.
|
||||
|
||||
### Customizing CORS Settings Dynamically per Request
|
||||
|
||||
For APIs that require different CORS configurations for specific routes or requests, you can dynamically generate CORS options based on the incoming request. The `cors` middleware allows you to achieve this by passing a function instead of static options. This function is called for each incoming request and must use the callback pattern to return the appropriate CORS options.
|
||||
|
||||
The function accepts:
|
||||
1. **`req`**:
|
||||
- The incoming request object.
|
||||
|
||||
2. **`callback(error, corsOptions)`**:
|
||||
- A function used to return the computed CORS options.
|
||||
- **Arguments**:
|
||||
- **`error`**: Pass `null` if there’s no error, or an error object to indicate a failure.
|
||||
- **`corsOptions`**: An object specifying the CORS policy for the current request.
|
||||
|
||||
Here’s an example that handles both public routes and restricted, credential-sensitive routes:
|
||||
|
||||
```javascript
|
||||
var dynamicCorsOptions = function(req, callback) {
|
||||
var corsOptions;
|
||||
if (req.path.startsWith('/auth/connect/')) {
|
||||
// Access-Control-Allow-Origin: http://mydomain.com, Access-Control-Allow-Credentials: true, Vary: Origin
|
||||
corsOptions = {
|
||||
origin: 'http://mydomain.com',
|
||||
credentials: true
|
||||
};
|
||||
} else {
|
||||
// Access-Control-Allow-Origin: *
|
||||
corsOptions = { origin: '*' };
|
||||
}
|
||||
callback(null, corsOptions);
|
||||
};
|
||||
|
||||
app.use(cors(dynamicCorsOptions));
|
||||
|
||||
app.get('/auth/connect/twitter', function (req, res) {
|
||||
res.send('Hello');
|
||||
});
|
||||
|
||||
app.get('/public', function (req, res) {
|
||||
res.send('Hello');
|
||||
});
|
||||
|
||||
app.listen(80, function () {
|
||||
console.log('web server listening on port 80')
|
||||
})
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
* `origin`: Configures the **Access-Control-Allow-Origin** CORS header. Possible values:
|
||||
- `Boolean` - set `origin` to `true` to reflect the [request origin](https://datatracker.ietf.org/doc/html/draft-abarth-origin-09), as defined by `req.header('Origin')`, or set it to `false` to disable CORS.
|
||||
- `String` - set `origin` to a specific origin. For example, if you set it to
|
||||
- `"http://example.com"` only requests from "http://example.com" will be allowed.
|
||||
- `"*"` for all domains to be allowed.
|
||||
- `RegExp` - set `origin` to a regular expression pattern which will be used to test the request origin. If it's a match, the request origin will be reflected. For example the pattern `/example\.com$/` will reflect any request that is coming from an origin ending with "example.com".
|
||||
- `Array` - set `origin` to an array of valid origins. Each origin can be a `String` or a `RegExp`. For example `["http://example1.com", /\.example2\.com$/]` will accept any request from "http://example1.com" or from a subdomain of "example2.com".
|
||||
- `Function` - set `origin` to a function implementing some custom logic. The function takes the request origin as the first parameter and a callback (called as `callback(err, origin)`, where `origin` is a non-function value of the `origin` option) as the second.
|
||||
* `methods`: Configures the **Access-Control-Allow-Methods** CORS header. Expects a comma-delimited string (ex: 'GET,PUT,POST') or an array (ex: `['GET', 'PUT', 'POST']`).
|
||||
* `allowedHeaders`: Configures the **Access-Control-Allow-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Type,Authorization') or an array (ex: `['Content-Type', 'Authorization']`). If not specified, defaults to reflecting the headers specified in the request's **Access-Control-Request-Headers** header.
|
||||
* `exposedHeaders`: Configures the **Access-Control-Expose-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Range,X-Content-Range') or an array (ex: `['Content-Range', 'X-Content-Range']`). If not specified, no custom headers are exposed.
|
||||
* `credentials`: Configures the **Access-Control-Allow-Credentials** CORS header. Set to `true` to pass the header, otherwise it is omitted.
|
||||
* `maxAge`: Configures the **Access-Control-Max-Age** CORS header. Set to an integer to pass the header, otherwise it is omitted.
|
||||
* `preflightContinue`: Pass the CORS preflight response to the next handler.
|
||||
* `optionsSuccessStatus`: Provides a status code to use for successful `OPTIONS` requests, since some legacy browsers (IE11, various SmartTVs) choke on `204`.
|
||||
|
||||
The default configuration is the equivalent of:
|
||||
|
||||
```json
|
||||
{
|
||||
"origin": "*",
|
||||
"methods": "GET,HEAD,PUT,PATCH,POST,DELETE",
|
||||
"preflightContinue": false,
|
||||
"optionsSuccessStatus": 204
|
||||
}
|
||||
```
|
||||
|
||||
## Common Misconceptions
|
||||
|
||||
### "CORS blocks requests from disallowed origins"
|
||||
|
||||
**No.** Your server receives and processes every request. CORS headers tell the browser whether JavaScript can read the response—not whether the request is allowed.
|
||||
|
||||
### "CORS protects my API from unauthorized access"
|
||||
|
||||
**No.** CORS is not access control. Any HTTP client (curl, Postman, another server) can call your API regardless of CORS settings. Use authentication and authorization to protect your API.
|
||||
|
||||
### "Setting `origin: 'http://example.com'` means only that domain can access my server"
|
||||
|
||||
**No.** It means browsers will only let JavaScript from that origin read responses. The server still responds to all requests.
|
||||
|
||||
## License
|
||||
|
||||
[MIT License](http://www.opensource.org/licenses/mit-license.php)
|
||||
|
||||
## Original Author
|
||||
|
||||
[Troy Goode](https://github.com/TroyGoode) ([troygoode@gmail.com](mailto:troygoode@gmail.com))
|
||||
|
||||
[coveralls-image]: https://img.shields.io/coveralls/expressjs/cors/master.svg
|
||||
[coveralls-url]: https://coveralls.io/r/expressjs/cors?branch=master
|
||||
[downloads-image]: https://img.shields.io/npm/dm/cors.svg
|
||||
[downloads-url]: https://npmjs.com/package/cors
|
||||
[github-actions-ci-image]: https://img.shields.io/github/actions/workflow/status/expressjs/cors/ci.yml?branch=master&label=ci
|
||||
[github-actions-ci-url]: https://github.com/expressjs/cors?query=workflow%3Aci
|
||||
[npm-image]: https://img.shields.io/npm/v/cors.svg
|
||||
[npm-url]: https://npmjs.com/package/cors
|
||||
238
node_modules/cors/lib/index.js
generated
vendored
Normal file
238
node_modules/cors/lib/index.js
generated
vendored
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
(function () {
|
||||
|
||||
'use strict';
|
||||
|
||||
var assign = require('object-assign');
|
||||
var vary = require('vary');
|
||||
|
||||
var defaults = {
|
||||
origin: '*',
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
preflightContinue: false,
|
||||
optionsSuccessStatus: 204
|
||||
};
|
||||
|
||||
function isString(s) {
|
||||
return typeof s === 'string' || s instanceof String;
|
||||
}
|
||||
|
||||
function isOriginAllowed(origin, allowedOrigin) {
|
||||
if (Array.isArray(allowedOrigin)) {
|
||||
for (var i = 0; i < allowedOrigin.length; ++i) {
|
||||
if (isOriginAllowed(origin, allowedOrigin[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} else if (isString(allowedOrigin)) {
|
||||
return origin === allowedOrigin;
|
||||
} else if (allowedOrigin instanceof RegExp) {
|
||||
return allowedOrigin.test(origin);
|
||||
} else {
|
||||
return !!allowedOrigin;
|
||||
}
|
||||
}
|
||||
|
||||
function configureOrigin(options, req) {
|
||||
var requestOrigin = req.headers.origin,
|
||||
headers = [],
|
||||
isAllowed;
|
||||
|
||||
if (!options.origin || options.origin === '*') {
|
||||
// allow any origin
|
||||
headers.push([{
|
||||
key: 'Access-Control-Allow-Origin',
|
||||
value: '*'
|
||||
}]);
|
||||
} else if (isString(options.origin)) {
|
||||
// fixed origin
|
||||
headers.push([{
|
||||
key: 'Access-Control-Allow-Origin',
|
||||
value: options.origin
|
||||
}]);
|
||||
headers.push([{
|
||||
key: 'Vary',
|
||||
value: 'Origin'
|
||||
}]);
|
||||
} else {
|
||||
isAllowed = isOriginAllowed(requestOrigin, options.origin);
|
||||
// reflect origin
|
||||
headers.push([{
|
||||
key: 'Access-Control-Allow-Origin',
|
||||
value: isAllowed ? requestOrigin : false
|
||||
}]);
|
||||
headers.push([{
|
||||
key: 'Vary',
|
||||
value: 'Origin'
|
||||
}]);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function configureMethods(options) {
|
||||
var methods = options.methods;
|
||||
if (methods.join) {
|
||||
methods = options.methods.join(','); // .methods is an array, so turn it into a string
|
||||
}
|
||||
return {
|
||||
key: 'Access-Control-Allow-Methods',
|
||||
value: methods
|
||||
};
|
||||
}
|
||||
|
||||
function configureCredentials(options) {
|
||||
if (options.credentials === true) {
|
||||
return {
|
||||
key: 'Access-Control-Allow-Credentials',
|
||||
value: 'true'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function configureAllowedHeaders(options, req) {
|
||||
var allowedHeaders = options.allowedHeaders || options.headers;
|
||||
var headers = [];
|
||||
|
||||
if (!allowedHeaders) {
|
||||
allowedHeaders = req.headers['access-control-request-headers']; // .headers wasn't specified, so reflect the request headers
|
||||
headers.push([{
|
||||
key: 'Vary',
|
||||
value: 'Access-Control-Request-Headers'
|
||||
}]);
|
||||
} else if (allowedHeaders.join) {
|
||||
allowedHeaders = allowedHeaders.join(','); // .headers is an array, so turn it into a string
|
||||
}
|
||||
if (allowedHeaders && allowedHeaders.length) {
|
||||
headers.push([{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value: allowedHeaders
|
||||
}]);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function configureExposedHeaders(options) {
|
||||
var headers = options.exposedHeaders;
|
||||
if (!headers) {
|
||||
return null;
|
||||
} else if (headers.join) {
|
||||
headers = headers.join(','); // .headers is an array, so turn it into a string
|
||||
}
|
||||
if (headers && headers.length) {
|
||||
return {
|
||||
key: 'Access-Control-Expose-Headers',
|
||||
value: headers
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function configureMaxAge(options) {
|
||||
var maxAge = (typeof options.maxAge === 'number' || options.maxAge) && options.maxAge.toString()
|
||||
if (maxAge && maxAge.length) {
|
||||
return {
|
||||
key: 'Access-Control-Max-Age',
|
||||
value: maxAge
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyHeaders(headers, res) {
|
||||
for (var i = 0, n = headers.length; i < n; i++) {
|
||||
var header = headers[i];
|
||||
if (header) {
|
||||
if (Array.isArray(header)) {
|
||||
applyHeaders(header, res);
|
||||
} else if (header.key === 'Vary' && header.value) {
|
||||
vary(res, header.value);
|
||||
} else if (header.value) {
|
||||
res.setHeader(header.key, header.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cors(options, req, res, next) {
|
||||
var headers = [],
|
||||
method = req.method && req.method.toUpperCase && req.method.toUpperCase();
|
||||
|
||||
if (method === 'OPTIONS') {
|
||||
// preflight
|
||||
headers.push(configureOrigin(options, req));
|
||||
headers.push(configureCredentials(options))
|
||||
headers.push(configureMethods(options))
|
||||
headers.push(configureAllowedHeaders(options, req));
|
||||
headers.push(configureMaxAge(options))
|
||||
headers.push(configureExposedHeaders(options))
|
||||
applyHeaders(headers, res);
|
||||
|
||||
if (options.preflightContinue) {
|
||||
next();
|
||||
} else {
|
||||
// Safari (and potentially other browsers) need content-length 0,
|
||||
// for 204 or they just hang waiting for a body
|
||||
res.statusCode = options.optionsSuccessStatus;
|
||||
res.setHeader('Content-Length', '0');
|
||||
res.end();
|
||||
}
|
||||
} else {
|
||||
// actual response
|
||||
headers.push(configureOrigin(options, req));
|
||||
headers.push(configureCredentials(options))
|
||||
headers.push(configureExposedHeaders(options))
|
||||
applyHeaders(headers, res);
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
function middlewareWrapper(o) {
|
||||
// if options are static (either via defaults or custom options passed in), wrap in a function
|
||||
var optionsCallback = null;
|
||||
if (typeof o === 'function') {
|
||||
optionsCallback = o;
|
||||
} else {
|
||||
optionsCallback = function (req, cb) {
|
||||
cb(null, o);
|
||||
};
|
||||
}
|
||||
|
||||
return function corsMiddleware(req, res, next) {
|
||||
optionsCallback(req, function (err, options) {
|
||||
if (err) {
|
||||
next(err);
|
||||
} else {
|
||||
var corsOptions = assign({}, defaults, options);
|
||||
var originCallback = null;
|
||||
if (corsOptions.origin && typeof corsOptions.origin === 'function') {
|
||||
originCallback = corsOptions.origin;
|
||||
} else if (corsOptions.origin) {
|
||||
originCallback = function (origin, cb) {
|
||||
cb(null, corsOptions.origin);
|
||||
};
|
||||
}
|
||||
|
||||
if (originCallback) {
|
||||
originCallback(req.headers.origin, function (err2, origin) {
|
||||
if (err2 || !origin) {
|
||||
next(err2);
|
||||
} else {
|
||||
corsOptions.origin = origin;
|
||||
cors(corsOptions, req, res, next);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// can pass either an options hash, an options delegate, or nothing
|
||||
module.exports = middlewareWrapper;
|
||||
|
||||
}());
|
||||
42
node_modules/cors/package.json
generated
vendored
Normal file
42
node_modules/cors/package.json
generated
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "cors",
|
||||
"description": "Node.js CORS middleware",
|
||||
"version": "2.8.6",
|
||||
"author": "Troy Goode <troygoode@gmail.com> (https://github.com/troygoode/)",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"cors",
|
||||
"express",
|
||||
"connect",
|
||||
"middleware"
|
||||
],
|
||||
"repository": "expressjs/cors",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
},
|
||||
"main": "./lib/index.js",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"after": "0.8.2",
|
||||
"eslint": "7.30.0",
|
||||
"express": "4.21.2",
|
||||
"mocha": "9.2.2",
|
||||
"nyc": "15.1.0",
|
||||
"supertest": "6.1.3"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "npm run lint && npm run test-ci",
|
||||
"test-ci": "nyc --reporter=lcov --reporter=text mocha --require test/support/env",
|
||||
"lint": "eslint lib test"
|
||||
}
|
||||
}
|
||||
90
node_modules/object-assign/index.js
generated
vendored
Normal file
90
node_modules/object-assign/index.js
generated
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
object-assign
|
||||
(c) Sindre Sorhus
|
||||
@license MIT
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
/* eslint-disable no-unused-vars */
|
||||
var getOwnPropertySymbols = Object.getOwnPropertySymbols;
|
||||
var hasOwnProperty = Object.prototype.hasOwnProperty;
|
||||
var propIsEnumerable = Object.prototype.propertyIsEnumerable;
|
||||
|
||||
function toObject(val) {
|
||||
if (val === null || val === undefined) {
|
||||
throw new TypeError('Object.assign cannot be called with null or undefined');
|
||||
}
|
||||
|
||||
return Object(val);
|
||||
}
|
||||
|
||||
function shouldUseNative() {
|
||||
try {
|
||||
if (!Object.assign) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Detect buggy property enumeration order in older V8 versions.
|
||||
|
||||
// https://bugs.chromium.org/p/v8/issues/detail?id=4118
|
||||
var test1 = new String('abc'); // eslint-disable-line no-new-wrappers
|
||||
test1[5] = 'de';
|
||||
if (Object.getOwnPropertyNames(test1)[0] === '5') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// https://bugs.chromium.org/p/v8/issues/detail?id=3056
|
||||
var test2 = {};
|
||||
for (var i = 0; i < 10; i++) {
|
||||
test2['_' + String.fromCharCode(i)] = i;
|
||||
}
|
||||
var order2 = Object.getOwnPropertyNames(test2).map(function (n) {
|
||||
return test2[n];
|
||||
});
|
||||
if (order2.join('') !== '0123456789') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// https://bugs.chromium.org/p/v8/issues/detail?id=3056
|
||||
var test3 = {};
|
||||
'abcdefghijklmnopqrst'.split('').forEach(function (letter) {
|
||||
test3[letter] = letter;
|
||||
});
|
||||
if (Object.keys(Object.assign({}, test3)).join('') !==
|
||||
'abcdefghijklmnopqrst') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
// We don't expect any of the above to throw, but better to be safe.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = shouldUseNative() ? Object.assign : function (target, source) {
|
||||
var from;
|
||||
var to = toObject(target);
|
||||
var symbols;
|
||||
|
||||
for (var s = 1; s < arguments.length; s++) {
|
||||
from = Object(arguments[s]);
|
||||
|
||||
for (var key in from) {
|
||||
if (hasOwnProperty.call(from, key)) {
|
||||
to[key] = from[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (getOwnPropertySymbols) {
|
||||
symbols = getOwnPropertySymbols(from);
|
||||
for (var i = 0; i < symbols.length; i++) {
|
||||
if (propIsEnumerable.call(from, symbols[i])) {
|
||||
to[symbols[i]] = from[symbols[i]];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return to;
|
||||
};
|
||||
21
node_modules/object-assign/license
generated
vendored
Normal file
21
node_modules/object-assign/license
generated
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
42
node_modules/object-assign/package.json
generated
vendored
Normal file
42
node_modules/object-assign/package.json
generated
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "object-assign",
|
||||
"version": "4.1.1",
|
||||
"description": "ES2015 `Object.assign()` ponyfill",
|
||||
"license": "MIT",
|
||||
"repository": "sindresorhus/object-assign",
|
||||
"author": {
|
||||
"name": "Sindre Sorhus",
|
||||
"email": "sindresorhus@gmail.com",
|
||||
"url": "sindresorhus.com"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "xo && ava",
|
||||
"bench": "matcha bench.js"
|
||||
},
|
||||
"files": [
|
||||
"index.js"
|
||||
],
|
||||
"keywords": [
|
||||
"object",
|
||||
"assign",
|
||||
"extend",
|
||||
"properties",
|
||||
"es2015",
|
||||
"ecmascript",
|
||||
"harmony",
|
||||
"ponyfill",
|
||||
"prollyfill",
|
||||
"polyfill",
|
||||
"shim",
|
||||
"browser"
|
||||
],
|
||||
"devDependencies": {
|
||||
"ava": "^0.16.0",
|
||||
"lodash": "^4.16.4",
|
||||
"matcha": "^0.7.0",
|
||||
"xo": "^0.16.0"
|
||||
}
|
||||
}
|
||||
61
node_modules/object-assign/readme.md
generated
vendored
Normal file
61
node_modules/object-assign/readme.md
generated
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# object-assign [](https://travis-ci.org/sindresorhus/object-assign)
|
||||
|
||||
> ES2015 [`Object.assign()`](http://www.2ality.com/2014/01/object-assign.html) [ponyfill](https://ponyfill.com)
|
||||
|
||||
|
||||
## Use the built-in
|
||||
|
||||
Node.js 4 and up, as well as every evergreen browser (Chrome, Edge, Firefox, Opera, Safari),
|
||||
support `Object.assign()` :tada:. If you target only those environments, then by all
|
||||
means, use `Object.assign()` instead of this package.
|
||||
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
$ npm install --save object-assign
|
||||
```
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
const objectAssign = require('object-assign');
|
||||
|
||||
objectAssign({foo: 0}, {bar: 1});
|
||||
//=> {foo: 0, bar: 1}
|
||||
|
||||
// multiple sources
|
||||
objectAssign({foo: 0}, {bar: 1}, {baz: 2});
|
||||
//=> {foo: 0, bar: 1, baz: 2}
|
||||
|
||||
// overwrites equal keys
|
||||
objectAssign({foo: 0}, {foo: 1}, {foo: 2});
|
||||
//=> {foo: 2}
|
||||
|
||||
// ignores null and undefined sources
|
||||
objectAssign({foo: 0}, null, {bar: 1}, undefined);
|
||||
//=> {foo: 0, bar: 1}
|
||||
```
|
||||
|
||||
|
||||
## API
|
||||
|
||||
### objectAssign(target, [source, ...])
|
||||
|
||||
Assigns enumerable own properties of `source` objects to the `target` object and returns the `target` object. Additional `source` objects will overwrite previous ones.
|
||||
|
||||
|
||||
## Resources
|
||||
|
||||
- [ES2015 spec - Object.assign](https://people.mozilla.org/~jorendorff/es6-draft.html#sec-object.assign)
|
||||
|
||||
|
||||
## Related
|
||||
|
||||
- [deep-assign](https://github.com/sindresorhus/deep-assign) - Recursive `Object.assign()`
|
||||
|
||||
|
||||
## License
|
||||
|
||||
MIT © [Sindre Sorhus](https://sindresorhus.com)
|
||||
31
package-lock.json
generated
31
package-lock.json
generated
|
|
@ -1,14 +1,15 @@
|
|||
{
|
||||
"name": "miyakarate",
|
||||
"name": "EmyKarate",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "miyakarate",
|
||||
"name": "EmyKarate",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1",
|
||||
"sharp": "^0.34.5"
|
||||
|
|
@ -690,6 +691,23 @@
|
|||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -1152,6 +1170,15 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1",
|
||||
"sharp": "^0.34.5"
|
||||
|
|
|
|||
BIN
static/.DS_Store
vendored
BIN
static/.DS_Store
vendored
Binary file not shown.
BIN
static/hp_karate_logo.png.old
Normal file
BIN
static/hp_karate_logo.png.old
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 452 KiB |
1
static/index.xml
Normal file
1
static/index.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>EmyKarate</title><link>https://emy.bonzeipunk.de/</link><description>Recent content on EmyKarate</description><generator>Hugo</generator><language>de</language><lastBuildDate>Fri, 15 Nov 2024 00:00:00 +0000</lastBuildDate><atom:link href="https://emy.bonzeipunk.de/index.xml" rel="self" type="application/rss+xml"/><item><title>Fairness-Preis</title><link>https://emy.bonzeipunk.de/erfolge/fairness-preis/</link><pubDate>Fri, 01 Dec 2023 00:00:00 +0000</pubDate><guid>https://emy.bonzeipunk.de/erfolge/fairness-preis/</guid><description>Auszeichnung mit dem Fairness-Preis 2023 unseres Dojos.</description></item><item><title>Landesmeisterschaft Berlin 2024</title><link>https://emy.bonzeipunk.de/erfolge/landesmeisterschaft-2024/</link><pubDate>Fri, 15 Nov 2024 00:00:00 +0000</pubDate><guid>https://emy.bonzeipunk.de/erfolge/landesmeisterschaft-2024/</guid><description>Erster Platz in der Kategorie Kata U14 bei der Berliner Landesmeisterschaft. Ein unvergesslicher Moment!</description></item><item><title>Landesmeisterschaft (3. Platz)</title><link>https://emy.bonzeipunk.de/erfolge/landesmeisterschaft-platz3/</link><pubDate>Thu, 09 May 2024 00:00:00 +0000</pubDate><guid>https://emy.bonzeipunk.de/erfolge/landesmeisterschaft-platz3/</guid><description>Ein harter Wettkampf, der mit dem 3. Platz belohnt wurde. Im Sommer geht es um die Meisterschaft!</description></item><item><title>Dojo Champion</title><link>https://emy.bonzeipunk.de/erfolge/dojo-champion/</link><pubDate>Mon, 15 Jan 2024 00:00:00 +0000</pubDate><guid>https://emy.bonzeipunk.de/erfolge/dojo-champion/</guid><description>Interner Titel als Dojo Champion beim Kiai Berlin.</description></item><item><title>Norddeutsche Meisterschaft 2024</title><link>https://emy.bonzeipunk.de/erfolge/norddeutsche-2024/</link><pubDate>Tue, 20 Aug 2024 00:00:00 +0000</pubDate><guid>https://emy.bonzeipunk.de/erfolge/norddeutsche-2024/</guid><description>Silbermedaille beim Norddeutschen Turnier – das bisher größte Turnier, an dem ich teilgenommen habe.</description></item></channel></rss>
|
||||
Loading…
Reference in a new issue