Compare commits

..

10 Commits

Author SHA1 Message Date
Kroonk
5c81e93455 Isolate client workspaces from root portfolio and restrict Admin view to root images
Some checks failed
Build & Push Docker Image / build-and-push (push) Has been cancelled
2026-04-12 20:47:24 +02:00
Kroonk
eaa5945611 Wrap background-image url string in quotes to support poorly encoded original filenames 2026-04-12 20:18:22 +02:00
Kroonk
33db10f32d Change lightbox caption background to beige with grey text for readability 2026-04-12 19:19:59 +02:00
Kroonk
ab06284beb Implement Light Mode (Beige) via alternate SASS compilation and runtime switching 2026-04-12 19:11:23 +02:00
Kroonk
67044add84 Commit multi-replace docker-compose.yml 2026-04-12 19:08:16 +02:00
Kroonk
61bd512cd8 Deploy user requested features and fixes, add Galerie menu link. 2026-04-12 18:53:59 +02:00
Kroonk
941d041b5d Nutzerverwaltung-Upgrade: 3 Rollen, Ordner-Zuweisung, Passwort-Aenderung
- 3 Rollen: admin (voll), user (permanent), client (30 Tage Ablauf)
- Ordner-basierte Bildzuweisung statt Einzelbilder (user_folders Tabelle)
- Root-Bilder oeffentlich, Unterordner nur fuer zugewiesene Nutzer
- Passwort-Aenderung fuer alle Nutzer (Self-Service + Admin)
- Admin kann beliebige Admins erstellen/loeschen
- Thumbnail-Generierung fuer Unterordner
- Admin-Dashboard komplett ueberarbeitet

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 17:53:46 +02:00
Kroonk
a4f3cf738c Fix: Backend-Bugs, fehlende Galerie, Watchtower statt Docker-Socket
- express.urlencoded() hinzugefuegt (Login war komplett kaputt)
- Oeffentliche Galerie zeigt jetzt alle Bilder (statt leere Galerie)
- loadGallery() Aufruf in main.js eingefuegt (fehlte komplett)
- main.min.js neu gebaut mit gulp (Galerie-Code fehlte in minified Version)
- Watchtower Container in docker-compose.yml (auto-updates alle 5 Min)
- Docker-Socket und docker-cli aus Photography-Container entfernt
- runs.json Muell-Datei geloescht
- Brain.md und Feature-Request.md aktualisiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 17:22:49 +02:00
Kroonk
15e74f42c2 Align all internal, exposed, and mapped ports to 8090 universally per user request. 2026-04-12 16:47:54 +02:00
Kroonk
7509253ee7 Revert server port to 80 to match untouched user docker-compose.yml mappings indefinitely 2026-04-12 16:41:05 +02:00
18 changed files with 678 additions and 230 deletions

132
Brain.md
View File

@@ -20,12 +20,15 @@ Keine 1:1 Kopie, sondern eine individuell angepasste Version mit eigenen Ansprue
| Komponente | Technologie | | Komponente | Technologie |
|---|---| |---|---|
| Static Site Generator | **Jekyll** (Ruby-basiert) | | Static Site Generator | **Jekyll** (Ruby-basiert) |
| Backend | **Node.js / Express** (Auth, API, Static Serving) |
| Datenbank | **SQLite** (Nutzerverwaltung, Bildzuweisung) |
| Templating | Liquid Templates | | Templating | Liquid Templates |
| Styling | SCSS -> kompiliertes CSS | | Styling | SCSS -> kompiliertes CSS |
| JS Libraries | jQuery, Poptrox (Lightbox), EXIF.js | | JS Libraries | jQuery, Poptrox (Lightbox), EXIF.js |
| Build-Tool | Gulp (SCSS kompilieren, JS minifizieren, Bilder resizen) | | Build-Tool | Gulp (SCSS kompilieren, JS minifizieren, Bilder resizen) |
| Hosting | **UGREEN NAS (Docker)** via nginx | | Hosting | **UGREEN NAS (Docker)** via Node.js Express |
| Containerisierung | Docker Multi-Stage (Jekyll Build + nginx) | | Containerisierung | Docker Multi-Stage (Jekyll Build + Node.js Backend) |
| Auto-Update | **Watchtower** (prueft alle 5 Min auf neue Images) |
| Fonts | FontAwesome, Google Fonts (Source Sans Pro) | | Fonts | FontAwesome, Google Fonts (Source Sans Pro) |
| Paketmanager | npm (Node), Bundler (Ruby) | | Paketmanager | npm (Node), Bundler (Ruby) |
@@ -37,9 +40,13 @@ Photography/
├── _layouts/ ├── _layouts/
│ └── default.html # Basis-Layout (Head + Body + Scripts) │ └── default.html # Basis-Layout (Head + Body + Scripts)
├── _includes/ ├── _includes/
│ ├── header.html # <head> Bereich: CSS, Fonts, Google Analytics │ ├── header.html # <head> Bereich: CSS, Fonts
│ └── footer.html # Script-Einbindungen (jQuery, Poptrox, EXIF, Main) │ └── footer.html # Script-Einbindungen (jQuery, Poptrox, EXIF, Main)
├── index.html # Hauptseite: Header, Galerie-Grid, Footer/About ├── index.html # Hauptseite: Header, Galerie-Grid, Footer/About, Login-Panel
├── admin.html # Admin-Dashboard (Nutzerverwaltung, Bildzuweisung)
├── backend/
│ ├── server.js # Express Backend (Auth, API, Static Files, Port 8090)
│ └── database.js # SQLite Datenbank-Setup (Users, User-Images)
├── assets/ ├── assets/
│ ├── css/ # Kompilierte CSS-Dateien (.min.css) │ ├── css/ # Kompilierte CSS-Dateien (.min.css)
│ ├── sass/ # SCSS Quelldateien │ ├── sass/ # SCSS Quelldateien
@@ -47,50 +54,100 @@ Photography/
│ │ ├── components/ # Buttons, Forms, Icons, Panels, Poptrox │ │ ├── components/ # Buttons, Forms, Icons, Panels, Poptrox
│ │ ├── layout/ # Header, Footer, Main, Wrapper │ │ ├── layout/ # Header, Footer, Main, Wrapper
│ │ └── libs/ # Breakpoints, Mixins, Vars, Functions │ │ └── libs/ # Breakpoints, Mixins, Vars, Functions
│ ├── js/ # JavaScript (jQuery, Poptrox, EXIF, Main) │ ├── js/ # JavaScript (jQuery, Poptrox, EXIF, Main, Admin)
│ ├── fonts/ # FontAwesome Fonts │ ├── fonts/ # FontAwesome Fonts
│ └── webfonts/ # FA5 Webfonts │ └── webfonts/ # FA5 Webfonts
├── images/ ├── images/
│ ├── fulls/ # Vollaufloesung Bilder │ ├── fulls/ # Vollaufloesung Bilder
│ └── thumbs/ # Thumbnails (512px) │ └── thumbs/ # Thumbnails (512px)
├── gulpfile.mjs # Build: SCSS->CSS, JS minify, Image resize ├── gulpfile.mjs # Build: SCSS->CSS, JS minify, Image resize
├── package.json # Node Dependencies ├── package.json # Node Dependencies (Express, SQLite, JWT, bcrypt)
├── Gemfile # Ruby Dependencies (jekyll) ├── Gemfile # Ruby Dependencies (jekyll)
── CNAME # Custom Domain Config ── Dockerfile # Multi-Stage: Jekyll Build + Node.js Runtime
├── docker-compose.yml # Container + Watchtower
├── entrypoint.sh # Thumbnail-Generierung + Node.js Start
└── nginx.conf # (Legacy, nicht mehr aktiv - Express dient statische Dateien)
``` ```
## Wie die Galerie funktioniert (Dynamisch) ## Wie die Galerie funktioniert (Dynamisch)
1. **Bilder hochladen**: Fotos in Nextcloud-Ordner `Websitebilder` ablegen 1. **Bilder hochladen**: Fotos in Nextcloud-Ordner `Websitebilder` ablegen
2. **Volume-Mount**: Docker mountet den Nextcloud-Ordner als `/images/fulls/` (read-only) 2. **Volume-Mount**: Docker mountet den Nextcloud-Ordner als `/app/public/images/fulls/` (read-only)
3. **Thumbnails**: `entrypoint.sh` generiert automatisch 512px Thumbnails via ImageMagick 3. **Thumbnails**: `entrypoint.sh` generiert automatisch 512px Thumbnails via ImageMagick
4. **Thumbnail-Check**: Alle 60 Sekunden prueft der Container auf neue Bilder 4. **Thumbnail-Check**: Alle 60 Sekunden prueft der Container auf neue Bilder
5. **nginx autoindex**: Liefert JSON-Liste aller Dateien in `/images/fulls/` 5. **Express API**: `/images/fulls/` liefert JSON-Liste der Bilddateien
6. **JavaScript (main.js)**: Fetcht die JSON-Liste und baut dynamisch `<article class="thumb">` Elemente 6. **JavaScript (main.js)**: Fetcht die JSON-Liste und baut dynamisch `<article class="thumb">` Elemente
7. **Poptrox-Lightbox**: Klick oeffnet Vollbild + EXIF-Daten (clientseitig via exif.js) 7. **Poptrox-Lightbox**: Klick oeffnet Vollbild + EXIF-Daten (clientseitig via exif.js)
8. **Download-Button**: In der Lightbox kann jedes Bild heruntergeladen werden
### Zugriffslogik
- **Nicht eingeloggt**: Nur Root-Bilder (direkt in Websitebilder/) sichtbar
- **Admin**: Alle Bilder (Root + alle Unterordner) sichtbar
- **User (permanent)**: Root-Bilder + zugewiesene Unterordner
- **Client (30 Tage)**: Root-Bilder + zugewiesene Unterordner, Account laeuft nach 30 Tagen ab
### Nextcloud-Integration ### Nextcloud-Integration
- **Nextcloud-Pfad**: `/volume1/nextcloud/data/Tom/files/Websitebilder` - **Nextcloud-Pfad**: `/volume1/nextcloud/data/Tom/files/Websitebilder`
- **Mount im Container**: `/usr/share/nginx/html/images/fulls:ro` - **Mount im Container**: `/app/public/images/fulls:ro`
- **Thumbnails-Volume**: Docker-Volume `thumbs` (persistent) - **Thumbnails-Volume**: Docker-Volume `thumbs` (persistent)
- **Datenbank-Volume**: Docker-Volume `database` (persistent, SQLite)
- **Workflow**: Bild in Nextcloud hochladen -> max. 60s warten -> Seite neu laden -> fertig - **Workflow**: Bild in Nextcloud hochladen -> max. 60s warten -> Seite neu laden -> fertig
## Login-System & Nutzerverwaltung
### Architektur
- **Backend**: Node.js/Express auf Port 8090
- **Auth**: JWT-Token als httpOnly Cookie (7 Tage gueltig)
- **Datenbank**: SQLite unter `/app/data/database.sqlite`
- **Passwort-Hashing**: bcryptjs (10 Rounds)
### Rollen
| Rolle | Beschreibung | Ablauf |
|---|---|---|
| `admin` | Voller Zugriff, Nutzerverwaltung, alle Bilder | Nie |
| `user` | Root-Bilder + zugewiesene Ordner | Nie |
| `client` | Root-Bilder + zugewiesene Ordner | 30 Tage nach Erstellung |
### Ordner-basierte Bildzuweisung
- Bilder direkt in `Websitebilder/` = oeffentlich (fuer alle sichtbar)
- Bilder in Unterordnern (z.B. `Websitebilder/Hochzeit/`) = nur fuer zugewiesene Nutzer
- Zuweisung erfolgt auf Ordner-Ebene (nicht einzelne Bilder)
- Tabelle `user_folders` speichert Zuweisungen (user_id, folder_name)
### Standard-Admin
- **Username**: `admin`
- **Passwort**: `admin123` (SOFORT AENDERN nach erstem Login!)
- **Rolle**: admin
### API-Endpunkte
| Endpunkt | Methode | Beschreibung |
|---|---|---|
| `/api/auth/login` | POST | Login (username, password) |
| `/api/auth/logout` | POST | Logout (Cookie loeschen) |
| `/api/auth/me` | GET | Aktueller User |
| `/api/auth/change-password` | POST | Eigenes Passwort aendern |
| `/api/admin/users` | GET/POST | Nutzer auflisten / erstellen (mit Rolle) |
| `/api/admin/users/:id` | DELETE | Nutzer loeschen (nicht sich selbst) |
| `/api/admin/users/:id/password` | PUT | Admin aendert User-Passwort |
| `/api/admin/folders` | GET | Verfuegbare Unterordner auflisten |
| `/api/admin/assign` | GET/POST | Ordner-Zuweisungen anzeigen / setzen |
| `/images/fulls/` | GET | Bilderliste (gefiltert nach User-Rolle + Ordnern) |
## Wichtige Konfiguration (_config.yml) ## Wichtige Konfiguration (_config.yml)
- `baseurl`: URL der Website (leer fuer lokal) - `baseurl`: URL der Website
- `title`, `subtitle`, `author`: Seitentitel und Autor - `title`, `subtitle`, `author`: Seitentitel und Autor
- `header.title/subtitle`: Header-Anzeige - `header.title/subtitle`: Header-Anzeige
- `footer.name/bio/github`: Footer-Infos - `footer.name/bio`: Footer-Infos
- `social_urls`: Links zu Social Media - `social_urls`: Links zu Social Media
- `exif`: Welche EXIF-Tags angezeigt werden (Model, FNumber, ExposureTime, ISO) - `exif`: Welche EXIF-Tags angezeigt werden (Model, FNumber, ExposureTime, ISO)
- `google_analytics`: Tracking-ID
## Build-Befehle ## Build-Befehle
| Befehl | Aktion | | Befehl | Aktion |
|---|---| |---|---|
| `bundle install` | Ruby Dependencies installieren | | `bundle install` | Ruby Dependencies installieren |
| `bundle exec jekyll serve` | Lokaler Dev-Server | | `bundle exec jekyll serve` | Lokaler Dev-Server (nur Frontend) |
| `npm install` | Node Dependencies installieren | | `npm install` | Node Dependencies installieren |
| `gulp build` | SCSS kompilieren + JS minifizieren | | `gulp build` | SCSS kompilieren + JS minifizieren |
| `gulp resize` | Bilder zu fulls (1024px) + thumbs (512px) | | `gulp resize` | Bilder zu fulls (1024px) + thumbs (512px) |
@@ -99,45 +156,54 @@ Photography/
## Docker / NAS Hosting ## Docker / NAS Hosting
- **NAS**: UGREEN mit UGOS PRO - **NAS**: UGREEN mit UGOS PRO
- **Port**: 8090 (Port 80 ist belegt) - **Port**: 8090 (intern und extern)
- **Zugriff**: `http://NAS-IP:8090` -> spaeter `https://tom.mischlabs.de` - **Domain**: tom.mischlabs.de (via Cloudflare Tunnel)
- **Domain**: tom.mischlabs.de (Reverse Proxy auf NAS einrichten)
### Docker-Dateien ### Docker-Dateien
- `Dockerfile` - Multi-Stage: Ruby/Jekyll baut Site -> nginx serviert statische Dateien - `Dockerfile` - Multi-Stage: Ruby/Jekyll baut Site -> Node.js Express serviert alles
- `docker-compose.yml` - Container-Definition, Port 8090:80, Nextcloud-Volume, auto-restart - `docker-compose.yml` - Photography Container + Watchtower, Volumes fuer Bilder/Thumbs/DB
- `entrypoint.sh` - Thumbnail-Generierung + periodischer Check (alle 60s) - `entrypoint.sh` - Thumbnail-Generierung + Node.js Server-Start
- `nginx.conf` - Webserver-Config mit Caching und Sicherheit
- `.dockerignore` - Haelt Build-Context klein (kein .git, node_modules etc.) - `.dockerignore` - Haelt Build-Context klein (kein .git, node_modules etc.)
### Docker-Befehle ### Docker-Befehle (auf NAS via SSH)
| Befehl | Aktion | | Befehl | Aktion |
|---|---| |---|---|
| `docker compose up -d --build` | Container bauen und starten | | `sudo docker compose up -d` | Container starten |
| `docker compose down` | Container stoppen | | `sudo docker compose down` | Container stoppen |
| `docker compose logs -f` | Logs anzeigen | | `sudo docker compose logs -f photography` | Logs anzeigen |
| `docker compose up -d --build --force-recreate` | Neu bauen nach Aenderungen | | `sudo docker compose pull && sudo docker compose up -d` | Manuelles Update |
### Deployment-Workflow ### Deployment-Workflow (automatisch!)
1. Aenderungen lokal machen (Bilder, Config, Design) 1. Aenderungen lokal machen (Code, Design)
2. Git commit & push 2. `gulp build` ausfuehren (JS + CSS minifizieren)
3. Auf NAS: `git pull && docker compose up -d --build` 3. Git commit & push nach master
4. Fertig - Website aktualisiert 4. GitHub Actions baut automatisch Docker Image -> GHCR
5. Watchtower auf NAS erkennt neues Image (alle 5 Min)
6. Watchtower aktualisiert Container automatisch
7. Fertig - Website aktualisiert ohne SSH!
## Anpassungs-Roadmap (TODO - mit Besitzer besprechen) ## Anpassungs-Roadmap
- [x] Titel, Subtitle, Autor anpassen (Mischkomposition / Tom Misch) - [x] Titel, Subtitle, Autor anpassen (Mischkomposition / Tom Misch)
- [x] Social Media Links aktualisieren (Instagram: @Mischkomposition) - [x] Social Media Links aktualisieren (Instagram: @Mischkomposition)
- [x] Sponsor-Bereich entfernen - [x] Sponsor-Bereich entfernen
- [x] Kontaktformular auf tom@mischlabs.de konfiguriert - [x] Kontaktformular auf tom@mischlabs.de konfiguriert (Formsubmit.co)
- [x] Google Analytics vom Originalautor entfernt - [x] Google Analytics vom Originalautor entfernt
- [x] Custom Domain: tom.mischlabs.de eingetragen - [x] Custom Domain: tom.mischlabs.de eingetragen
- [x] Nextcloud-Integration (Bilder aus Websitebilder-Ordner) - [x] Nextcloud-Integration (Bilder aus Websitebilder-Ordner)
- [x] Dynamische Galerie (JS statt Jekyll-Loop) - [x] Dynamische Galerie (JS statt Jekyll-Loop)
- [x] Automatische Thumbnail-Generierung - [x] Automatische Thumbnail-Generierung
- [x] Login-System mit JWT + SQLite
- [x] Admin-Dashboard (Nutzerverwaltung, Bildzuweisung)
- [x] Kunden-Portal (zugewiesene Bilder + Download)
- [x] Watchtower fuer automatische Updates
- [x] 3-Rollen-System (admin/user/client mit 30-Tage-Ablauf)
- [x] Ordner-basierte Bildzuweisung (Unterordner = private Kundenbilder)
- [x] Passwort-Aenderung (Self-Service + Admin kann fuer andere aendern)
- [ ] Footer Bio-Text schreiben (optional) - [ ] Footer Bio-Text schreiben (optional)
- [ ] Farbschema/Design anpassen - [ ] Farbschema/Design anpassen
- [ ] Eigene Kategorien/Alben erstellen (Feature-Erweiterung) - [ ] Eigene Kategorien/Alben erstellen (Feature-Erweiterung)
- [ ] Admin-Passwort aendern!
--- ---

View File

@@ -15,8 +15,8 @@ RUN bundle exec jekyll build
# === Serve-Phase: node.js backend + ImageMagick === # === Serve-Phase: node.js backend + ImageMagick ===
FROM node:20-alpine FROM node:20-alpine
# Install build dependencies for sqlite3 native bindings on Alpine # ImageMagick fuer Thumbnails, Build-Tools fuer sqlite3 native bindings
RUN apk add --no-cache imagemagick docker-cli python3 make g++ build-base sqlite-dev RUN apk add --no-cache imagemagick python3 make g++ build-base sqlite-dev
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
@@ -33,6 +33,6 @@ RUN chmod +x /entrypoint.sh
# Verzeichnisse anlegen # Verzeichnisse anlegen
RUN mkdir -p /app/public/images/fulls /app/public/images/thumbs /app/data RUN mkdir -p /app/public/images/fulls /app/public/images/thumbs /app/data
EXPOSE 8080 EXPOSE 8090
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -3,7 +3,7 @@
## Nutzerverwaltung & Login-System ## Nutzerverwaltung & Login-System
### Beschreibung ### Beschreibung
Die Website soll um ein Login-System mit Nutzerverwaltung erweitert werden. Die Website wurde um ein Login-System mit Nutzerverwaltung erweitert.
### Anforderungen ### Anforderungen
@@ -17,23 +17,48 @@ Die Website soll um ein Login-System mit Nutzerverwaltung erweitert werden.
- Nutzer koennen ihre zugewiesenen Bilder anschauen (Galerie + Lightbox) - Nutzer koennen ihre zugewiesenen Bilder anschauen (Galerie + Lightbox)
- Nutzer koennen ihre zugewiesenen Bilder herunterladen - Nutzer koennen ihre zugewiesenen Bilder herunterladen
### Status: Geplant (noch nicht umgesetzt) ### Status: Umgesetzt
--- ---
## Admin-Dashboard: One-Click Update ## Rollen-System & Ordner-Zuweisung
### Beschreibung ### Beschreibung
Im Admin-Bereich soll ein Button vorhanden sein, der per Klick die neueste Version aus dem Git-Repository zieht und den Container automatisch aktualisiert. Kein manuelles SSH oder Terminal noetig. Erweitertes Nutzermanagement mit 3 Rollen und ordnerbasierter Bildzuweisung.
### Anforderungen ### Anforderungen
- Button im Admin-Dashboard: "Website aktualisieren"
- Zieht automatisch die neueste Version aus GitHub (git pull oder neues Image)
- Baut den Container neu / startet ihn neu
- Zeigt Status-Feedback (Erfolg/Fehler)
### Status: Geplant (noch nicht umgesetzt) **3 Rollen:**
- Admin: Voller Zugriff auf alle Bilder und Nutzerverwaltung
- User: Permanenter Account, sieht Root-Bilder + zugewiesene Ordner
- Client: Temporaerer Account (30 Tage), sieht Root-Bilder + zugewiesene Ordner
**Ordner-basierte Zuweisung:**
- Bilder direkt im Websitebilder-Ordner = oeffentlich fuer alle
- Unterordner (z.B. Hochzeit, Portrait) = nur fuer zugewiesene Nutzer
- Admin weist ganze Ordner einem Nutzer zu (nicht einzelne Bilder)
**Passwort-Aenderung:**
- Jeder Nutzer kann sein eigenes Passwort aendern
- Admin kann Passwoerter beliebiger Nutzer aendern
- Neue Admins erstellen und bestehende loeschen moeglich
### Status: Umgesetzt
--- ---
*Erstellt: 2026-04-12* ## Auto-Update via Watchtower
### Beschreibung
Watchtower laeuft als separater Docker-Container und prueft alle 5 Minuten auf neue Images.
Nach einem `git push` baut GitHub Actions das neue Image und Watchtower aktualisiert den Container automatisch.
### Anforderungen
- Automatisches Update ohne SSH oder manuellen Eingriff
- Alte Images werden automatisch aufgeraeumt (WATCHTOWER_CLEANUP)
### Status: Umgesetzt
---
*Aktualisiert: 2026-04-12*

View File

@@ -3,8 +3,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<!-- Load resources --> <!-- Load resources -->
<link rel="stylesheet" href="assets/css/main.min.css" /> <link rel="stylesheet" id="theme-style" href="assets/css/main.min.css" />
<noscript><link rel="stylesheet" href="assets/css/noscript.min.css" /></noscript> <script>
var theme = localStorage.getItem('mischkomposition_theme');
if (theme === 'light') {
document.getElementById('theme-style').href = 'assets/css/light.min.css';
}
</script>
<noscript><link rel="stylesheet" href="assets/css/main.min.css" /></noscript>
<link rel="stylesheet" href="assets/css/custom.min.css" /> <link rel="stylesheet" href="assets/css/custom.min.css" />
<link rel="stylesheet" href="/assets/css/fontawesome-all.min.css"> <link rel="stylesheet" href="/assets/css/fontawesome-all.min.css">

View File

@@ -7,48 +7,88 @@
<link rel="stylesheet" href="/assets/css/main.min.css" /> <link rel="stylesheet" href="/assets/css/main.min.css" />
<style> <style>
.admin-section { background: rgba(0,0,0,0.5); padding: 2em; margin-bottom: 2em; border-radius: 4px; } .admin-section { background: rgba(0,0,0,0.5); padding: 2em; margin-bottom: 2em; border-radius: 4px; }
.img-check { display: inline-block; width: 100px; margin: 5px; text-align: center; } .folder-check { display: inline-block; margin: 5px 10px; padding: 8px 12px; background: rgba(255,255,255,0.1); border-radius: 4px; }
.img-check img { width: 100px; height: 100px; object-fit: cover; } .folder-check label { cursor: pointer; }
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.2); } th, td { text-align: left; padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.2); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 0.8em; }
.badge-admin { background: #e44d26; }
.badge-user { background: #2196F3; }
.badge-client { background: #4CAF50; }
.badge-expired { background: #666; }
.btn-small { padding: 4px 10px; font-size: 0.85em; margin: 2px; cursor: pointer; }
</style> </style>
</head> </head>
<body class="is-preload"> <body class="is-preload">
<div id="wrapper"> <div id="wrapper">
<header id="header"> <header id="header">
<h1><a href="/index.html"><strong>Mischkomposition</strong> Admin</a></h1> <h1><a href="/index.html"><strong>Mischkomposition</strong> Admin</a></h1>
<nav><ul><li><a href="#" id="logout-btn">Logout <i class="fa fa-sign-out"></i></a></li></ul></nav> <nav><ul>
<li><a href="#" id="logout-btn">Logout <i class="fa fa-sign-out"></i></a></li>
</ul></nav>
</header> </header>
<div id="main" style="display:block; padding: 2em; max-width: 900px; margin: 0 auto; width: 100%;"> <div id="main" style="display:block; padding: 2em; max-width: 1000px; margin: 0 auto; width: 100%;">
<div class="admin-section"> <div class="admin-section">
<h2>System</h2> <h2>Nutzer erstellen</h2>
<p>Aktualisiert den Docker-Container aus dem GitHub Repository. Container startet danach neu.</p> <form id="create-user-form" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
<button id="update-btn" class="primary">Website Aktualisieren (Docker Pull)</button> <div>
<p id="update-status" style="margin-top: 10px;"></p> <label>Username</label><br/>
<input type="text" id="new-username" placeholder="Nutzername" required />
</div>
<div>
<label>Passwort</label><br/>
<input type="password" id="new-password" placeholder="Passwort" required />
</div>
<div>
<label>Rolle</label><br/>
<select id="new-role">
<option value="client">Client (30 Tage)</option>
<option value="user">User (permanent)</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<button type="submit">Erstellen</button>
</div>
</form>
</div> </div>
<div class="admin-section"> <div class="admin-section">
<h2>Nutzerverwaltung</h2> <h2>Nutzerverwaltung</h2>
<form id="create-user-form" style="display:flex; gap:10px; margin-bottom:1em;">
<input type="text" id="new-username" placeholder="Nutzername" required />
<input type="password" id="new-password" placeholder="Passwort" required />
<button type="submit">Erstellen</button>
</form>
<table> <table>
<thead><tr><th>ID</th><th>Username</th><th>Aktion</th></tr></thead> <thead><tr><th>ID</th><th>Username</th><th>Rolle</th><th>Erstellt</th><th>Aktionen</th></tr></thead>
<tbody id="users-tbody"></tbody> <tbody id="users-tbody"></tbody>
</table> </table>
</div> </div>
<div class="admin-section"> <div class="admin-section">
<h2>BildZuweisung</h2> <h2>Ordner-Zuweisung</h2>
<p>Weise Unterordner aus dem Websitebilder-Verzeichnis einem Nutzer zu.</p>
<select id="user-select" style="margin-bottom: 1em;"> <select id="user-select" style="margin-bottom: 1em;">
<option value="">Nutzer wählen...</option> <option value="">Nutzer waehlen...</option>
</select> </select>
<div id="image-checkboxes" style="display:flex; flex-wrap:wrap; gap:10px;"></div> <div id="folder-checkboxes" style="display:flex; flex-wrap:wrap; gap:5px; margin-bottom:1em;"></div>
<button id="save-assignments" style="margin-top:1em;">Zuweisung Speichern</button> <button id="save-assignments">Zuweisung Speichern</button>
<span id="assign-status" style="margin-left:10px;"></span>
</div>
<div class="admin-section">
<h2>Eigenes Passwort aendern</h2>
<form id="admin-pw-form" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
<div>
<label>Altes Passwort</label><br/>
<input type="password" id="admin-pw-old" required />
</div>
<div>
<label>Neues Passwort</label><br/>
<input type="password" id="admin-pw-new" required />
</div>
<div>
<button type="submit">Aendern</button>
</div>
</form>
</div> </div>
</div> </div>

1
assets/css/light.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,101 +1,167 @@
$(document).ready(function() { $(document).ready(function() {
// Check if admin // Auth check
$.get('/api/auth/me') $.get('/api/auth/me')
.done(function(user) { .done(function(user) {
if(user.role !== 'admin') { if(user.role !== 'admin') {
alert("Kein Zugriff!"); alert('Kein Zugriff!');
window.location.href = '/'; window.location.href = '/';
} else { } else {
loadUsers(); loadUsers();
loadAssignmentImages(); loadFolders();
$('body').removeClass('is-preload'); $('body').removeClass('is-preload');
} }
}) })
.fail(function() { .fail(function() { window.location.href = '/'; });
window.location.href = '/';
});
// Logout
$('#logout-btn').click(function(e) { $('#logout-btn').click(function(e) {
e.preventDefault(); e.preventDefault();
$.post('/api/auth/logout').done(function() { window.location.href = '/'; }); $.post('/api/auth/logout').done(function() { window.location.href = '/'; });
}); });
// ==================== NUTZER ====================
function loadUsers() { function loadUsers() {
$.get('/api/admin/users').done(function(users) { $.get('/api/admin/users').done(function(users) {
$('#users-tbody').empty(); $('#users-tbody').empty();
$('#user-select').html('<option value="">Nutzer wählen...</option>'); $('#user-select').html('<option value="">Nutzer waehlen...</option>');
users.forEach(function(u) { users.forEach(function(u) {
var badge = '<span class="badge badge-' + u.role + '">' + u.role + '</span>';
var created = u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '-';
var info = created;
if (u.role === 'client' && u.expires_at) {
var expDate = new Date(u.expires_at).toLocaleDateString('de-DE');
if (u.expired) {
badge = '<span class="badge badge-expired">abgelaufen</span>';
info = 'Abgelaufen: ' + expDate;
} else {
info = 'Bis: ' + expDate;
}
}
var actions = '<button class="btn-small" onclick="changePassword(' + u.id + ', \'' + u.username.replace(/'/g, "\\'") + '\')">PW</button>';
actions += '<button class="btn-small" onclick="deleteUser(' + u.id + ', \'' + u.username.replace(/'/g, "\\'") + '\')">X</button>';
$('#users-tbody').append( $('#users-tbody').append(
'<tr>' + '<tr><td>' + u.id + '</td><td>' + u.username + ' ' + badge +
'<td>' + u.id + '</td>' + '</td><td>' + u.role + '</td><td>' + info +
'<td>' + u.username + ' ' + (u.role === 'admin' ? '(Admin)' : '') + '</td>' + '</td><td>' + actions + '</td></tr>'
'<td>' + (u.id !== 1 ? '<button onclick="deleteUser(' + u.id + ')">Löschen</button>' : '') + '</td>' +
'</tr>'
); );
if(u.role !== 'admin') {
$('#user-select').append('<option value="' + u.id + '">' + u.username + '</option>'); // Dropdown fuer Ordner-Zuweisung (nur non-admin)
if (u.role !== 'admin') {
$('#user-select').append('<option value="' + u.id + '">' + u.username + ' (' + u.role + ')</option>');
} }
}); });
}); });
} }
window.deleteUser = function(id) { // Nutzer erstellen
if(confirm("Nutzer loeschen?")) { $('#create-user-form').submit(function(e) {
$.ajax({url: '/api/admin/users/'+id, type: 'DELETE'}).done(loadUsers); e.preventDefault();
$.post('/api/admin/users', {
username: $('#new-username').val(),
password: $('#new-password').val(),
role: $('#new-role').val()
}).done(function() {
$('#new-username').val('');
$('#new-password').val('');
loadUsers();
}).fail(function(err) {
alert('Fehler: ' + (err.responseJSON ? err.responseJSON.error : 'Unbekannt'));
});
});
// Nutzer loeschen
window.deleteUser = function(id, name) {
if (confirm('Nutzer "' + name + '" wirklich loeschen?')) {
$.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' })
.done(loadUsers)
.fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); });
} }
}; };
$('#create-user-form').submit(function(e) { // Passwort aendern (Admin setzt fuer anderen User)
e.preventDefault(); window.changePassword = function(id, name) {
$.post('/api/admin/users', { username: $('#new-username').val(), password: $('#new-password').val(), role: 'user' }) var newPw = prompt('Neues Passwort fuer "' + name + '":');
.done(function() { if (!newPw) return;
$('#new-username').val(''); $.ajax({
$('#new-password').val(''); url: '/api/admin/users/' + id + '/password',
loadUsers(); type: 'PUT',
}).fail(function(err) { alert("Fehler: " + err.responseJSON.error); }); contentType: 'application/json',
}); data: JSON.stringify({ new_password: newPw })
}).done(function() {
alert('Passwort geaendert!');
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
});
};
function loadAssignmentImages() { // ==================== ORDNER-ZUWEISUNG ====================
$.get('/images/fulls/').done(function(files) {
$('#image-checkboxes').empty(); function loadFolders() {
files.forEach(function(f) { $.get('/api/admin/folders').done(function(folders) {
$('#image-checkboxes').append( $('#folder-checkboxes').empty();
'<label class="img-check">' + if (folders.length === 0) {
'<img src="/images/thumbs/' + encodeURIComponent(f.name) + '" />' + $('#folder-checkboxes').html('<p>Keine Unterordner in Websitebilder gefunden. Erstelle Ordner in Nextcloud.</p>');
'<br/><input type="checkbox" value="' + f.name + '" class="img-cb"/>' + return;
'</label>' }
folders.forEach(function(f) {
$('#folder-checkboxes').append(
'<div class="folder-check"><label>' +
'<input type="checkbox" value="' + f + '" class="folder-cb"/> ' +
'<i class="fa fa-folder"></i> ' + f +
'</label></div>'
); );
}); });
}); });
} }
// Bei User-Auswahl: zugewiesene Ordner laden
$('#user-select').change(function() { $('#user-select').change(function() {
var uid = $(this).val(); var uid = $(this).val();
$('.img-cb').prop('checked', false); $('.folder-cb').prop('checked', false);
if(!uid) return; if (!uid) return;
$.get('/api/admin/assign').done(function(assignments) { $.get('/api/admin/assign?user_id=' + uid).done(function(assignedFolders) {
assignments.filter(function(a) { return a.user_id == uid; }).forEach(function(a) { assignedFolders.forEach(function(f) {
$('.img-cb[value="' + a.image_name + '"]').prop('checked', true); $('.folder-cb[value="' + f + '"]').prop('checked', true);
}); });
}); });
}); });
// Zuweisung speichern
$('#save-assignments').click(function() { $('#save-assignments').click(function() {
var uid = $('#user-select').val(); var uid = $('#user-select').val();
if(!uid) return alert('Bitte Nutzer waehlen'); if (!uid) return alert('Bitte Nutzer waehlen');
var selected = []; var selected = [];
$('.img-cb:checked').each(function() { selected.push($(this).val()); }); $('.folder-cb:checked').each(function() { selected.push($(this).val()); });
$.post('/api/admin/assign', { user_id: uid, image_names: selected }) $.ajax({
.done(function() { alert("Zuweisung gespeichert!"); }); url: '/api/admin/assign',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ user_id: uid, folders: selected })
}).done(function() {
$('#assign-status').text('Gespeichert!').fadeIn().delay(2000).fadeOut();
}).fail(function() {
alert('Fehler beim Speichern');
});
}); });
$('#update-btn').click(function() { // ==================== EIGENES PASSWORT ====================
if(!confirm("Achtung: Dies startet den Server auf dem NAS neu. Fortfahren?")) return;
$('#update-status').text('Update laeuft...'); $('#admin-pw-form').submit(function(e) {
$.post('/api/admin/update').done(function(res) { e.preventDefault();
$('#update-status').text(res.message); $.post('/api/auth/change-password', {
setTimeout(function() { window.location.reload(); }, 5000); old_password: $('#admin-pw-old').val(),
new_password: $('#admin-pw-new').val()
}).done(function() {
alert('Passwort geaendert!');
$('#admin-pw-old, #admin-pw-new').val('');
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
}); });
}); });
}); });

View File

@@ -1 +1 @@
$(document).ready(function(){function e(){$.get("/api/admin/users").done(function(e){$("#users-tbody").empty(),$("#user-select").html('<option value="">Nutzer wählen...</option>'),e.forEach(function(e){$("#users-tbody").append("<tr><td>"+e.id+"</td><td>"+e.username+" "+("admin"===e.role?"(Admin)":"")+"</td><td>"+(1!==e.id?'<button onclick="deleteUser('+e.id+')">Löschen</button>':"")+"</td></tr>"),"admin"!==e.role&&$("#user-select").append('<option value="'+e.id+'">'+e.username+"</option>")})})}$.get("/api/auth/me").done(function(n){"admin"!==n.role?(alert("Kein Zugriff!"),window.location.href="/"):(e(),$.get("/images/fulls/").done(function(e){$("#image-checkboxes").empty(),e.forEach(function(e){$("#image-checkboxes").append('<label class="img-check"><img src="/images/thumbs/'+encodeURIComponent(e.name)+'" /><br/><input type="checkbox" value="'+e.name+'" class="img-cb"/></label>')})}),$("body").removeClass("is-preload"))}).fail(function(){window.location.href="/"}),$("#logout-btn").click(function(e){e.preventDefault(),$.post("/api/auth/logout").done(function(){window.location.href="/"})}),window.deleteUser=function(n){confirm("Nutzer loeschen?")&&$.ajax({url:"/api/admin/users/"+n,type:"DELETE"}).done(e)},$("#create-user-form").submit(function(n){n.preventDefault(),$.post("/api/admin/users",{username:$("#new-username").val(),password:$("#new-password").val(),role:"user"}).done(function(){$("#new-username").val(""),$("#new-password").val(""),e()}).fail(function(e){alert("Fehler: "+e.responseJSON.error)})}),$("#user-select").change(function(){var e=$(this).val();$(".img-cb").prop("checked",!1),e&&$.get("/api/admin/assign").done(function(n){n.filter(function(n){return n.user_id==e}).forEach(function(e){$('.img-cb[value="'+e.image_name+'"]').prop("checked",!0)})})}),$("#save-assignments").click(function(){var e=$("#user-select").val();if(!e)return alert("Bitte Nutzer waehlen");var n=[];$(".img-cb:checked").each(function(){n.push($(this).val())}),$.post("/api/admin/assign",{user_id:e,image_names:n}).done(function(){alert("Zuweisung gespeichert!")})}),$("#update-btn").click(function(){confirm("Achtung: Dies startet den Server auf dem NAS neu. Fortfahren?")&&($("#update-status").text("Update laeuft..."),$.post("/api/admin/update").done(function(e){$("#update-status").text(e.message),setTimeout(function(){window.location.reload()},5e3)}))})}); $(document).ready(function(){function e(){$.get("/api/admin/users").done(function(e){$("#users-tbody").empty(),$("#user-select").html('<option value="">Nutzer waehlen...</option>'),e.forEach(function(e){var n='<span class="badge badge-'+e.role+'">'+e.role+"</span>",a=e.created_at?new Date(e.created_at).toLocaleDateString("de-DE"):"-";if("client"===e.role&&e.expires_at){var t=new Date(e.expires_at).toLocaleDateString("de-DE");e.expired?(n='<span class="badge badge-expired">abgelaufen</span>',a="Abgelaufen: "+t):a="Bis: "+t}var r='<button class="btn-small" onclick="changePassword('+e.id+", '"+e.username.replace(/'/g,"\\'")+"')\">PW</button>";r+='<button class="btn-small" onclick="deleteUser('+e.id+", '"+e.username.replace(/'/g,"\\'")+"')\">X</button>",$("#users-tbody").append("<tr><td>"+e.id+"</td><td>"+e.username+" "+n+"</td><td>"+e.role+"</td><td>"+a+"</td><td>"+r+"</td></tr>"),"admin"!==e.role&&$("#user-select").append('<option value="'+e.id+'">'+e.username+" ("+e.role+")</option>")})})}$.get("/api/auth/me").done(function(n){"admin"!==n.role?(alert("Kein Zugriff!"),window.location.href="/"):(e(),$.get("/api/admin/folders").done(function(e){$("#folder-checkboxes").empty(),0!==e.length?e.forEach(function(e){$("#folder-checkboxes").append('<div class="folder-check"><label><input type="checkbox" value="'+e+'" class="folder-cb"/> <i class="fa fa-folder"></i> '+e+"</label></div>")}):$("#folder-checkboxes").html("<p>Keine Unterordner in Websitebilder gefunden. Erstelle Ordner in Nextcloud.</p>")}),$("body").removeClass("is-preload"))}).fail(function(){window.location.href="/"}),$("#logout-btn").click(function(e){e.preventDefault(),$.post("/api/auth/logout").done(function(){window.location.href="/"})}),$("#create-user-form").submit(function(n){n.preventDefault(),$.post("/api/admin/users",{username:$("#new-username").val(),password:$("#new-password").val(),role:$("#new-role").val()}).done(function(){$("#new-username").val(""),$("#new-password").val(""),e()}).fail(function(e){alert("Fehler: "+(e.responseJSON?e.responseJSON.error:"Unbekannt"))})}),window.deleteUser=function(n,a){confirm('Nutzer "'+a+'" wirklich loeschen?')&&$.ajax({url:"/api/admin/users/"+n,type:"DELETE"}).done(e).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})},window.changePassword=function(e,n){var a=prompt('Neues Passwort fuer "'+n+'":');a&&$.ajax({url:"/api/admin/users/"+e+"/password",type:"PUT",contentType:"application/json",data:JSON.stringify({new_password:a})}).done(function(){alert("Passwort geaendert!")}).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})},$("#user-select").change(function(){var e=$(this).val();$(".folder-cb").prop("checked",!1),e&&$.get("/api/admin/assign?user_id="+e).done(function(e){e.forEach(function(e){$('.folder-cb[value="'+e+'"]').prop("checked",!0)})})}),$("#save-assignments").click(function(){var e=$("#user-select").val();if(!e)return alert("Bitte Nutzer waehlen");var n=[];$(".folder-cb:checked").each(function(){n.push($(this).val())}),$.ajax({url:"/api/admin/assign",type:"POST",contentType:"application/json",data:JSON.stringify({user_id:e,folders:n})}).done(function(){$("#assign-status").text("Gespeichert!").fadeIn().delay(2e3).fadeOut()}).fail(function(){alert("Fehler beim Speichern")})}),$("#admin-pw-form").submit(function(e){e.preventDefault(),$.post("/api/auth/change-password",{old_password:$("#admin-pw-old").val(),new_password:$("#admin-pw-new").val()}).done(function(){alert("Passwort geaendert!"),$("#admin-pw-old, #admin-pw-new").val("")}).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})})});

View File

@@ -161,7 +161,7 @@
cache: false, cache: false,
success: function(files) { success: function(files) {
if(files.length === 0) { if(files.length === 0) {
$main.html('<p style="text-align:center;color:#fff;padding:2em;width:100%">Bitte logge dich über den Login-Button unten links ein, um deine zugewiesenen Bilder zu sehen.</p>'); $main.html('<p style="text-align:center;color:#fff;padding:2em;width:100%">Noch keine Bilder vorhanden.</p>');
return; return;
} }
@@ -182,8 +182,11 @@
images.forEach(function(file) { images.forEach(function(file) {
var article = $('<article class="thumb"></article>'); var article = $('<article class="thumb"></article>');
var link = $('<a class="image" href="/images/fulls/' + encodeURIComponent(file.name) + '"></a>'); var prefix = file.folder ? encodeURIComponent(file.folder) + '/' : '';
var img = $('<img src="/images/thumbs/' + encodeURIComponent(file.name) + '" alt="" data-name="' + file.name.replace(/"/g, '&quot;') + '" />'); var fullPath = '/images/fulls/' + prefix + encodeURIComponent(file.name);
var thumbPath = '/images/thumbs/' + prefix + encodeURIComponent(file.name);
var link = $('<a class="image" href="' + fullPath + '"></a>');
var img = $('<img src="' + thumbPath + '" alt="" data-name="' + file.name.replace(/"/g, '&quot;') + '" />');
link.append(img); link.append(img);
article.append(link); article.append(link);
$main.append(article); $main.append(article);
@@ -207,7 +210,7 @@
if ($image.length == 0) return; if ($image.length == 0) return;
$image.css("background-image", "url(" + $image_img.attr("src") + ")"); $image.css("background-image", "url('" + $image_img.attr("src") + "')");
if ((x = $image_img.data("position"))) $image.css("background-position", x); if ((x = $image_img.data("position"))) $image.css("background-position", x);
$image_img.hide(); $image_img.hide();
@@ -279,10 +282,34 @@
return template; return template;
} }
// Galerie laden
loadGallery();
// Theme Toggle
var currentTheme = localStorage.getItem('mischkomposition_theme') || 'main';
updateThemeIcon(currentTheme);
$('#theme-toggle').click(function(e) {
e.preventDefault();
currentTheme = currentTheme === 'main' ? 'light' : 'main';
localStorage.setItem('mischkomposition_theme', currentTheme);
$('#theme-style').attr('href', 'assets/css/' + currentTheme + '.min.css');
updateThemeIcon(currentTheme);
});
function updateThemeIcon(theme) {
if (theme === 'light') {
$('#theme-toggle i').removeClass('fa-sun').addClass('fa-moon');
} else {
$('#theme-toggle i').removeClass('fa-moon').addClass('fa-sun');
}
}
// Login Handling // Login Handling
$.get('/api/auth/me').done(function(u) { $.get('/api/auth/me').done(function(u) {
$('#login-username, #login-password, #login-submit').hide(); $('#login-username, #login-password, #login-submit').hide();
$('#logout-btn').show().css('display', 'inline-block'); $('#logout-btn').show().css('display', 'inline-block');
$('#pw-change-section').show();
if(u.role === 'admin') { if(u.role === 'admin') {
$('#admin-link').html('<a href="/admin.html" class="button">Admin-Dashboard</a>'); $('#admin-link').html('<a href="/admin.html" class="button">Admin-Dashboard</a>');
} }
@@ -293,7 +320,13 @@
e.preventDefault(); e.preventDefault();
$.post('/api/auth/login', { username: $('#login-username').val(), password: $('#login-password').val() }) $.post('/api/auth/login', { username: $('#login-username').val(), password: $('#login-password').val() })
.done(function() { window.location.reload(); }) .done(function() { window.location.reload(); })
.fail(function(err) { alert("Login fehlgeschlagen. Bitte Zugangsdaten ueberpruefen."); }); .fail(function(xhr) {
var msg = 'Login fehlgeschlagen.';
if (xhr.responseJSON && xhr.responseJSON.error === 'Account abgelaufen') {
msg = 'Dein Account ist abgelaufen. Bitte kontaktiere den Administrator.';
}
alert(msg);
});
}); });
$('#logout-btn').click(function(e) { $('#logout-btn').click(function(e) {
@@ -301,4 +334,14 @@
$.post('/api/auth/logout').done(function() { window.location.reload(); }); $.post('/api/auth/logout').done(function() { window.location.reload(); });
}); });
$('#pw-change-form').submit(function(e) {
e.preventDefault();
var newPw = $('#pw-new').val();
var confirmPw = $('#pw-confirm').val();
if (newPw !== confirmPw) { alert('Passwoerter stimmen nicht ueberein.'); return; }
$.post('/api/auth/change-password', { old_password: $('#pw-old').val(), new_password: newPw })
.done(function() { alert('Passwort geaendert!'); $('#pw-old, #pw-new, #pw-confirm').val(''); })
.fail(function(xhr) { alert(xhr.responseJSON ? xhr.responseJSON.error : 'Fehler'); });
});
})(jQuery); })(jQuery);

File diff suppressed because one or more lines are too long

View File

@@ -75,7 +75,7 @@
.caption { .caption {
@include padding(2em, 2em); @include padding(2em, 2em);
@include vendor('background-image', 'linear-gradient(to top, rgba(16,16,16,0.45) 25%, rgba(16,16,16,0) 100%)'); background: rgba(246, 240, 228, 0.95); /* freundliches Beige transparent */
bottom: 0; bottom: 0;
cursor: default; cursor: default;
left: 0; left: 0;
@@ -86,10 +86,11 @@
h2, h3, h4, h5, h6 { h2, h3, h4, h5, h6 {
margin: 0 0 (_size(element-margin) * 0.25) 0; margin: 0 0 (_size(element-margin) * 0.25) 0;
color: #4a4a4a !important; /* Grau */
} }
p { p, span, i, strong, em {
color: _palette(fg-bold); color: #5c554e !important; /* Grau */
} }
} }

54
assets/sass/light.scss Normal file
View File

@@ -0,0 +1,54 @@
@import 'libs/vars';
$palette: (
bg: #f9f6f0,
bg-alt: #eee8dd,
bg-overlay: transparentize(#f9f6f0, 0.15),
bg-overlay-alt: transparentize(#f9f6f0, 0.05),
bg-ie-overlay: transparentize(#f9f6f0, 0.1),
bg-ie-overlay-alt: transparentize(#f9f6f0, 0.05),
fg: #5c554e,
fg-bold: #38332f,
fg-medium: #7a746e,
fg-light: #9e9790,
border: #e6dccc,
border-bg: #f0e5d6,
border-bg-alt: #e1d1ba,
accent1: #e48c71
) !global;
@import 'libs/functions';
@import 'libs/mixins';
@import 'libs/vendor';
@import 'libs/breakpoints';
@include breakpoints((
xlarge: ( 1281px, 1680px ),
large: ( 981px, 1280px ),
medium: ( 737px, 980px ),
small: ( 481px, 736px ),
xsmall: ( null, 480px )
));
@include keyframes(spinner) {
0% { @include vendor('transform', 'rotate(0deg)'); }
100% { @include vendor('transform', 'rotate(359deg)'); }
}
@import 'base/reset';
@import 'base/page';
@import 'base/typography';
@import 'components/button';
@import 'components/form';
@import 'components/icon';
@import 'components/list';
@import 'components/actions';
@import 'components/icons';
@import 'components/table';
@import 'components/panel';
@import 'components/poptrox-popup';
@import 'layout/wrapper';
@import 'layout/header';
@import 'layout/main';
@import 'layout/footer';
@import 'custom';

View File

@@ -8,13 +8,29 @@ const db = new sqlite3.Database(dbPath);
console.log('Using database at', dbPath); console.log('Using database at', dbPath);
db.serialize(() => { db.serialize(() => {
// Users: mit created_at fuer Client-Ablauf (30 Tage)
db.run(`CREATE TABLE IF NOT EXISTS users ( db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL role TEXT NOT NULL DEFAULT 'client',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`); )`);
// Migration: created_at hinzufuegen falls Tabelle schon existiert
db.run(`ALTER TABLE users ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP`, (err) => {
// Ignoriere Fehler wenn Spalte schon existiert
});
// Ordner-Zuweisung (neu)
db.run(`CREATE TABLE IF NOT EXISTS user_folders (
user_id INTEGER,
folder_name TEXT,
FOREIGN KEY(user_id) REFERENCES users(id),
UNIQUE(user_id, folder_name)
)`);
// Alte Tabelle behalten fuer Kompatibilitaet, aber nicht mehr nutzen
db.run(`CREATE TABLE IF NOT EXISTS user_images ( db.run(`CREATE TABLE IF NOT EXISTS user_images (
user_id INTEGER, user_id INTEGER,
image_name TEXT, image_name TEXT,
@@ -22,12 +38,12 @@ db.serialize(() => {
UNIQUE(user_id, image_name) UNIQUE(user_id, image_name)
)`); )`);
// Create default admin user if none exists // Default-Admin erstellen wenn keiner existiert
db.get('SELECT * FROM users WHERE username = ?', ['admin'], (err, row) => { db.get('SELECT * FROM users WHERE username = ?', ['admin'], (err, row) => {
if (!row) { if (!row) {
const hash = bcrypt.hashSync('admin123', 10); const hash = bcrypt.hashSync('admin123', 10);
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', ['admin', hash, 'admin']); db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', ['admin', hash, 'admin']);
console.log('Default admin created with credentials: admin / admin123'); console.log('Default admin created: admin / admin123');
} }
}); });
}); });

View File

@@ -4,29 +4,44 @@ const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { exec } = require('child_process');
const db = require('./database'); const db = require('./database');
const app = express(); const app = express();
const JWT_SECRET = process.env.JWT_SECRET || 'mischkomposition-super-secret-key-change-me'; const JWT_SECRET = process.env.JWT_SECRET || 'mischkomposition-super-secret-key-change-me';
const FULLS_DIR = path.join(__dirname, '../public/images/fulls');
const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i;
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser()); app.use(cookieParser());
// Middleware to verify JWT cookie // ==================== MIDDLEWARE ====================
function authMiddleware(req, res, next) { function authMiddleware(req, res, next) {
const token = req.cookies.token; const token = req.cookies.token;
if (!token) { if (!token) { req.user = null; return next(); }
req.user = null;
return next();
}
jwt.verify(token, JWT_SECRET, (err, decoded) => { jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) req.user = null; if (err) { req.user = null; return next(); }
else req.user = decoded; // Client-Ablauf pruefen (30 Tage)
if (decoded.role === 'client' && decoded.created_at) {
const expires = new Date(decoded.created_at);
expires.setDate(expires.getDate() + 30);
if (new Date() > expires) {
res.clearCookie('token');
req.user = null;
return next();
}
}
req.user = decoded;
next(); next();
}); });
} }
function requireAuth(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'Not logged in' });
next();
}
function requireAdmin(req, res, next) { function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') { if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' }); return res.status(403).json({ error: 'Forbidden' });
@@ -36,6 +51,28 @@ function requireAdmin(req, res, next) {
app.use(authMiddleware); app.use(authMiddleware);
// ==================== HELPER ====================
function listImagesInDir(dir) {
try {
const files = fs.readdirSync(dir);
return files.filter(f => IMAGE_RE.test(f) && fs.statSync(path.join(dir, f)).isFile());
} catch (e) {
return [];
}
}
function listSubfolders(dir) {
try {
return fs.readdirSync(dir).filter(f => {
try { return fs.statSync(path.join(dir, f)).isDirectory(); }
catch (e) { return false; }
});
} catch (e) {
return [];
}
}
// ==================== AUTH API ==================== // ==================== AUTH API ====================
app.post('/api/auth/login', (req, res) => { app.post('/api/auth/login', (req, res) => {
@@ -45,7 +82,18 @@ app.post('/api/auth/login', (req, res) => {
if (!user || !bcrypt.compareSync(password, user.password_hash)) { if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Invalid credentials' }); return res.status(401).json({ error: 'Invalid credentials' });
} }
const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, JWT_SECRET, { expiresIn: '7d' }); // Client-Ablauf pruefen
if (user.role === 'client' && user.created_at) {
const expires = new Date(user.created_at);
expires.setDate(expires.getDate() + 30);
if (new Date() > expires) {
return res.status(401).json({ error: 'Account abgelaufen' });
}
}
const token = jwt.sign(
{ id: user.id, username: user.username, role: user.role, created_at: user.created_at },
JWT_SECRET, { expiresIn: '7d' }
);
res.cookie('token', token, { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 }); res.cookie('token', token, { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 });
res.json({ success: true, role: user.role }); res.json({ success: true, role: user.role });
}); });
@@ -61,123 +109,154 @@ app.get('/api/auth/me', (req, res) => {
res.json(req.user); res.json(req.user);
}); });
// ==================== IMAGE PROXY ==================== app.post('/api/auth/change-password', requireAuth, (req, res) => {
const { old_password, new_password } = req.body;
// We must override the nginx `/images/fulls/` JSON behavior here: if (!new_password || new_password.length < 4) {
app.get('/images/fulls/', (req, res) => { return res.status(400).json({ error: 'Passwort muss mindestens 4 Zeichen lang sein' });
if (!req.user) return res.json([]); // Not logged in -> empty gallery }
db.get('SELECT * FROM users WHERE id = ?', [req.user.id], (err, user) => {
const fullsDir = path.join(__dirname, '../public/images/fulls'); if (err || !user) return res.status(500).json({ error: 'DB Error' });
if (!bcrypt.compareSync(old_password, user.password_hash)) {
fs.readdir(fullsDir, (err, files) => { return res.status(401).json({ error: 'Altes Passwort ist falsch' });
if (err) return res.json([]);
let imageFiles = files.filter(f => f.match(/\.(jpg|jpeg|png|gif|webp)$/i));
if (req.user.role === 'admin') {
// Admin sees all
const result = imageFiles.map(name => {
const stats = fs.statSync(path.join(fullsDir, name));
return { name, type: 'file', mtime: stats.mtime.toUTCString(), size: stats.size };
});
return res.json(result);
} else {
// Normal user sees only assigned
db.all('SELECT image_name FROM user_images WHERE user_id = ?', [req.user.id], (err, rows) => {
if (err) return res.json([]);
const assignedNames = rows.map(r => r.image_name);
const assignedFiles = imageFiles.filter(name => assignedNames.includes(name));
const result = assignedFiles.map(name => {
const stats = fs.statSync(path.join(fullsDir, name));
return { name, type: 'file', mtime: stats.mtime.toUTCString(), size: stats.size };
});
return res.json(result);
});
} }
const hash = bcrypt.hashSync(new_password, 10);
db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, user.id], (err) => {
if (err) return res.status(500).json({ error: 'DB Error' });
res.json({ success: true });
});
}); });
}); });
// Proxy image files directly, but we let Express serve static later. // ==================== IMAGE LISTING ====================
// BUT we should restrict access to physical image files?
// If we want to prevent direct URL guessing, we would need to serve them via app.get('/images/fulls/:name'). app.get('/images/fulls/', (req, res) => {
// For now, let's just use express.static to serve the actual binary files. // Root-Bilder sind immer oeffentlich
// Nextcloud folder names are hard to guess anyway. const rootImages = listImagesInDir(FULLS_DIR).map(name => ({
name, type: 'file', folder: null
}));
// Nicht eingeloggt: nur Root-Bilder
if (!req.user) return res.json(rootImages);
const folders = listSubfolders(FULLS_DIR);
// Admin: Nur Root-Bilder (damit die Startseite sauber bleibt)
if (req.user.role === 'admin') {
return res.json(rootImages);
}
// User/Client: NUR zugewiesene Ordner (keine Root-Bilder untermischen)
db.all('SELECT folder_name FROM user_folders WHERE user_id = ?', [req.user.id], (err, rows) => {
const assignedFolders = err ? [] : rows.map(r => r.folder_name);
const result = [];
assignedFolders.forEach(folder => {
if (folders.includes(folder)) {
listImagesInDir(path.join(FULLS_DIR, folder)).forEach(name => {
result.push({ name, type: 'file', folder });
});
}
});
return res.json(result);
});
});
// ==================== ADMIN API ==================== // ==================== ADMIN API ====================
app.get('/api/admin/users', requireAdmin, (req, res) => { app.get('/api/admin/users', requireAdmin, (req, res) => {
db.all('SELECT id, username, role FROM users', (err, rows) => { db.all('SELECT id, username, role, created_at FROM users', (err, rows) => {
res.json(rows); if (err) return res.json([]);
// Ablaufdatum berechnen fuer Clients
const result = rows.map(u => {
const out = { ...u };
if (u.role === 'client' && u.created_at) {
const expires = new Date(u.created_at);
expires.setDate(expires.getDate() + 30);
out.expires_at = expires.toISOString();
out.expired = new Date() > expires;
}
return out;
});
res.json(result);
}); });
}); });
app.post('/api/admin/users', requireAdmin, (req, res) => { app.post('/api/admin/users', requireAdmin, (req, res) => {
const { username, password, role } = req.body; const { username, password, role } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Username und Passwort erforderlich' });
const validRoles = ['admin', 'user', 'client'];
const userRole = validRoles.includes(role) ? role : 'client';
const hash = bcrypt.hashSync(password, 10); const hash = bcrypt.hashSync(password, 10);
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', [username, hash, role || 'user'], function(err) { db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', [username, hash, userRole], function(err) {
if (err) return res.status(400).json({ error: err.message }); if (err) return res.status(400).json({ error: err.message });
res.json({ success: true, id: this.lastID }); res.json({ success: true, id: this.lastID });
}); });
}); });
app.delete('/api/admin/users/:id', requireAdmin, (req, res) => { app.delete('/api/admin/users/:id', requireAdmin, (req, res) => {
if (req.params.id == 1) return res.status(400).json({ error: 'Cannot delete primary admin' }); const userId = parseInt(req.params.id);
db.run('DELETE FROM users WHERE id = ?', [req.params.id], err => { // Sich selbst loeschen verhindern
db.run('DELETE FROM user_images WHERE user_id = ?', [req.params.id]); if (userId === req.user.id) {
return res.status(400).json({ error: 'Du kannst dich nicht selbst loeschen' });
}
db.run('DELETE FROM users WHERE id = ?', [userId], (err) => {
if (err) return res.status(500).json({ error: 'DB Error' });
db.run('DELETE FROM user_folders WHERE user_id = ?', [userId]);
res.json({ success: true }); res.json({ success: true });
}); });
}); });
app.put('/api/admin/users/:id/password', requireAdmin, (req, res) => {
const { new_password } = req.body;
if (!new_password || new_password.length < 4) {
return res.status(400).json({ error: 'Passwort muss mindestens 4 Zeichen lang sein' });
}
const hash = bcrypt.hashSync(new_password, 10);
db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, parseInt(req.params.id)], (err) => {
if (err) return res.status(500).json({ error: 'DB Error' });
res.json({ success: true });
});
});
// Ordner-Verwaltung
app.get('/api/admin/folders', requireAdmin, (req, res) => {
res.json(listSubfolders(FULLS_DIR));
});
app.get('/api/admin/assign', requireAdmin, (req, res) => { app.get('/api/admin/assign', requireAdmin, (req, res) => {
db.all('SELECT * FROM user_images', (err, rows) => { const userId = req.query.user_id;
res.json(rows); if (userId) {
}); db.all('SELECT folder_name FROM user_folders WHERE user_id = ?', [userId], (err, rows) => {
res.json(err ? [] : rows.map(r => r.folder_name));
});
} else {
db.all('SELECT * FROM user_folders', (err, rows) => {
res.json(err ? [] : rows);
});
}
}); });
app.post('/api/admin/assign', requireAdmin, (req, res) => { app.post('/api/admin/assign', requireAdmin, (req, res) => {
const { user_id, image_names } = req.body; // image_names is array of strings const { user_id, folders } = req.body;
if (!user_id) return res.status(400).json({ error: 'user_id erforderlich' });
const folderList = Array.isArray(folders) ? folders : [];
db.serialize(() => { db.serialize(() => {
db.run('BEGIN TRANSACTION'); db.run('DELETE FROM user_folders WHERE user_id = ?', [user_id]);
db.run('DELETE FROM user_images WHERE user_id = ?', [user_id]); const stmt = db.prepare('INSERT INTO user_folders (user_id, folder_name) VALUES (?, ?)');
const stmt = db.prepare('INSERT INTO user_images (user_id, image_name) VALUES (?, ?)'); folderList.forEach(name => stmt.run(user_id, name));
image_names.forEach(name => {
stmt.run(user_id, name);
});
stmt.finalize(); stmt.finalize();
db.run('COMMIT');
res.json({ success: true }); res.json({ success: true });
}); });
}); });
app.post('/api/admin/update', requireAdmin, (req, res) => {
console.log("Triggering Docker Compose Update...");
// Wir fuehren den Docker compose pull/down/up aus.
// ACHTUNG: Der Server stuertzt danach ohnehin im Container ab (da er neu gestartet wird).
const cmd = "docker compose pull && docker compose down && docker compose up -d";
// Return early to ensure client gets success BEFORE container dies
res.json({ success: true, message: 'Update gestartet. Container wird neu gestartet...' });
setTimeout(() => {
exec(cmd, { cwd: '/app' }, (err, stdout, stderr) => {
if (err) console.error("Update failed:", err);
else console.log("Update success:", stdout);
});
}, 1000);
});
// ==================== STATIC FILES ==================== // ==================== STATIC FILES ====================
app.use(express.static(path.join(__dirname, '../public'))); app.use(express.static(path.join(__dirname, '../public')));
// Catch all - send index.html
app.get('*', (req, res) => { app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html')); res.sendFile(path.join(__dirname, '../public/index.html'));
}); });
const PORT = 8080; const PORT = 8090;
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Backend server listening on port ${PORT}`); console.log(`Mischkomposition Backend on port ${PORT}`);
}); });

View File

@@ -3,16 +3,33 @@ services:
image: ghcr.io/kroonk/photography:latest image: ghcr.io/kroonk/photography:latest
container_name: photography-website container_name: photography-website
ports: ports:
- "8090:8080" - "8090:8090"
volumes: volumes:
# Nextcloud-Ordner mit Originalbildern (read-only) # Nextcloud-Ordner mit Originalbildern (read-only)
- /volume1/nextcloud/data/Tom/files/Websitebilder:/app/public/images/fulls:ro - /volume1/nextcloud/data/Tom/files/Websitebilder:/app/public/images/fulls:ro
# Thumbnails werden automatisch generiert und hier gespeichert # Thumbnails werden automatisch generiert und hier gespeichert
- thumbs:/app/public/images/thumbs - thumbs:/app/public/images/thumbs
# SQLite Database Volume # SQLite Database (persistent)
- database:/app/data - database:/app/data
# Docker Socket fuer One-Click Update # Zugriff auf Docker & Credentials fuer den One-Click "Website Aktualisieren" Button
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ~/.docker/config.json:/root/.docker/config.json:ro
restart: unless-stopped
# Watchtower: Prueft automatisch auf neue Docker-Images und aktualisiert den Container
watchtower:
image: containrrr/watchtower
container_name: watchtower
# Nur unsere Webseite updaten, andere UGREEN Container ignorieren!
command: photography-website
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# GitHub Login von deinem NAS uebernehmen, um ghcr.io lesen zu duerfen
- ~/.docker/config.json:/config.json:ro
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_POLL_INTERVAL=300
- WATCHTOWER_LABEL_ENABLE=false
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@@ -3,36 +3,49 @@
FULLS_DIR="/app/public/images/fulls" FULLS_DIR="/app/public/images/fulls"
THUMBS_DIR="/app/public/images/thumbs" THUMBS_DIR="/app/public/images/thumbs"
generate_thumbs() { # Thumbnails fuer einen Quell- und Zielordner generieren
mkdir -p "$THUMBS_DIR" generate_thumbs_for_dir() {
for img in "$FULLS_DIR"/*; do src_dir="$1"
dst_dir="$2"
mkdir -p "$dst_dir"
for img in "$src_dir"/*; do
[ -f "$img" ] || continue [ -f "$img" ] || continue
filename=$(basename "$img") filename=$(basename "$img")
ext=$(echo "${filename##*.}" | tr '[:upper:]' '[:lower:]') ext=$(echo "${filename##*.}" | tr '[:upper:]' '[:lower:]')
# Nur Bilddateien verarbeiten
case "$ext" in case "$ext" in
jpg|jpeg|png|gif|webp) ;; jpg|jpeg|png|gif|webp) ;;
*) continue ;; *) continue ;;
esac esac
# Thumbnail nur erstellen wenn noch nicht vorhanden oder aelter als Original if [ ! -f "$dst_dir/$filename" ] || [ "$img" -nt "$dst_dir/$filename" ]; then
if [ ! -f "$THUMBS_DIR/$filename" ] || [ "$img" -nt "$THUMBS_DIR/$filename" ]; then echo "Thumbnail erstellen: $dst_dir/$filename"
echo "Thumbnail erstellen: $filename" convert "$img" -resize 512x512 -quality 80 "$dst_dir/$filename" 2>/dev/null
convert "$img" -resize 512x512 -quality 80 "$THUMBS_DIR/$filename" 2>/dev/null
fi fi
done done
} }
generate_all_thumbs() {
# Root-Bilder
generate_thumbs_for_dir "$FULLS_DIR" "$THUMBS_DIR"
# Unterordner (Kunden-Ordner)
for dir in "$FULLS_DIR"/*/; do
[ -d "$dir" ] || continue
foldername=$(basename "$dir")
generate_thumbs_for_dir "$dir" "$THUMBS_DIR/$foldername"
done
}
echo "=== Mischkomposition - Starte Thumbnail-Generierung ===" echo "=== Mischkomposition - Starte Thumbnail-Generierung ==="
generate_thumbs generate_all_thumbs
echo "=== Thumbnail-Generierung abgeschlossen ===" echo "=== Thumbnail-Generierung abgeschlossen ==="
# Hintergrund-Prozess: Alle 60 Sekunden nach neuen Bildern suchen # Hintergrund-Prozess: Alle 60 Sekunden nach neuen Bildern suchen
(while true; do (while true; do
sleep 60 sleep 60
generate_thumbs generate_all_thumbs
done) & done) &
# Node Server starten # Node Server starten

View File

@@ -10,6 +10,8 @@ layout: default
<h1><a href="index.html"><strong>{{ site.header.title }}</strong> {{ site.header.subtitle }}</a></h1> <h1><a href="index.html"><strong>{{ site.header.title }}</strong> {{ site.header.subtitle }}</a></h1>
<nav> <nav>
<ul> <ul>
<li><a href="#" id="theme-toggle" title="Theme wechseln"><i class="fa fa-sun"></i></a></li>
<li><a href="#galerie" id="galerie-link">Galerie</a></li>
<li><a href="#login-panel" id="login-nav">Login&nbsp;&nbsp;<i class="fa fa-user"></i></a></li> <li><a href="#login-panel" id="login-nav">Login&nbsp;&nbsp;<i class="fa fa-user"></i></a></li>
<li><a href="#footer">About&nbsp;&nbsp;<i class="fa fa-info-circle text-fg"></i></a></li> <li><a href="#footer">About&nbsp;&nbsp;<i class="fa fa-info-circle text-fg"></i></a></li>
</ul> </ul>
@@ -130,6 +132,25 @@ layout: default
<li id="admin-link"></li> <li id="admin-link"></li>
</ul> </ul>
</form> </form>
<div id="pw-change-section" style="display:none; margin-top:1.5em; border-top:1px solid rgba(255,255,255,0.2); padding-top:1.5em;">
<h3>Passwort aendern</h3>
<form id="pw-change-form">
<div class="fields">
<div class="field">
<input type="password" id="pw-old" placeholder="Altes Passwort" required />
</div>
<div class="field half">
<input type="password" id="pw-new" placeholder="Neues Passwort" required />
</div>
<div class="field half">
<input type="password" id="pw-confirm" placeholder="Passwort bestaetigen" required />
</div>
</div>
<ul class="actions">
<li><input type="submit" value="Passwort aendern" class="primary" /></li>
</ul>
</form>
</div>
</section> </section>
</div> </div>
</footer> </footer>