Compare commits

..

No commits in common. 'main' and 'update-fix-2.6.1' have entirely different histories.

2
.gitignore vendored

@ -2,6 +2,4 @@ venv/
*/__pycache__*
*/migrations*
*/.DS_Store
/data/
db.sqlite3

@ -152,10 +152,6 @@ bisherigen Versions-URLs auf die aktuellste weiter.
alte, gecachte Version zu behalten. Ohne diesen Schritt können CSS/JS und HTML
auseinanderlaufen (führte schon zu eingefrorenen Screens bei Bestandsnutzern) –
**nicht vergessen!**
7. Auf dem Server deployen: neuen Code holen (`git pull`), **falls sich Models geändert
haben** `python manage.py makemigrations app && python manage.py migrate` ausführen
(Migrationen sind nicht im Repo, siehe `.gitignore`), Static Files einsammeln
(`python manage.py collectstatic`), Gunicorn neu starten
## Roadmap / offene Punkte
@ -166,9 +162,8 @@ bisherigen Versions-URLs auf die aktuellste weiter.
## Mitwirken
Feedback, Bug-Reports und Ideen gerne über die
[WhatsApp-Community](https://chat.whatsapp.com/Cy4laGaEdLP1DZbhN04zRQ), per E-Mail an
**app-feedback@zielke.studio** (auch direkt in der App unter Einstellungen → Kontakt
verlinkt) oder direkt an Samuel.
[WhatsApp-Community](https://chat.whatsapp.com/Cy4laGaEdLP1DZbhN04zRQ) oder direkt an
Samuel.
## Lizenz

@ -1,12 +1,3 @@
from django.contrib import admin
from .models import UsageEvent
@admin.register(UsageEvent)
class UsageEventAdmin(admin.ModelAdmin):
list_display = ("device_id", "created_at", "app_version", "platform", "browser", "is_standalone")
list_filter = ("platform", "browser", "is_standalone", "app_version")
search_fields = ("device_id",)
date_hierarchy = "created_at"
ordering = ("-created_at",)
# Register your models here.

@ -1,27 +1,3 @@
from django.db import models
class UsageEvent(models.Model):
"""Anonymes Nutzungssignal für das Admin-Dashboard (siehe home01.js: pingUsageIfNeeded()).
Ein Eintrag pro Gerät und Tag - device_id ist eine zufällige, nur lokal im Browser
(localStorage) gespeicherte ID ohne jeden Bezug zu einer echten Person. Plattform und
Browser werden serverseitig aus dem User-Agent-Header grob abgeleitet (siehe app/utils.py).
"""
device_id = models.CharField(max_length=64, db_index=True)
app_version = models.CharField(max_length=20, blank=True)
user_agent = models.TextField(blank=True)
platform = models.CharField(max_length=40, blank=True)
browser = models.CharField(max_length=40, blank=True)
is_standalone = models.BooleanField(
default=False,
help_text="True, wenn als installierte PWA (Homescreen) geöffnet statt im Browser-Tab.",
)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.device_id[:8]}… – {self.created_at:%d.%m.%Y %H:%M}"
# Create your models here.

@ -310,16 +310,3 @@ a:hover {
font-weight: bold;
border-radius: 25px 0 0 25px; /* Runde Ecken auf der linken Seite */
}
/* Datumsfeld (Neuer Eintrag / Bearbeiten): Safari/iOS ignoriert bei input[type=date]
die gesetzte width:100% und rendert das Feld in seiner nativen Breite, wodurch es
über den Rand der Modal-Karte hinausläuft. -webkit-appearance: none nimmt dem
Feld die native Breitenberechnung und lässt es sich wie ein normales .form-control
verhalten. */
input[type="date"].form-control {
width: 100%;
max-width: 100%;
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

@ -361,14 +361,6 @@ function fillTableWithEntries(entries, sortField = 'date', sortDirection = 'asc'
} else if (entry.type === 1) {
typeCell.textContent = 'Dienst'; // Ansonsten zeige den Type-Wert
}
// Partner (optional) klein darunter anzeigen, falls beim Eintrag angegeben
if (entry.partner) {
const partnerLine = document.createElement('small');
partnerLine.classList.add('text-muted');
partnerLine.style.display = 'block';
partnerLine.textContent = `mit ${entry.partner}`;
typeCell.appendChild(partnerLine);
}
row.appendChild(typeCell);
const editCell = document.createElement('td');
@ -379,7 +371,6 @@ function fillTableWithEntries(entries, sortField = 'date', sortDirection = 'asc'
document.getElementById('editStunden').value = durationDBtoForm(entry.duration)[0];
document.getElementById('editMinuten').value = durationDBtoForm(entry.duration)[1];
document.getElementById('editDatum').value = entry.date;
document.getElementById('editPartnerInput').value = entry.partner || '';
if (entry.type === 2) {
document.getElementById('editldcCheck').checked = true;
} else {

@ -52,61 +52,8 @@ var currentMonthEntries = [];
// Trigger
//
//
// ANONYME NUTZUNGSSTATISTIK (Admin-Dashboard)
//
// Liest die zufällige Geräte-ID aus dem localStorage oder legt einmalig eine neue an.
// Kein Bezug zu einer Person - rein lokal generiert, dient nur der Dashboard-Zählung.
function getOrCreateDeviceId() {
try {
let id = localStorage.getItem('piomintDeviceId')
if (!id) {
id = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`
localStorage.setItem('piomintDeviceId', id)
}
return id
} catch (error) {
return null
}
}
// Meldet höchstens einmal pro Gerät und Tag ein anonymes "App genutzt"-Signal ans Backend.
// Darf die App unter keinen Umständen blockieren oder zum Absturz bringen - daher überall try/catch.
function pingUsageIfNeeded() {
try {
const today = new Date().toISOString().slice(0, 10)
if (localStorage.getItem('piomintLastPing') === today) {
return
}
const deviceId = getOrCreateDeviceId()
if (!deviceId) {
return
}
const standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || window.navigator.standalone === true
fetch('/app/track/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: deviceId,
app_version: document.body.dataset.appVersion || '',
standalone: standalone,
}),
}).then(() => {
localStorage.setItem('piomintLastPing', today)
}).catch(() => {
// Kein Netz/Fehler beim Tracking - einfach beim nächsten Öffnen erneut versuchen
})
} catch (error) {
console.error('Nutzungssignal konnte nicht gesendet werden:', error)
}
}
// Wenn das Dokument komplett geladen ist
document.addEventListener('DOMContentLoaded', (event) => {
pingUsageIfNeeded()
initDB().then(async () => {
console.log('IndexedDB erfolgreich initialisiert.');
// Hier kannst du die App weiter initialisieren, z.B. Daten laden oder die UI aufbauen
@ -327,7 +274,7 @@ NewEntryButton.addEventListener('click', () => {
}
// Berechnen der Duration (Stunden sind optional -> ohne Eingabe wird 0 gespeichert)
const duration = ((parseInt(hours.value) || 0)*60) + (parseInt(mins.value) || 0)
const duration = ((parseInt(hours.value) || 0)*60) + parseInt(mins.value)
const newEntryData = {
date: document.getElementById('datum').value,
@ -335,7 +282,6 @@ NewEntryButton.addEventListener('click', () => {
type: type,
status: 1,
sondertext: document.getElementById('sonstigesInput').value,
partner: document.getElementById('partnerInput').value.trim(),
}
addEntry(newEntryData, 'hours').then((id) => {
@ -346,7 +292,6 @@ NewEntryButton.addEventListener('click', () => {
hours.value = null
mins.value = null
document.getElementById('sonstigesInput').value = null
document.getElementById('partnerInput').value = null
checkInput()
}
})
@ -700,10 +645,6 @@ editsonstigesCheck.addEventListener('change', function() {
document.getElementById('editEntrySubmitButton').addEventListener('click', () => {
if (!editallowToSend) {
return
}
var ToEditID = document.getElementById('editEntryID').value
// console.log(`ID: ${ToEditID}, Hours: ${editHours.values}, Minutes: ${editMins.values}`)
@ -722,11 +663,10 @@ document.getElementById('editEntrySubmitButton').addEventListener('click', () =>
updateEntry(parseInt(ToEditID), 'hours', {
date: document.getElementById('editDatum').value,
duration: ((parseInt(edithours.value) || 0)*60) + (parseInt(editMins.value) || 0),
duration: ((parseInt(edithours.value) || 0)*60) + parseInt(editMins.value),
type: type,
status: 1,
sondertext: document.getElementById('editsonstigesInput').value,
partner: document.getElementById('editPartnerInput').value.trim(),
}).then(() => {
// console.log('Eintrag aktualisiert.');
location.reload()
@ -844,19 +784,16 @@ function checkInput() {
allowToSend = true;
NewEntryButton.classList.remove('disabled');
NewEntryButton.classList.replace('btn-light', 'btn-primary');
NewEntryButton.disabled = false;
}
} else if (allowToSend) {
allowToSend = false;
NewEntryButton.classList.add('disabled');
NewEntryButton.classList.replace('btn-primary', 'btn-light');
NewEntryButton.disabled = true;
}
} else if (allowToSend) {
allowToSend = false;
NewEntryButton.classList.add('disabled');
NewEntryButton.classList.replace('btn-primary', 'btn-light');
NewEntryButton.disabled = true;
}
}
@ -887,20 +824,17 @@ function checkEditInput() {
editallowToSend = true;
EditSaveButton.classList.remove('disabled');
EditSaveButton.classList.replace('btn-light', 'btn-primary');
EditSaveButton.disabled = false;
}
} else if (editallowToSend) {
editallowToSend = false;
EditSaveButton.classList.add('disabled');
EditSaveButton.classList.replace('btn-primary', 'btn-light');
EditSaveButton.disabled = true;
}
} else if (editallowToSend) {
editallowToSend = false;
EditSaveButton.classList.add('disabled');
EditSaveButton.classList.replace('btn-primary', 'btn-light');
EditSaveButton.disabled = true;
}
// Debug ausgabe er Werte
console.log(`Status: ${editallowToSend} - Check: ${editsonstigesCheck.checked} - Input: ${editsonstigesInput.value} - Stunden: ${edithours.value} - Min: ${editMins.value}`)
console.log(`Status: ${editallowToSend} - Check: ${editsonstigesCheck.checked} - Input: ${editsonstigesInput.value} - Stunden: ${edithours.value} - Min: ${editMinuten.value}`)
}

@ -15,7 +15,7 @@
<title>PioMint</title>
</head>
<body id="body" style="overflow: hidden;" class="bg-white-color" data-app-version="{{ STATIC_VERSION }}">
<body id="body" style="overflow: hidden;" class="bg-white-color">
<!-- HEADER -->
<div class="container-fluid header-bg-block bg-white-color HeaderDefaultHeightbg" id="header-bg-block">
<div class="container-fluid header-color-block text-center bg-white-color HeaderDefaultHeightcolor" id="header-color-block" style="color: white; width: 100vw;">
@ -67,10 +67,6 @@
WhatsApp Community
<a class="btn btn-success" href="https://chat.whatsapp.com/Cy4laGaEdLP1DZbhN04zRQ" target="_blank" rel="noopener noreferrer">Beitreten</a>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Feedback / Fehler melden
<a class="btn btn-primary" href="mailto:app-feedback@zielke.studio?subject=PioMint%20Feedback">E-Mail</a>
</li>
<h5 class="mt-5 text-white text-center">Daten Verwaltung</h5>
<li class="list-group-item d-flex justify-content-between align-items-center">
<button class="btn btn-info" onclick="exportIndexedDBData()">Exportieren</button>

@ -1,249 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'app/css/bootstrap.css' %}?v={{ STATIC_VERSION }}">
<title>PioMint – Nutzung</title>
<script>
// Theme so früh wie möglich setzen - sonst blitzt beim Laden kurz
// das helle Theme auf, bevor auf Dunkel umgeschaltet wird.
(function () {
try {
var saved = localStorage.getItem("piomintDashboardTheme");
var theme = saved || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
} catch (e) {}
})();
</script>
<style>
:root {
--bg: #f4f6f5; --card-bg: #ffffff; --ink: #212529; --ink-muted: #6c757d;
--border: #dee2e6; --stripe: rgba(0, 0, 0, .05); --accent: rgb(120, 170, 86);
--shadow: 0 0 0.5rem gray;
}
:root[data-theme="dark"] {
--bg: #15171a; --card-bg: #1f2124; --ink: #e8e8e6; --ink-muted: #9a9d9f;
--border: rgba(255, 255, 255, .12); --stripe: rgba(255, 255, 255, .04); --accent: rgb(140, 195, 100);
--shadow: 0 0 0.6rem rgba(0, 0, 0, .5);
}
body {
background-color: var(--bg);
color: var(--ink);
padding: 2rem 1rem 3rem 1rem;
}
h1 b {
color: var(--accent);
}
.theme-toggle {
width: 2.25rem; height: 2.25rem; border-radius: 999px; border: 1px solid var(--border);
background: var(--card-bg); display: inline-flex; align-items: center; justify-content: center;
font-size: 1rem; cursor: pointer; margin-left: .5rem;
}
.stat-card {
background: var(--card-bg);
border-radius: 1rem;
box-shadow: var(--shadow);
padding: 1.25rem;
}
.stat-card.text-center .stat-value {
font-size: 2rem;
font-weight: bold;
color: var(--accent);
}
.stat-card .stat-label {
color: var(--ink-muted);
font-size: 0.9rem;
}
.stat-card table {
background: var(--card-bg);
color: var(--ink);
}
[data-theme="dark"] .text-muted {
color: var(--ink-muted) !important;
}
[data-theme="dark"] .table-striped tbody tr:nth-of-type(odd) {
background-color: var(--stripe);
}
[data-theme="dark"] .table td,
[data-theme="dark"] .table th,
[data-theme="dark"] .border-bottom {
border-color: var(--border) !important;
}
[data-theme="dark"] code {
color: #f092c0;
}
</style>
</head>
<body>
<div class="container" style="max-width: 960px;">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="m-0">PIO<b>MINT</b> – Nutzung</h1>
<div>
<a href="/app/home/" class="btn btn-sm btn-light">Zur App</a>
<button type="button" class="theme-toggle" aria-label="Hell/Dunkel umschalten" title="Hell/Dunkel umschalten">🌙</button>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6 col-md-6">
<div class="stat-card text-center">
<div class="stat-value">{{ total_connections }}</div>
<div class="stat-label">Verbindungen insgesamt <small class="text-muted">(alle Pings)</small></div>
</div>
</div>
<div class="col-6 col-md-6">
<div class="stat-card text-center">
<div class="stat-value">{{ users_total }}</div>
<div class="stat-label">Nutzer insgesamt <small class="text-muted">(als App installiert)</small></div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6 col-md-4">
<div class="stat-card text-center">
<div class="stat-value">{{ users_today }}</div>
<div class="stat-label">Nutzer heute</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="stat-card text-center">
<div class="stat-value">{{ users_7d }}</div>
<div class="stat-label">Nutzer (7 Tage)</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="stat-card text-center">
<div class="stat-value">{{ users_30d }}</div>
<div class="stat-label">Nutzer (30 Tage)</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="stat-card">
<h5>Plattform <small class="text-muted">(eindeutige Nutzer)</small></h5>
<ul class="list-unstyled mb-0">
{% for row in by_platform %}
<li class="d-flex justify-content-between border-bottom py-1">
<span>{{ row.platform|default:"Unbekannt" }}</span><b>{{ row.count }}</b>
</li>
{% empty %}
<li class="text-muted">Noch keine Daten</li>
{% endfor %}
</ul>
</div>
</div>
<div class="col-md-6">
<div class="stat-card">
<h5>Browser <small class="text-muted">(eindeutige Nutzer)</small></h5>
<ul class="list-unstyled mb-0">
{% for row in by_browser %}
<li class="d-flex justify-content-between border-bottom py-1">
<span>{{ row.browser|default:"Unbekannt" }}</span><b>{{ row.count }}</b>
</li>
{% empty %}
<li class="text-muted">Noch keine Daten</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<div class="stat-card mb-3">
<h5 class="mb-1">Nutzer vs. Aufrufer</h5>
<p class="mb-0 text-muted">
<b>{{ users_total }} Nutzer</b> haben PioMint mindestens einmal als installierte PWA
(Standalone) geöffnet – das ist echte Nutzung. Weitere
<b>{{ visitors_total }} Aufrufer</b> haben die Seite nur im Browser-Tab geöffnet,
aber nie installiert; sie fließen oben nicht in die Nutzer-Zahlen ein.
</p>
</div>
<div class="stat-card mb-3">
<h5 class="mb-2">Geräte-Übersicht <small class="text-muted">(ein Eintrag pro Gerät, nicht pro Ping)</small></h5>
<div style="overflow-x: auto;">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Gerät</th>
<th>Pings</th>
<th>Erster Kontakt</th>
<th>Letzter Kontakt</th>
<th>Nutzer?</th>
</tr>
</thead>
<tbody>
{% for device in device_summary %}
<tr>
<td><code>{{ device.device_id|slice:":8" }}…</code></td>
<td>{{ device.ping_count }}</td>
<td>{{ device.first_seen|date:"d.m.Y H:i" }}</td>
<td>{{ device.last_seen|date:"d.m.Y H:i" }}</td>
<td>{{ device.standalone_any|yesno:"Ja,Nein" }}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="text-muted">Noch keine Daten</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="stat-card">
<h5 class="mb-2">Letzte Signale <small class="text-muted">(roher Ping-Log, ein Gerät kann hier mehrfach auftauchen)</small></h5>
<div style="overflow-x: auto;">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Zeit</th>
<th>Gerät</th>
<th>Version</th>
<th>Plattform</th>
<th>Browser</th>
<th>Standalone</th>
</tr>
</thead>
<tbody>
{% for event in recent_events %}
<tr>
<td>{{ event.created_at|date:"d.m.Y H:i" }}</td>
<td><code>{{ event.device_id|slice:":8" }}…</code></td>
<td>{{ event.app_version|default:"–" }}</td>
<td>{{ event.platform }}</td>
<td>{{ event.browser }}</td>
<td>{{ event.is_standalone|yesno:"Ja,Nein" }}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-muted">Noch keine Daten</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="text-muted mt-4" style="font-size: 0.8rem;">
Komplett anonym: <code>device_id</code> ist eine zufällige, nur lokal im Browser
gespeicherte ID ohne Bezug zu einer Person. Ein Signal pro Gerät und Tag.
</p>
</div>
<script>
(function () {
var button = document.querySelector(".theme-toggle");
function paint() {
button.textContent = document.documentElement.getAttribute("data-theme") === "dark" ? "☀️" : "🌙";
}
paint();
button.addEventListener("click", function () {
var next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
try { localStorage.setItem("piomintDashboardTheme", next); } catch (e) {}
paint();
});
})();
</script>
</body>
</html>

@ -191,12 +191,6 @@
<input type="date" class="form-control" id="datum" required>
</div>
<!-- Partner (optional) -->
<div class="form-group">
<p><b>Partner</b> <span class="text-muted" style="font-weight: normal; font-size: 0.85rem;">(optional)</span></p>
<input type="text" class="form-control" id="partnerInput" placeholder="Name" maxlength="30">
</div>
<!-- Auswahl LDC oder Sonstiges (Checkboxen) -->
<div class="form-group">
<p><b>Optionen:</b></p>
@ -264,12 +258,6 @@
<input type="date" class="form-control editItem" id="editDatum" required>
</div>
<!-- Partner (optional) -->
<div class="form-group">
<p><b>Partner</b> <span class="text-muted" style="font-weight: normal; font-size: 0.85rem;">(optional)</span></p>
<input type="text" class="form-control editItem" id="editPartnerInput" placeholder="Name" maxlength="30">
</div>
<!-- Auswahl LDC oder Sonstiges (Checkboxen) -->
<div class="form-group">
<p><b>Optionen:</b></p>

@ -1,424 +1,15 @@
{% load pwa %}
{% load static %}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'app/css/bootstrap.css' %}?v={{ STATIC_VERSION }}">
{% comment %}
Manifest + Apple-Meta-Tags: ohne die legt "Zum Home-Bildschirm"/"App installieren"
nur ein Lesezeichen auf /install/ an statt die PWA mit start_url zu installieren
{% endcomment %}
{% progressive_web_app_meta %}
<title>PioMint installieren</title>
<style>
:root {
--mint: rgb(120, 170, 86);
--mint-dark: rgb(98, 140, 68);
--teal: #026670;
--ink: #1c2723;
--paper: #fbfbf9;
}
html, body {
overflow-x: hidden;
}
body {
background-color: var(--paper);
color: var(--ink);
padding-bottom: 4rem;
}
.hero {
background: linear-gradient(155deg, var(--teal) 0%, #024a52 100%);
color: white;
padding: 3rem 1.25rem 4.5rem 1.25rem;
text-align: center;
}
.hero img {
width: 84px;
height: 84px;
border-radius: 22px;
box-shadow: 0 0.75rem 1.75rem rgba(0, 0, 0, 0.35);
margin-bottom: 1.25rem;
}
.hero h1 {
font-weight: 700;
font-size: clamp(1.4rem, 7vw, 2.5rem);
margin-bottom: 0.5rem;
}
.hero h1 b {
color: var(--mint);
}
.hero p {
color: rgba(255, 255, 255, 0.8);
max-width: 32rem;
margin: 0 auto;
}
.wrap {
max-width: 640px;
margin: -3rem auto 0 auto;
padding: 0 1.25rem;
}
/* Segmented control */
.platform-switch {
display: flex;
background: white;
border-radius: 1rem;
box-shadow: 0 0.5rem 1.5rem rgba(2, 30, 33, 0.15);
padding: 0.35rem;
gap: 0.35rem;
}
.platform-switch button {
flex: 1;
min-width: 0;
border: none;
background: transparent;
padding: 0.7rem 0.5rem;
border-radius: 0.7rem;
font-weight: 600;
font-size: 0.95rem;
color: #6c757d;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: background-color 0.2s ease, color 0.2s ease;
}
.platform-switch button.active {
background: var(--teal);
color: white;
}
.platform-switch svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* Warnhinweis */
.browser-note {
margin-top: 1.25rem;
background: #fff7e6;
border: 1px solid #f3dca6;
color: #7a5b12;
border-radius: 0.85rem;
padding: 0.9rem 1.1rem;
font-size: 0.9rem;
display: flex;
gap: 0.65rem;
align-items: flex-start;
}
.browser-note svg {
width: 20px;
height: 20px;
flex-shrink: 0;
margin-top: 0.1rem;
}
/* Schritte */
.steps {
margin-top: 2rem;
position: relative;
}
.step {
display: flex;
gap: 1.1rem;
padding-bottom: 2rem;
position: relative;
}
.step:not(:last-child)::before {
content: "";
position: absolute;
left: 27px;
top: 56px;
bottom: 0;
width: 2px;
background: repeating-linear-gradient(to bottom, #d8ded4 0 6px, transparent 6px 12px);
}
.step-badge {
flex-shrink: 0;
width: 56px;
height: 56px;
border-radius: 16px;
background: white;
box-shadow: 0 0.4rem 1rem rgba(2, 30, 33, 0.12);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.step-badge svg {
width: 26px;
height: 26px;
color: var(--teal);
}
.step-num {
position: absolute;
top: -6px;
right: -6px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--mint);
color: white;
font-size: 0.7rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
/* Tippen-Indikator */
.tap-ring {
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--mint);
animation: tapPulse 1.8s ease-out infinite;
}
@keyframes tapPulse {
0% { transform: scale(0.6); opacity: 0.9; }
80% { transform: scale(1.8); opacity: 0; }
100% { transform: scale(1.8); opacity: 0; }
}
.step-body {
padding-top: 0.35rem;
min-width: 0;
}
.browser-note > div {
min-width: 0;
}
.step-body h3 {
font-size: 1.05rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.step-body p {
color: #4b5a54;
margin: 0;
font-size: 0.95rem;
}
.step-body kbd {
background: #eef2ef;
color: var(--teal);
border-radius: 0.35rem;
padding: 0.05rem 0.4rem;
font-weight: 600;
font-size: 0.85em;
}
.done-card {
margin-top: 0.5rem;
background: linear-gradient(155deg, var(--mint) 0%, var(--mint-dark) 100%);
color: white;
border-radius: 1rem;
padding: 1.5rem;
text-align: center;
box-shadow: 0 0.5rem 1.5rem rgba(98, 140, 68, 0.3);
}
.done-card h3 {
font-weight: 700;
margin-bottom: 0.35rem;
}
.done-card p {
margin: 0;
opacity: 0.92;
}
footer {
text-align: center;
margin-top: 3rem;
color: #8a938c;
font-size: 0.85rem;
}
footer a {
color: var(--teal);
font-weight: 600;
text-decoration: none;
}
[data-platform-section] {
display: none;
}
[data-platform-section].active {
display: block;
}
@media (prefers-reduced-motion: reduce) {
.tap-ring { animation: none; opacity: 0.6; }
}
</style>
</head>
<body>
<div class="hero">
<img src="{% static 'app/images/appleicon_whitebg.png' %}" alt="PioMint Icon">
<h1>PIO<b>MINT</b> installieren</h1>
<p>Einmal einrichten, dann liegt PioMint als eigenes App-Icon auf deinem
Homescreen – ganz ohne App Store, in unter einer Minute.</p>
</div>
<div class="wrap">
<div class="platform-switch" role="tablist">
<button type="button" id="tab-ios" role="tab" onclick="showPlatform('ios')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.53 4.09l-.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/></svg>
iPhone
</button>
<button type="button" id="tab-android" role="tab" onclick="showPlatform('android')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.6 9.48l1.84-3.18a.5.5 0 00-.87-.5l-1.86 3.22a10.9 10.9 0 00-9.42 0L5.43 5.8a.5.5 0 00-.87.5L6.4 9.48A9.99 9.99 0 001 17h22a9.99 9.99 0 00-5.4-7.52zM7 14.5a1 1 0 111-1 1 1 0 01-1 1zm10 0a1 1 0 111-1 1 1 0 01-1 1z"/></svg>
Android
</button>
</div>
<!-- iOS -->
<div data-platform-section="ios">
<div class="browser-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01" stroke-linecap="round"/></svg>
<div>Funktioniert nur in <b>Safari</b>. Falls dieser Link in Instagram, WhatsApp
oder einer anderen App geöffnet wurde: oben rechts <b>„In Safari öffnen"</b>
wählen, dann weiter unten.</div>
</div>
<div class="steps">
<div class="step">
<div class="step-badge">
<span class="step-num">1</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3M8 7l4-4 4 4"/><path d="M4 13v6a2 2 0 002 2h12a2 2 0 002-2v-6"/></svg>
<span class="tap-ring" style="bottom: -2px; right: -2px;"></span>
</div>
<div class="step-body">
<h3>Teilen-Symbol antippen</h3>
<p>In der Leiste am unteren (bei manchen Geräten oberen) Bildschirmrand
von Safari – das Quadrat mit dem Pfeil nach oben.
Siehst du dort nur <kbd>•••</kbd>? Dann zuerst darauf tippen – bei
neueren Safari-Versionen ist das Teilen-Symbol dahinter versteckt.</p>
</div>
</div>
<div class="step">
<div class="step-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="4"/><path d="M12 9v6M9 12h6"/></svg>
</div>
<div class="step-body">
<h3>„Zum Home-Bildschirm" wählen</h3>
<p>In der Liste etwas herunterscrollen, falls es nicht sofort zu sehen
ist.</p>
</div>
</div>
<div class="step">
<div class="step-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<div class="step-body">
<h3>Oben rechts auf „Hinzufügen" tippen</h3>
<p>Der Name „PioMint" ist schon vorausgefüllt – einfach so lassen.</p>
</div>
</div>
</div>
</div>
<!-- Android -->
<div data-platform-section="android">
<div class="browser-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01" stroke-linecap="round"/></svg>
<div>Am zuverlässigsten in <b>Chrome</b>. Manche Geräte (z. B. Samsung Internet)
nennen den Menüpunkt etwas anders, das Prinzip ist aber gleich.</div>
</div>
<div class="steps">
<div class="step">
<div class="step-badge">
<span class="step-num">1</span>
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
<span class="tap-ring" style="bottom: -2px; right: -2px;"></span>
</div>
<div class="step-body">
<h3>Menü oben rechts öffnen</h3>
<p>Die drei Punkte <kbd>⋮</kbd> neben der Adressleiste antippen.</p>
</div>
</div>
<div class="step">
<div class="step-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M12 8v5M9.5 10.5h5" stroke-linecap="round"/></svg>
</div>
<div class="step-body">
<h3>„App installieren" antippen</h3>
<p>Manchmal schlägt Chrome das schon automatisch über ein kleines
Banner vor – dann reicht ein Tipp darauf.</p>
</div>
</div>
<div class="step">
<div class="step-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<div class="step-body">
<h3>Mit „Installieren" bestätigen</h3>
<p>Das Icon landet direkt auf deinem Startbildschirm.</p>
</div>
</div>
</div>
</div>
<div class="done-card">
<h3>Fertig! 🎉</h3>
<p>PioMint öffnet sich jetzt wie eine eigene App – ohne Adressleiste, mit
eigenem Icon. Einfach drauftippen und loslegen.</p>
</div>
<footer>
Fragen oder klappt etwas nicht? Schreib uns an
<a href="mailto:app-feedback@zielke.studio">app-feedback@zielke.studio</a>
oder in der <a href="https://chat.whatsapp.com/Cy4laGaEdLP1DZbhN04zRQ" target="_blank" rel="noopener noreferrer">WhatsApp-Community</a>.
</footer>
</div>
<script>
function showPlatform(platform) {
document.querySelectorAll('[data-platform-section]').forEach(function (el) {
el.classList.toggle('active', el.dataset.platformSection === platform);
});
document.getElementById('tab-ios').classList.toggle('active', platform === 'ios');
document.getElementById('tab-android').classList.toggle('active', platform === 'android');
try { localStorage.setItem('piomintInstallPlatform', platform); } catch (error) {}
}
(function () {
var saved = null;
try { saved = localStorage.getItem('piomintInstallPlatform'); } catch (error) {}
var ua = navigator.userAgent || '';
var detected = /Android/i.test(ua) ? 'android' : 'ios';
showPlatform(saved || detected);
})();
</script>
</body>
</html>
{% extends 'app/base.html' %}
{% block content %}
<h1>Installieren</h1>
<br>
<p>
Folgende Schritte sind nötig um PioMint unter IOS zu installieren:
<br>
1. Auf das <b>Teilen</b> Symbol klicken.
<br>
2. Auf <b>Zum Home-Bildschirm hinzufügen</b> klicken.
<br>
3. Auf <b>Hinzufügen</b> klicken.
</p>
{% endblock %}

@ -1,22 +1,22 @@
<br>
<div class="text-center">
<h1 style="margin-bottom: 0;">Kurzer Hotfix <span style="color: rgb(120,170,86);">26.3</span></h1>
<h1 style="margin-bottom: 0;">Kurzer Hotfix <span style="color: rgb(120,170,86);">26.1</span></h1>
</div>
<br>
<p>
Hey! 👋
<br>Zwei gemeldete Fehler sind jetzt behoben:
<br>Kurz hinterher: Bei manchen bereits installierten Geräten konnte diese
Update-Ansicht hängen bleiben – kein Scrollen möglich, "Alles klar"-Button nicht
erreichbar.
<br>Das ist jetzt behoben.
</p>
<ul style="padding-left: 1.25rem;">
<li>🧮 <b>Falsche Berechnung</b> – wenn man beim Erstellen/Bearbeiten eines Eintrags
die "0" bei den Minuten gelöscht hat, konnten die Monats- und Jahresstunden
durcheinandergeraten. Behoben.</li>
<li>📅 <b>Datumsfeld zu breit</b> – das Datumsfeld beim neuen Eintrag lief auf
manchen Geräten über den Rand der Karte hinaus. Behoben.</li>
</ul>
<p>
Danke fürs Melden! 🙏
War dein Bildschirm gerade eingefroren? Einmal die App schließen, kurz warten und
neu öffnen – jetzt sollte wieder alles normal laufen.
</p>
<p>
Danke für die Geduld und fürs Melden! 🙏
<br><b>Gruß Samuel</b>
</p>
<br>
<input type="text" name="versionnumber" id="versionnumber" class="d-none" value="26.3">
<input type="text" name="versionnumber" id="versionnumber" class="d-none" value="26.1">

@ -20,8 +20,4 @@ urlpatterns = [
path("v1340/", views.v1340),
path("v260/", views.v260),
path("v261/", views.v261),
path("v262/", views.v262),
path("v263/", views.v263),
path("track/", views.track_usage),
path("dashboard/", views.usage_dashboard),
]

@ -1,32 +0,0 @@
def parse_user_agent(user_agent):
"""Sehr einfache, dependency-freie Grobzuordnung von Plattform/Browser aus dem
User-Agent-String - reicht für die Dashboard-Statistik, muss nicht exakt sein."""
ua = (user_agent or "").lower()
if "iphone" in ua or "ipad" in ua or "ipod" in ua:
platform = "iOS"
elif "android" in ua:
platform = "Android"
elif "windows" in ua:
platform = "Windows"
elif "macintosh" in ua or "mac os x" in ua:
platform = "macOS"
elif "linux" in ua:
platform = "Linux"
else:
platform = "Unbekannt"
if "edg/" in ua or "edga" in ua or "edgios" in ua:
browser = "Edge"
elif "opr/" in ua or "opera" in ua:
browser = "Opera"
elif "firefox" in ua or "fxios" in ua:
browser = "Firefox"
elif "crios" in ua or "chrome" in ua:
browser = "Chrome"
elif "safari" in ua:
browser = "Safari"
else:
browser = "Unbekannt"
return platform, browser

@ -1,17 +1,4 @@
import json
from django.contrib.admin.views.decorators import staff_member_required
from django.db.models import Count, Max, Min
from django.http import JsonResponse
from django.shortcuts import render, HttpResponse, redirect
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from datetime import timedelta
from .models import UsageEvent
from .utils import parse_user_agent
# Create your views here.
def index(request):
@ -21,115 +8,37 @@ def install(request):
return render(request, "app/install.html")
def home(request):
return redirect("/app/v263")
return redirect("/app/v261")
def login(request):
return render(request, "app/login.html")
def v1321(request):
return redirect("/app/v263")
return redirect("/app/v261")
def v1322(request):
return redirect("/app/v263")
return redirect("/app/v261")
def v133(request):
return redirect("/app/v263")
return redirect("/app/v261")
def v1332(request):
return redirect("/app/v263")
return redirect("/app/v261")
def v1333(request):
return redirect("/app/v263")
return redirect("/app/v261")
def v1334(request):
return redirect("/app/v263")
return redirect("/app/v261")
def v1335(request):
return redirect("/app/v263")
return redirect("/app/v261")
def v1340(request):
return redirect("/app/v263")
return redirect("/app/v261")
def v260(request):
return redirect("/app/v263")
return redirect("/app/v261")
def v261(request):
return redirect("/app/v263")
def v262(request):
return redirect("/app/v263")
def v263(request):
return render(request, "app/home.html")
# ---------------------------------------------------------------------------
# Anonyme Nutzungsstatistik fürs Admin-Dashboard
# ---------------------------------------------------------------------------
@csrf_exempt
@require_POST
def track_usage(request):
"""Nimmt ein anonymes Nutzungssignal entgegen (ein Aufruf pro Gerät und Tag,
siehe home01.js: pingUsageIfNeeded()). Kein CSRF-Schutz nötig, da rein additiv
und ohne jeden Personenbezug - device_id kommt aus dem localStorage des Geräts."""
try:
data = json.loads(request.body or "{}")
except json.JSONDecodeError:
data = {}
device_id = str(data.get("device_id", "")).strip()[:64]
if not device_id:
return JsonResponse({"ok": False, "error": "device_id fehlt"}, status=400)
user_agent = request.META.get("HTTP_USER_AGENT", "")
platform, browser = parse_user_agent(user_agent)
UsageEvent.objects.create(
device_id=device_id,
app_version=str(data.get("app_version", ""))[:20],
user_agent=user_agent[:1000],
platform=platform,
browser=browser,
is_standalone=bool(data.get("standalone", False)),
)
return JsonResponse({"ok": True})
@staff_member_required
def usage_dashboard(request):
"""Nutzungs-Dashboard für Admins - nur für angemeldete Staff-Nutzer (Django-Admin-Login)."""
now = timezone.now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
events = UsageEvent.objects.all()
# "Nutzer" = Gerät hat PioMint mindestens einmal als installierte PWA
# (Standalone) geöffnet - nur das ist echte Nutzung. Ein Gerät, das die
# Seite nur im Browser-Tab geöffnet hat, ist ein "Aufrufer", kein Nutzer.
user_device_ids = events.filter(is_standalone=True).values_list("device_id", flat=True).distinct()
users = events.filter(device_id__in=user_device_ids)
visitors_total = events.exclude(device_id__in=user_device_ids).values("device_id").distinct().count()
context = {
"total_connections": events.count(),
"visitors_total": visitors_total,
"users_total": user_device_ids.count(),
"users_today": users.filter(created_at__gte=today_start).values("device_id").distinct().count(),
"users_7d": users.filter(created_at__gte=now - timedelta(days=7)).values("device_id").distinct().count(),
"users_30d": users.filter(created_at__gte=now - timedelta(days=30)).values("device_id").distinct().count(),
"by_platform": users.values("platform").annotate(count=Count("device_id", distinct=True)).order_by("-count"),
"by_browser": users.values("browser").annotate(count=Count("device_id", distinct=True)).order_by("-count"),
"device_summary": (
events.values("device_id")
.annotate(
ping_count=Count("id"),
first_seen=Min("created_at"),
last_seen=Max("created_at"),
standalone_any=Max("is_standalone"),
)
.order_by("-last_seen")
),
"recent_events": events[:30],
}
return render(request, "app/dashboard.html", context)

@ -9,7 +9,7 @@ PWA_APP_BACKGROUND_COLOR = '#9FEDD7'
PWA_APP_DISPLAY = 'standalone'
PWA_APP_SCOPE = '/'
PWA_APP_ORIENTATION = 'portrait'
PWA_APP_START_URL = '/app/v263/'
PWA_APP_START_URL = '/app/v261/'
PWA_APP_STATUS_BAR_COLOR = 'black-translucent'
PWA_SERVICE_WORKER_PATH = os.path.join(BASE_DIR, 'app/static/app/js', 'serviceworker.js')
PWA_APP_ICONS = [

@ -29,12 +29,6 @@ DEBUG = True
ALLOWED_HOSTS = ['*'] if DEBUG else ['localhost', 'app.piomint.de']
# Ohne das schlägt jedes POST-Formular (z.B. der Admin-Login) mit "Origin checking
# failed" fehl, sobald die Seite über HTTPS läuft: der Server sitzt hinter einem
# Reverse-Proxy, Django kennt seine eigene Produktions-Domain sonst nicht als
# vertrauenswürdigen Origin.
CSRF_TRUSTED_ORIGINS = ['https://app.piomint.de']
# Application definition
@ -91,18 +85,10 @@ WSGI_APPLICATION = 'piomint.wsgi.application'
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
#STANDART DATENBANK
# SQLite liegt bewusst in einem eigenen data/-Verzeichnis außerhalb des Quellcodes:
# Auf dem Server läuft Gunicorn als www-data, das Deployment (git pull/migrate) aber
# als root - nur so kann www-data die DB beschreiben, ohne dass der ganze Projektordner
# (inkl. .git) für den Webserver-Prozess beschreibbar sein muss. Verzeichnis wird bei
# Bedarf automatisch angelegt; data/ ist in .gitignore, landet also nie im Repo.
DATA_DIR = BASE_DIR / 'data'
DATA_DIR.mkdir(parents=True, exist_ok=True)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': DATA_DIR / 'db.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
@ -161,7 +147,7 @@ STATIC_ROOT = '/var/www/piomint/static/' # Directory to collect static files.
# während schon neues HTML ausgeliefert wird - das führte u.a. zu eingefrorenen Screens
# (kein Scroll, kein Button erreichbar) bei Bestandsnutzern. Bei jedem Release erhöhen,
# zusammen mit der Versions-URL in app/urls.py / app/views.py.
STATIC_VERSION = '26.3'
STATIC_VERSION = '26.1'
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field

@ -1,461 +1,24 @@
{% load static %}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="PioMint: die einfache, anonyme App für Pioniere, um Dienststunden zu erfassen. Kein Account, keine Cloud – alles bleibt auf deinem Gerät.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,500;1,9..144,500;1,9..144,600&display=swap" rel="stylesheet">
<title>PioMint – von einem Pio, für Pios</title>
<style>
:root {
--teal: #026670;
--teal-deep: #013e45;
--mint: rgb(120, 170, 86);
--mint-soft: #eef3e7;
--paper: #fbf9f4;
--ink: #1c2723;
--ink-soft: #5b6b63;
}
* { box-sizing: border-box; }
html, body {
overflow-x: hidden;
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.55;
}
a { color: inherit; }
.display {
font-family: "Fraunces", Georgia, serif;
}
img { max-width: 100%; display: block; }
.wrap {
max-width: 1080px;
margin: 0 auto;
padding: 0 1.5rem;
}
/* --- Top mark --- */
.topbar {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 1.5rem 1.5rem 0 1.5rem;
max-width: 1080px;
margin: 0 auto;
}
.topbar img {
width: 34px;
height: 34px;
border-radius: 9px;
}
.topbar span {
font-weight: 700;
letter-spacing: 0.02em;
}
.topbar span b { color: var(--mint); }
/* --- Hero --- */
.hero {
display: grid;
grid-template-columns: 1fr;
gap: 2.5rem;
align-items: center;
padding: 2.5rem 1.5rem 3rem 1.5rem;
max-width: 1080px;
margin: 0 auto;
}
@media (min-width: 900px) {
.hero {
grid-template-columns: 1.05fr 0.95fr;
padding-top: 4rem;
gap: 3rem;
}
}
.hero-quote {
font-size: clamp(2rem, 6vw, 3.1rem);
font-weight: 500;
font-style: italic;
line-height: 1.12;
margin: 0 0 1.25rem 0;
position: relative;
}
.hero-quote .squiggle {
display: block;
width: 132px;
height: 14px;
margin-top: 0.4rem;
}
.hero-sub {
font-size: 1.08rem;
color: var(--ink-soft);
max-width: 34rem;
margin: 0 0 1.75rem 0;
}
.cta {
display: inline-flex;
align-items: center;
gap: 0.6rem;
background: var(--teal);
color: white;
font-weight: 700;
font-size: 1.05rem;
padding: 0.9rem 1.6rem;
border-radius: 999px;
text-decoration: none;
box-shadow: 0 0.6rem 1.4rem rgba(2, 102, 112, 0.28);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.cta:hover {
transform: translateY(-2px);
box-shadow: 0 0.9rem 1.8rem rgba(2, 102, 112, 0.34);
color: white;
}
.cta svg { width: 20px; height: 20px; }
.cta-note {
margin: 0.85rem 0 0 0;
font-size: 0.85rem;
color: var(--ink-soft);
}
/* --- Phone frame --- */
.phone {
justify-self: center;
width: min(280px, 78vw);
border-radius: 2.4rem;
padding: 0.55rem;
background: linear-gradient(160deg, #ffffff, #eef1ee);
box-shadow: 0 1.5rem 3rem rgba(2, 30, 33, 0.18), 0 0 0 1px rgba(2, 30, 33, 0.05);
}
.phone img {
border-radius: 1.9rem;
width: 100%;
}
.phone.float {
animation: floaty 5s ease-in-out infinite;
}
@keyframes floaty {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@media (prefers-reduced-motion: reduce) {
.phone.float { animation: none; }
}
/* --- Feature sections --- */
.features {
background: white;
padding: 3.5rem 0 4rem 0;
border-top: 1px solid rgba(2, 30, 33, 0.06);
border-bottom: 1px solid rgba(2, 30, 33, 0.06);
}
.features-head {
text-align: center;
margin-bottom: 2.75rem;
}
.features-head .eyebrow {
color: var(--mint);
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.features-head h2 {
font-family: "Fraunces", Georgia, serif;
font-style: italic;
font-weight: 500;
font-size: clamp(1.7rem, 4vw, 2.3rem);
margin: 0.4rem 0 0 0;
}
.feature-row {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
align-items: center;
padding: 2.5rem 1.5rem;
max-width: 1080px;
margin: 0 auto;
}
.feature-row:not(:last-child) {
border-bottom: 1px dashed rgba(2, 30, 33, 0.12);
}
@media (min-width: 900px) {
.feature-row {
grid-template-columns: 1fr 1fr;
gap: 3.5rem;
}
.feature-row.reverse .feature-text { order: 2; }
.feature-row.reverse .feature-shot { order: 1; }
}
.feature-row-center {
grid-template-columns: 1fr !important;
text-align: center;
padding-top: 2.5rem;
padding-bottom: 2.5rem;
}
.feature-row-center .feature-text {
max-width: 34rem;
margin: 0 auto;
}
.feature-row-center .feature-callout {
text-align: left;
}
.feature-icon {
width: 46px;
height: 46px;
border-radius: 14px;
background: var(--mint-soft);
color: var(--teal);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
}
.feature-icon svg { width: 22px; height: 22px; }
.feature-text h3 {
font-size: 1.4rem;
font-weight: 700;
margin: 0 0 0.6rem 0;
}
.feature-text p {
color: var(--ink-soft);
font-size: 1.02rem;
margin: 0;
}
.feature-callout {
margin-top: 1.1rem;
background: var(--mint-soft);
border-radius: 0.9rem;
padding: 0.9rem 1.1rem;
font-size: 0.92rem;
color: var(--teal-deep);
display: flex;
gap: 0.6rem;
}
.feature-callout svg {
width: 18px;
height: 18px;
flex-shrink: 0;
margin-top: 0.15rem;
color: var(--mint);
}
.feature-shot {
justify-self: center;
}
.feature-shot .phone {
width: min(230px, 62vw);
}
/* --- Closing CTA --- */
.closing {
background: linear-gradient(160deg, var(--teal) 0%, var(--teal-deep) 100%);
color: white;
text-align: center;
padding: 4rem 1.5rem;
}
.closing h2 {
font-family: "Fraunces", Georgia, serif;
font-style: italic;
font-weight: 500;
font-size: clamp(1.8rem, 5vw, 2.6rem);
margin: 0 0 0.75rem 0;
}
.closing p {
color: rgba(255, 255, 255, 0.82);
max-width: 30rem;
margin: 0 auto 1.75rem auto;
}
.closing .cta {
background: var(--mint);
box-shadow: 0 0.6rem 1.4rem rgba(120, 170, 86, 0.35);
}
/* --- Footer --- */
footer {
padding: 2.5rem 1.5rem 3rem 1.5rem;
text-align: center;
font-size: 0.85rem;
color: var(--ink-soft);
}
footer a {
color: var(--teal);
font-weight: 600;
text-decoration: none;
}
footer .links {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.2rem;
justify-content: center;
margin-bottom: 1rem;
}
footer .fineprint {
max-width: 32rem;
margin: 0 auto;
opacity: 0.85;
}
</style>
</head>
<body>
<div class="topbar">
<img src="{% static 'app/images/appleicon_whitebg.png' %}" alt="">
<span>PIO<b>MINT</b></span>
</div>
<section class="hero">
<div>
<p class="hero-quote display">
Von einem Pio.<br>Für Pios gemacht.
<svg class="squiggle" viewBox="0 0 132 14" fill="none">
<path d="M2 10C20 2 34 2 50 8C66 14 80 2 96 6C108 9 118 6 130 2" stroke="var(--mint)" stroke-width="3" stroke-linecap="round"/>
</svg>
</p>
<p class="hero-sub">
Ich hab PioMint gebaut, weil ich selbst jeden Monat meine Stunden
eintragen musste – und keine der bestehenden Apps wollte. Zu
kompliziert, zu englisch, zu viel drumherum. Also hab ich meine
eigene gemacht. Jetzt darfst du sie auch benutzen.
</p>
<a class="cta" href="/app/install/">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3M8 7l4-4 4 4"/><path d="M4 13v6a2 2 0 002 2h12a2 2 0 002-2v-6"/></svg>
Jetzt installieren
</a>
<p class="cta-note">Kostenlos. Ohne Account. In unter einer Minute.</p>
</div>
<div class="phone float">
<img src="{% static 'app/images/landing/screen-uebersicht.png' %}" alt="PioMint Dashboard: Jahr, Monat, Timer und Aktivitäten auf einen Blick">
</div>
</section>
<section class="features">
<div class="features-head">
<div class="eyebrow">Warum PioMint?</div>
<h2 class="display">Nichts, was du nicht brauchst.</h2>
</div>
<section data-cid="slide-05-7059bc59" class="slide fade-6 kenBurns">
<div class="content">
<div class="container">
<div class="wrap">
<div class="feature-row">
<div class="feature-text">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
</div>
<h3>Einfach, nicht kompliziert</h3>
<p>Keine Diagramme, keine Statistik-Flut, kein Menü-Dschungel.
Nur deine Stunden – klar und auf einen Blick.</p>
</div>
<div class="feature-shot">
<div class="phone"><img src="{% static 'app/images/landing/screen-liste.png' %}" alt="Übersichtliche Liste aller Aktivitäten in PioMint"></div>
</div>
</div>
<div class="feature-row reverse">
<div class="feature-text">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l3 2M9 2h6M12 2v3"/></svg>
</div>
<h3>Timer eingebaut</h3>
<p>Starten, pausieren, fertig. Die Zeit läuft von selbst mit –
du musst am Ende nichts mehr zusammenrechnen.</p>
</div>
<div class="feature-shot">
<div class="phone"><img src="{% static 'app/images/landing/screen-timer.png' %}" alt="Laufender Timer in PioMint"></div>
<ul class="flex">
<li class="col-12-12">
<div class="fix-4-12">
<h1>PioMint</h1>
<a href="#" class="button hollow ae-2 cropLeft"><img src="assets/img/appstore.jpg" height="63"/></a><a href="#" class="button hollow ae-3 cropRight"><img src="assets/img/googleplay.jpg" height="63"/></a>
</div>
</li>
<li class="col-12-12">
<div class="fix-6-12 ae-2 margin-top-3">
<img class="iphone-5" width="380" src="assets/img/iphone-5.png" alt="iPhone App" data-action="zoom"/>
</div>
</li>
</ul>
<div class="feature-row feature-row-center">
<div class="feature-text">
<div class="feature-icon" style="margin-left: auto; margin-right: auto;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6l8-4z"/></svg>
</div>
<h3>100&nbsp;% anonym</h3>
<p>Kein Account, kein Login, keine Cloud. Deine Daten bleiben
ausschließlich auf deinem Gerät – sonst nirgends.</p>
<div class="feature-callout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
<span>Und genau weil alles nur lokal liegt, kannst du jederzeit
ein Backup machen und es auf einem neuen Handy wieder
einspielen – deine Daten gehen beim Wechsel nicht verloren.</span>
</div>
</div>
</div>
</div>
<div class="background" style="background-image:url(assets/img/background/img-05.jpg)"></div>
</section>
<section class="closing">
<h2 class="display">Bereit?</h2>
<p>Einmal einrichten, dann liegt PioMint als eigenes Icon auf deinem
Homescreen – ganz ohne App Store.</p>
<a class="cta" href="/app/install/">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3M8 7l4-4 4 4"/><path d="M4 13v6a2 2 0 002 2h12a2 2 0 002-2v-6"/></svg>
PioMint installieren
</a>
</section>
<footer>
<div class="links">
<a href="mailto:app-feedback@zielke.studio">Feedback</a>
<a href="https://chat.whatsapp.com/Cy4laGaEdLP1DZbhN04zRQ" target="_blank" rel="noopener noreferrer">WhatsApp-Community</a>
<a href="https://www.zielke.studio/" target="_blank" rel="noopener noreferrer">Impressum</a>
</div>
<p class="fineprint">
PioMint ist ein privates Herzensprojekt, kein kommerzielles Produkt.
Das Impressum stellt zielke.studio (Button unten auf der Seite).
</p>
</footer>
</body>
</html>

Loading…
Cancel
Save

Powered by TurnKey Linux.