Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07ab0461e9 | |||
| 96b979a878 |
12
.env.example
12
.env.example
@@ -5,7 +5,19 @@ DATABASE_PATH=./data/putzliga.db
|
|||||||
UPLOAD_FOLDER=./data/uploads
|
UPLOAD_FOLDER=./data/uploads
|
||||||
APP_BASE_URL=http://localhost:8000
|
APP_BASE_URL=http://localhost:8000
|
||||||
APP_TIMEZONE=Europe/Berlin
|
APP_TIMEZONE=Europe/Berlin
|
||||||
|
|
||||||
|
# Web Push
|
||||||
|
# Lokal: APP_BASE_URL auf deine lokale URL setzen
|
||||||
|
# Cloudron: APP_BASE_URL leer lassen und automatisch CLOUDRON_APP_ORIGIN nutzen
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
VAPID_CLAIMS_SUBJECT=mailto:admin@example.com
|
VAPID_CLAIMS_SUBJECT=mailto:admin@example.com
|
||||||
|
|
||||||
|
# Beispiel für Cloudron:
|
||||||
|
# SECRET_KEY=ein-langer-zufallswert
|
||||||
|
# APP_TIMEZONE=Europe/Berlin
|
||||||
|
# VAPID_PUBLIC_KEY=...
|
||||||
|
# VAPID_PRIVATE_KEY=...
|
||||||
|
# VAPID_CLAIMS_SUBJECT=mailto:mail@deine-domain.tld
|
||||||
|
|
||||||
GUNICORN_WORKERS=2
|
GUNICORN_WORKERS=2
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"author": "hnzio <mail@example.com>",
|
"author": "hnzio <mail@example.com>",
|
||||||
"description": "Spielerische Haushalts-App mit Aufgaben, Punkten, Monats-Highscore, Kalender und PWA-Push.",
|
"description": "Spielerische Haushalts-App mit Aufgaben, Punkten, Monats-Highscore, Kalender und PWA-Push.",
|
||||||
"tagline": "Haushalt mit Liga-Gefühl",
|
"tagline": "Haushalt mit Liga-Gefühl",
|
||||||
"version": "1.0.0",
|
"version": "0.6.0",
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
"healthCheckPath": "/healthz",
|
"healthCheckPath": "/healthz",
|
||||||
"httpPort": 8000,
|
"httpPort": 8000,
|
||||||
|
|||||||
59
README.md
59
README.md
@@ -296,6 +296,35 @@ https://docs.cloudron.io/docker/
|
|||||||
- `APP_BASE_URL` kann auf Cloudron über `CLOUDRON_APP_ORIGIN` gesetzt oder daraus abgeleitet werden
|
- `APP_BASE_URL` kann auf Cloudron über `CLOUDRON_APP_ORIGIN` gesetzt oder daraus abgeleitet werden
|
||||||
- Lokale Testdaten aus `data/` werden weder committed noch in das Docker-Image gepackt
|
- Lokale Testdaten aus `data/` werden weder committed noch in das Docker-Image gepackt
|
||||||
|
|
||||||
|
### Cloudron-ENV für Push
|
||||||
|
|
||||||
|
Für Cloudron brauchst du mindestens diese Variablen:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SECRET_KEY=hier-einen-langen-zufallswert-eintragen
|
||||||
|
APP_TIMEZONE=Europe/Berlin
|
||||||
|
VAPID_PUBLIC_KEY=dein_public_key
|
||||||
|
VAPID_PRIVATE_KEY=dein_private_key
|
||||||
|
VAPID_CLAIMS_SUBJECT=mailto:mail@deine-domain.tld
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinweise:
|
||||||
|
|
||||||
|
- `APP_BASE_URL` musst du auf Cloudron normalerweise nicht setzen, weil `config.py` automatisch `CLOUDRON_APP_ORIGIN` verwendet
|
||||||
|
- `DATA_DIR`, `DATABASE_PATH` und `UPLOAD_FOLDER` können auf den Standardwerten bleiben, solange du die App normal auf Cloudron betreibst
|
||||||
|
- `VAPID_PRIVATE_KEY` darf als einzeiliger Wert mit escaped `\\n` gespeichert werden; Putzliga wandelt das beim Start automatisch zurück
|
||||||
|
|
||||||
|
Wenn du die Werte lokal in einer `.env` testen willst, kann das zum Beispiel so aussehen:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SECRET_KEY=lokal-irgendein-langer-zufallswert
|
||||||
|
APP_BASE_URL=http://127.0.0.1:5000
|
||||||
|
APP_TIMEZONE=Europe/Berlin
|
||||||
|
VAPID_PUBLIC_KEY=dein_public_key
|
||||||
|
VAPID_PRIVATE_KEY=dein_private_key
|
||||||
|
VAPID_CLAIMS_SUBJECT=mailto:mail@deine-domain.tld
|
||||||
|
```
|
||||||
|
|
||||||
### Beispielstart in Cloudron-/Container-Umgebungen
|
### Beispielstart in Cloudron-/Container-Umgebungen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -312,6 +341,36 @@ python scripts/generate_vapid_keys.py
|
|||||||
|
|
||||||
Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Newlines formatiert. `config.py` wandelt `\\n` beim Start automatisch in echte Zeilenumbrüche zurück.
|
Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Newlines formatiert. `config.py` wandelt `\\n` beim Start automatisch in echte Zeilenumbrüche zurück.
|
||||||
|
|
||||||
|
## Release Notes
|
||||||
|
|
||||||
|
### 0.6.0
|
||||||
|
|
||||||
|
- Persönlicher read-only ICS-Feed pro Nutzer für externe Kalender ergänzt
|
||||||
|
- Export enthält nur eigene zugewiesene Aufgaben mit Titel, Beschreibung und Fälligkeitsdatum
|
||||||
|
- Kalender-Link in den Optionen sichtbar gemacht und Token-Neugenerierung ergänzt
|
||||||
|
- Cloudron-Version auf `0.6.0` angehoben
|
||||||
|
|
||||||
|
### 0.5.1
|
||||||
|
|
||||||
|
- Footer mit automatischer Versionsanzeige und Links zu Releases und hnz.io
|
||||||
|
- Login-Seite bereinigt und Demo-Login-Hinweis entfernt
|
||||||
|
- Mobile Abstände rund um die feste Bottom-Navigation weiter nachgeschärft
|
||||||
|
- Cloudron-Version auf `0.5.1` angehoben
|
||||||
|
|
||||||
|
### 0.5.0
|
||||||
|
|
||||||
|
- Mehrnutzer-Haushalts-App mit Login, Admin-Nutzermanagement und deaktivierter offener Registrierung
|
||||||
|
- Aufgaben mit sauberer Trennung aus `TaskTemplate` und `TaskInstance`
|
||||||
|
- Wiederkehrende Aufgaben für Tage, Wochen und Monate
|
||||||
|
- Monats-Highscore mit Badge-Boni, Balkenanzeige und Monatsarchiv
|
||||||
|
- Persistentes Badge-System mit eigener Admin-Seite
|
||||||
|
- Schnellaufgabe per globalem Plus-Button mit admin-konfigurierbarem Aufwand
|
||||||
|
- Kalender- und Listenansicht, inklusive mobiler Agenda-Ansicht
|
||||||
|
- Systemabhängiges Light-/Dark-Design
|
||||||
|
- PWA-Grundlage mit Manifest, Service Worker und App-Icons
|
||||||
|
- Echte Web-Push-Architektur mit VAPID und Cloudron-tauglicher ENV-Konfiguration
|
||||||
|
- Cloudron-kompatibler Start mit persistentem Storage, ohne lokale Entwicklungsdaten ins Deployment zu übernehmen
|
||||||
|
|
||||||
Icons neu generieren:
|
Icons neu generieren:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -22,6 +23,12 @@ def create_app(config_class: type[Config] = Config) -> Flask:
|
|||||||
app = Flask(__name__, static_folder="static", template_folder="templates")
|
app = Flask(__name__, static_folder="static", template_folder="templates")
|
||||||
app.config.from_object(config_class)
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
manifest_path = Path(app.root_path).parent / "CloudronManifest.json"
|
||||||
|
try:
|
||||||
|
app.config["APP_VERSION"] = json.loads(manifest_path.read_text(encoding="utf-8")).get("version", "0.0.0")
|
||||||
|
except FileNotFoundError:
|
||||||
|
app.config["APP_VERSION"] = "0.0.0"
|
||||||
|
|
||||||
app.config["DATA_DIR"].mkdir(parents=True, exist_ok=True)
|
app.config["DATA_DIR"].mkdir(parents=True, exist_ok=True)
|
||||||
app.config["UPLOAD_FOLDER"].mkdir(parents=True, exist_ok=True)
|
app.config["UPLOAD_FOLDER"].mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -66,6 +73,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"app_name": app.config["APP_NAME"],
|
"app_name": app.config["APP_NAME"],
|
||||||
|
"app_version": app.config["APP_VERSION"],
|
||||||
"nav_items": [
|
"nav_items": [
|
||||||
("tasks.my_tasks", "Meine Aufgaben", "house"),
|
("tasks.my_tasks", "Meine Aufgaben", "house"),
|
||||||
("tasks.all_tasks", "Alle", "list"),
|
("tasks.all_tasks", "Alle", "list"),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
|
||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
@@ -24,6 +25,7 @@ class User(UserMixin, TimestampMixin, db.Model):
|
|||||||
password_hash = db.Column(db.String(255), nullable=False)
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
is_admin = db.Column(db.Boolean, nullable=False, default=False)
|
is_admin = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
avatar_path = db.Column(db.String(255), nullable=True)
|
avatar_path = db.Column(db.String(255), nullable=True)
|
||||||
|
calendar_feed_token = db.Column(db.String(255), nullable=True, unique=True, index=True)
|
||||||
notification_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True)
|
notification_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
notification_monthly_winner_enabled = db.Column(db.Boolean, nullable=False, default=True)
|
notification_monthly_winner_enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
@@ -60,6 +62,11 @@ class User(UserMixin, TimestampMixin, db.Model):
|
|||||||
def check_password(self, password: str) -> bool:
|
def check_password(self, password: str) -> bool:
|
||||||
return check_password_hash(self.password_hash, password)
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
def ensure_calendar_feed_token(self) -> str:
|
||||||
|
if not self.calendar_feed_token:
|
||||||
|
self.calendar_feed_token = secrets.token_urlsafe(32)
|
||||||
|
return self.calendar_feed_token
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_avatar(self) -> str:
|
def display_avatar(self) -> str:
|
||||||
return self.avatar_path or "images/avatars/default.svg"
|
return self.avatar_path or "images/avatars/default.svg"
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, current_app, redirect, send_from_directory, url_for
|
from flask import Blueprint, Response, current_app, redirect, send_from_directory, url_for
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from ..models import User
|
||||||
|
from ..services.calendar_feeds import build_calendar_feed
|
||||||
|
|
||||||
bp = Blueprint("main", __name__)
|
bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
@@ -39,6 +41,16 @@ def uploads(filename: str):
|
|||||||
return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename)
|
return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/calendar-feed/<token>.ics")
|
||||||
|
def calendar_feed(token: str):
|
||||||
|
user = User.query.filter_by(calendar_feed_token=token).first_or_404()
|
||||||
|
body = build_calendar_feed(user, url_for("tasks.my_tasks", _external=True))
|
||||||
|
response = Response(body, content_type="text/calendar; charset=utf-8")
|
||||||
|
response.headers["Content-Disposition"] = 'inline; filename="putzliga.ics"'
|
||||||
|
response.headers["Cache-Control"] = "private, max-age=300"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=64)
|
@lru_cache(maxsize=64)
|
||||||
def load_icon_svg(name: str, static_folder: str) -> str:
|
def load_icon_svg(name: str, static_folder: str) -> str:
|
||||||
path = Path(static_folder) / "icons" / f"{name}.svg"
|
path = Path(static_folder) / "icons" / f"{name}.svg"
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ def _save_avatar(file_storage) -> str:
|
|||||||
@bp.route("", methods=["GET", "POST"])
|
@bp.route("", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
|
current_user.ensure_calendar_feed_token()
|
||||||
form = SettingsProfileForm(original_email=current_user.email, obj=current_user)
|
form = SettingsProfileForm(original_email=current_user.email, obj=current_user)
|
||||||
admin_form = AdminUserForm(prefix="admin")
|
admin_form = AdminUserForm(prefix="admin")
|
||||||
quick_task_config_form = QuickTaskConfigForm(prefix="quickconfig")
|
quick_task_config_form = QuickTaskConfigForm(prefix="quickconfig")
|
||||||
@@ -80,6 +81,7 @@ def index():
|
|||||||
quick_task_config=quick_task_config,
|
quick_task_config=quick_task_config,
|
||||||
users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(),
|
users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(),
|
||||||
earned_badges=earned_badges_for_user(current_user.id),
|
earned_badges=earned_badges_for_user(current_user.id),
|
||||||
|
calendar_feed_url=url_for("main.calendar_feed", token=current_user.calendar_feed_token, _external=True),
|
||||||
push_ready=push_enabled(),
|
push_ready=push_enabled(),
|
||||||
has_subscription=bool(subscriptions),
|
has_subscription=bool(subscriptions),
|
||||||
settings_tabs=_settings_tabs(),
|
settings_tabs=_settings_tabs(),
|
||||||
@@ -87,6 +89,16 @@ def index():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/calendar-feed/regenerate", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def regenerate_calendar_feed():
|
||||||
|
current_user.calendar_feed_token = None
|
||||||
|
current_user.ensure_calendar_feed_token()
|
||||||
|
db.session.commit()
|
||||||
|
flash("Dein persönlicher Kalender-Link wurde neu erzeugt.", "success")
|
||||||
|
return redirect(url_for("settings.index"))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/badges")
|
@bp.route("/badges")
|
||||||
@login_required
|
@login_required
|
||||||
def badges():
|
def badges():
|
||||||
|
|||||||
@@ -17,8 +17,18 @@ def ensure_schema_and_admins() -> None:
|
|||||||
db.session.execute(text("ALTER TABLE user ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0"))
|
db.session.execute(text("ALTER TABLE user ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0"))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
if "calendar_feed_token" not in column_names:
|
||||||
|
db.session.execute(text("ALTER TABLE user ADD COLUMN calendar_feed_token VARCHAR(255)"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
ensure_app_settings()
|
ensure_app_settings()
|
||||||
|
|
||||||
|
users_without_feed = User.query.filter(User.calendar_feed_token.is_(None)).all()
|
||||||
|
if users_without_feed:
|
||||||
|
for user in users_without_feed:
|
||||||
|
user.ensure_calendar_feed_token()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
admin_exists = User.query.filter_by(is_admin=True).first()
|
admin_exists = User.query.filter_by(is_admin=True).first()
|
||||||
if admin_exists:
|
if admin_exists:
|
||||||
return
|
return
|
||||||
|
|||||||
60
app/services/calendar_feeds.py
Normal file
60
app/services/calendar_feeds.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, time, timedelta
|
||||||
|
|
||||||
|
from ..models import TaskInstance, User
|
||||||
|
|
||||||
|
|
||||||
|
def _ics_escape(value: str | None) -> str:
|
||||||
|
text = (value or "").replace("\\", "\\\\")
|
||||||
|
text = text.replace(";", "\\;").replace(",", "\\,")
|
||||||
|
text = text.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _format_date(value) -> str:
|
||||||
|
return value.strftime("%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_timestamp(value: datetime | None) -> str:
|
||||||
|
timestamp = value or datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
return timestamp.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def build_calendar_feed(user: User, base_url: str) -> str:
|
||||||
|
tasks = (
|
||||||
|
TaskInstance.query.filter_by(assigned_user_id=user.id)
|
||||||
|
.order_by(TaskInstance.due_date.asc(), TaskInstance.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//hnz.io//Putzliga//DE",
|
||||||
|
"CALSCALE:GREGORIAN",
|
||||||
|
"METHOD:PUBLISH",
|
||||||
|
f"X-WR-CALNAME:{_ics_escape(f'Putzliga - {user.name}')}",
|
||||||
|
f"X-WR-CALDESC:{_ics_escape('Persoenlicher Aufgabenfeed aus Putzliga')}",
|
||||||
|
]
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
due_end = task.due_date + timedelta(days=1)
|
||||||
|
description = _ics_escape(task.description)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
f"UID:taskinstance-{task.id}@putzliga",
|
||||||
|
f"DTSTAMP:{_format_timestamp(task.updated_at)}",
|
||||||
|
f"LAST-MODIFIED:{_format_timestamp(task.updated_at)}",
|
||||||
|
f"SUMMARY:{_ics_escape(task.title)}",
|
||||||
|
f"DESCRIPTION:{description}",
|
||||||
|
f"DTSTART;VALUE=DATE:{_format_date(task.due_date)}",
|
||||||
|
f"DTEND;VALUE=DATE:{_format_date(due_end)}",
|
||||||
|
f"URL:{_ics_escape(base_url)}",
|
||||||
|
"END:VEVENT",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append("END:VCALENDAR")
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
--font-body: "InterLocal", system-ui, sans-serif;
|
--font-body: "InterLocal", system-ui, sans-serif;
|
||||||
--font-heading: "SpaceGroteskLocal", "InterLocal", sans-serif;
|
--font-heading: "SpaceGroteskLocal", "InterLocal", sans-serif;
|
||||||
--safe-bottom: max(24px, env(safe-area-inset-bottom));
|
--safe-bottom: max(24px, env(safe-area-inset-bottom));
|
||||||
|
--mobile-nav-space: 138px;
|
||||||
--body-radial-a: rgba(181, 210, 255, 0.85);
|
--body-radial-a: rgba(181, 210, 255, 0.85);
|
||||||
--body-radial-b: rgba(255, 221, 196, 0.48);
|
--body-radial-b: rgba(255, 221, 196, 0.48);
|
||||||
--body-linear-start: #f8fbff;
|
--body-linear-start: #f8fbff;
|
||||||
@@ -147,7 +148,9 @@ p {
|
|||||||
|
|
||||||
.page-shell {
|
.page-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 24px 18px calc(100px + var(--safe-bottom));
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px 18px calc(var(--mobile-nav-space) + var(--safe-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
@@ -179,6 +182,26 @@ p {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 18px 0 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer span {
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel,
|
.panel,
|
||||||
|
|||||||
@@ -40,7 +40,6 @@
|
|||||||
<label class="checkbox">{{ form.remember_me() }} <span>Angemeldet bleiben</span></label>
|
<label class="checkbox">{{ form.remember_me() }} <span>Angemeldet bleiben</span></label>
|
||||||
{{ form.submit(class_="button button--wide") }}
|
{{ form.submit(class_="button button--wide") }}
|
||||||
</form>
|
</form>
|
||||||
<p class="inline-note">Demo-Logins nach dem Seeden: `anna@putzliga.local` / `putzliga123` und `ben@putzliga.local` / `putzliga123`.</p>
|
|
||||||
{% if registration_open %}
|
{% if registration_open %}
|
||||||
<p class="inline-note">Es gibt noch keinen Nutzer. <a href="{{ url_for('auth.register') }}">Ersten Account anlegen</a></p>
|
<p class="inline-note">Es gibt noch keinen Nutzer. <a href="{{ url_for('auth.register') }}">Ersten Account anlegen</a></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -86,6 +86,12 @@
|
|||||||
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<a href="https://git.hnz.io/hnzio/putzliga/releases" target="_blank" rel="noreferrer">Version {{ app_version }}</a>
|
||||||
|
<span>·</span>
|
||||||
|
<a href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,25 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<p class="eyebrow">Kalender-Abo</p>
|
||||||
|
<h2>Persönlicher ICS-Feed</h2>
|
||||||
|
<p class="muted">Dieser Read-only-Link zeigt in externen Kalendern nur Aufgaben an, die dir zugewiesen sind. Exportiert werden nur Titel, Beschreibung und Fälligkeitsdatum.</p>
|
||||||
|
<div class="push-box">
|
||||||
|
<div class="field">
|
||||||
|
<label for="calendarFeedUrl">Persönliche Kalender-URL</label>
|
||||||
|
<input id="calendarFeedUrl" type="text" value="{{ calendar_feed_url }}" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<a class="button button--secondary" href="{{ calendar_feed_url }}" target="_blank" rel="noreferrer">ICS öffnen</a>
|
||||||
|
<form method="post" action="{{ url_for('settings.regenerate_calendar_feed') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="button button--ghost">Link neu erzeugen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<p class="eyebrow">Admin</p>
|
<p class="eyebrow">Admin</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user