Merge dev into main (v0.26.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s
No files matched your search
@@ -38,6 +38,12 @@ Die Linter-Warnungen `react-hooks/exhaustive-deps` gibt es schon länger; neuer
|
|||||||
```
|
```
|
||||||
- **Zeitabhängiger Code** (Alarme, Tageszusammenfassung, Begrüßungen, Termine): nie `time.Now()` dort, wo eine übergebene Zeit gemeint ist. Die betroffenen Go-Tests zusätzlich mit verschiedenen `TZ`-Werten laufen lassen (z. B. `Europe/Berlin`, `America/Los_Angeles`, `Asia/Tokyo`). Ein uhrzeitabhängiger Test hat schon einmal einen Release verhindert, weil er nur vormittags grün war.
|
- **Zeitabhängiger Code** (Alarme, Tageszusammenfassung, Begrüßungen, Termine): nie `time.Now()` dort, wo eine übergebene Zeit gemeint ist. Die betroffenen Go-Tests zusätzlich mit verschiedenen `TZ`-Werten laufen lassen (z. B. `Europe/Berlin`, `America/Los_Angeles`, `Asia/Tokyo`). Ein uhrzeitabhängiger Test hat schon einmal einen Release verhindert, weil er nur vormittags grün war.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
- Bilder in `screenshots/` (README) sind **WebP**, keine PNG: Desktop und Theme-Raster 1280 px breit, Handy 480 px, Qualität 90. Das bringt rund ein Fünftel der PNG-Größe bei sauberer Schrift.
|
||||||
|
- Erzeugt werden sie mit `e2e/screenshots.sh`; das Skript nimmt PNGs auf und wandelt sie am Ende mit `cwebp` (Alpine-Container) um. Einzelne Bilder neu aufnehmen: denselben Ablauf aus `screenshots.mjs` verwenden und mit dem Umwandlungsschritt aus `screenshots.sh` konvertieren.
|
||||||
|
- Chromiums eigenes WebP (`canvas.toDataURL('image/webp')`) nicht verwenden: verlustfrei wird es größer als die PNG.
|
||||||
|
|
||||||
## Release-Ablauf
|
## Release-Ablauf
|
||||||
|
|
||||||
**Committen** ist jederzeit erlaubt. **Jeder Push ist ein Release**, also mit Version und CHANGELOG-Eintrag.
|
**Committen** ist jederzeit erlaubt. **Jeder Push ist ein Release**, also mit Version und CHANGELOG-Eintrag.
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.26.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Verbindungsstatus im Kopf: Ist der Server nicht erreichbar, steht am Desktop unter deinem Namen klein „● offline“ mit rotem Punkt; die Seite bleibt bedienbar. Am Handy zeigt ein kleiner Punkt unten rechts am Profilbild den Status (grün online, rot offline). Das funktioniert auch, wenn nur der Server ausgefallen ist und das Gerät selbst noch Netz hat – bisher war das nur in den Listen zu sehen.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Screenshots im README sind jetzt kleinere WebP-Bilder (zusammen etwa 0,5 statt 2,7 MB); das Journal-Bild zeigt die aktuelle Ansicht.
|
||||||
|
|
||||||
## [0.25.1] - 2026-10-31
|
## [0.25.1] - 2026-10-31
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -519,7 +527,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.1...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.26.0...dev
|
||||||
|
[0.26.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.1...v0.26.0
|
||||||
[0.25.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.0...v0.25.1
|
[0.25.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.0...v0.25.1
|
||||||
[0.25.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.2...v0.25.0
|
[0.25.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.2...v0.25.0
|
||||||
[0.24.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.1...v0.24.2
|
[0.24.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.1...v0.24.2
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go te
|
|||||||
- **Lese-Cache** ([lib/offline.ts](../frontend/src/lib/offline.ts)): Jede erfolgreiche GET-Antwort landet pro Benutzer in IndexedDB (`mindsift-offline`). Ohne Verbindung liefert `request()` die letzte bekannte Antwort; so startet die App offline angemeldet und zeigt Gedanken, Aufgaben und Listen. Ausgenommen: Streams, Long-Polling, Notiz-Sync (eigener Speicher, [modules/collaboration.md](modules/collaboration.md)), Export, Suche. Abmelden leert Cache und Warteschlange.
|
- **Lese-Cache** ([lib/offline.ts](../frontend/src/lib/offline.ts)): Jede erfolgreiche GET-Antwort landet pro Benutzer in IndexedDB (`mindsift-offline`). Ohne Verbindung liefert `request()` die letzte bekannte Antwort; so startet die App offline angemeldet und zeigt Gedanken, Aufgaben und Listen. Ausgenommen: Streams, Long-Polling, Notiz-Sync (eigener Speicher, [modules/collaboration.md](modules/collaboration.md)), Export, Suche. Abmelden leert Cache und Warteschlange.
|
||||||
- **Warteschlange (Outbox):** Offline erfasste Gedanken (`POST /inbox`) und abgehakte Aufgaben bzw. Listeneinträge (`PATCH`) werden in `localStorage` gesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beim `online`-Ereignis, beim Start und alle 30 s, solange etwas wartet; vom Server abgelehnte Einträge (4xx) entfallen. Andere Änderungen brauchen weiterhin eine Verbindung.
|
- **Warteschlange (Outbox):** Offline erfasste Gedanken (`POST /inbox`) und abgehakte Aufgaben bzw. Listeneinträge (`PATCH`) werden in `localStorage` gesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beim `online`-Ereignis, beim Start und alle 30 s, solange etwas wartet; vom Server abgelehnte Einträge (4xx) entfallen. Andere Änderungen brauchen weiterhin eine Verbindung.
|
||||||
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
|
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
|
||||||
|
- **Verbindungsstatus im Kopf** ([lib/useServerOnline.ts](../frontend/src/lib/useServerOnline.ts)): Maßgeblich ist die app-weite Echtzeit-Verbindung (WebSocket/SSE), nicht `navigator.onLine`; so fällt auch ein ausgefallener Server auf. Offline gilt erst nach 4 s ohne Verbindung (Start, kurze Reconnects), ohne Gerätenetz sofort. Desktop: „● offline“ unter dem Namen, nur ohne Verbindung; Handy: Punkt am Profilbild, grün oder rot.
|
||||||
|
|
||||||
## App-Icons, PWA & Social-Preview (Open Graph)
|
## App-Icons, PWA & Social-Preview (Open Graph)
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage am Desktop, links hell, rechts dunkel" width="85%" />
|
<img src="screenshots/Triage_desktop.webp" alt="MindSift Triage am Desktop, links hell, rechts dunkel" width="85%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<sub>Links das helle, rechts das dunkle Theme.</sub>
|
<sub>Links das helle, rechts das dunkle Theme.</sub>
|
||||||
@@ -43,9 +43,9 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
|||||||
* Papierkorb mit Wiederherstellen.
|
* Papierkorb mit Wiederherstellen.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/Mobile_Triage.png" alt="MindSift Triage am Smartphone, helles Theme" width="280px" />
|
<img src="screenshots/Mobile_Triage.webp" alt="MindSift Triage am Smartphone, helles Theme" width="280px" />
|
||||||
|
|
||||||
<img src="screenshots/Mobile_Workspace.png" alt="MindSift Notiz am Smartphone, dunkles Theme" width="280px" />
|
<img src="screenshots/Mobile_Workspace.webp" alt="MindSift Notiz am Smartphone, dunkles Theme" width="280px" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### ✅ Aufgaben & Kanban
|
### ✅ Aufgaben & Kanban
|
||||||
@@ -61,7 +61,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
|||||||
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort, alle übrigen Ereignisse (Freigaben, erledigte geteilte Aufgaben, beobachtete Aufgaben) gebündelt; pro Ereignis wählbar über E-Mail, Discord, eigenen Webhook und Push.
|
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort, alle übrigen Ereignisse (Freigaben, erledigte geteilte Aufgaben, beobachtete Aufgaben) gebündelt; pro Ereignis wählbar über E-Mail, Discord, eigenen Webhook und Push.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
<img src="screenshots/aufgaben_desktop.webp" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 📝 Workspace: Outliner-Notizen
|
### 📝 Workspace: Outliner-Notizen
|
||||||
@@ -88,11 +88,11 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
|||||||
Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md).
|
Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md).
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace: zwei Personen in derselben Notiz" width="90%" />
|
<img src="screenshots/workspace_desktop.webp" alt="MindSift Workspace: zwei Personen in derselben Notiz" width="90%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/journal_desktop.png" alt="MindSift Journal mit Kalender" width="90%" />
|
<img src="screenshots/journal_desktop.webp" alt="MindSift Journal mit Kalender" width="90%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🛒 Geteilte Listen
|
### 🛒 Geteilte Listen
|
||||||
@@ -101,7 +101,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
|
|||||||
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
|
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" />
|
<img src="screenshots/Listen_desktop.webp" alt="MindSift Geteilte Listen" width="90%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🏢 Benutzer, Gruppen, Administration
|
### 🏢 Benutzer, Gruppen, Administration
|
||||||
@@ -117,12 +117,12 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><img src="screenshots/Theme_fjord.png" alt="Farbfamilie Fjord, links hell, rechts dunkel" /><br /><sub><b>Fjord</b></sub></td>
|
<td align="center"><img src="screenshots/Theme_fjord.webp" alt="Farbfamilie Fjord, links hell, rechts dunkel" /><br /><sub><b>Fjord</b></sub></td>
|
||||||
<td align="center"><img src="screenshots/Theme_terracotta.png" alt="Farbfamilie Terrakotta, links hell, rechts dunkel" /><br /><sub><b>Terrakotta</b></sub></td>
|
<td align="center"><img src="screenshots/Theme_terracotta.webp" alt="Farbfamilie Terrakotta, links hell, rechts dunkel" /><br /><sub><b>Terrakotta</b></sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><img src="screenshots/Theme_polarized.png" alt="Farbfamilie Polarized, links hell, rechts dunkel" /><br /><sub><b>Polarized</b></sub></td>
|
<td align="center"><img src="screenshots/Theme_polarized.webp" alt="Farbfamilie Polarized, links hell, rechts dunkel" /><br /><sub><b>Polarized</b></sub></td>
|
||||||
<td align="center"><img src="screenshots/Theme_suri.png" alt="Farbfamilie Suri, links hell, rechts dunkel" /><br /><sub><b>Suri</b></sub></td>
|
<td align="center"><img src="screenshots/Theme_suri.webp" alt="Farbfamilie Suri, links hell, rechts dunkel" /><br /><sub><b>Suri</b></sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -40,4 +40,4 @@ Der Eigentümer tippt mit normaler Verbindung, der Bearbeiter sitzt hinter einem
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
`e2e/screenshots.sh` baut das Image, startet es im Demo-Modus mit frischer Datenbank und erzeugt die Bilder in `screenshots/` neu (`screenshots.mjs`). Für die Versionsangabe im Kopf: `APP_VERSION=v0.2 e2e/screenshots.sh`. In der Workspace-Aufnahme bearbeitet der Demo-Benutzer `user` gleichzeitig einen Bullet, damit Sperre und Präsenz zu sehen sind.
|
`e2e/screenshots.sh` baut das Image, startet es im Demo-Modus mit frischer Datenbank und erzeugt die Bilder in `screenshots/` neu (`screenshots.mjs`). Danach werden sie verkleinert (Desktop 1280 px, Handy 480 px breit) und als WebP mit Qualität 90 gespeichert (`cwebp` in einem Alpine-Container). Für die Versionsangabe im Kopf: `APP_VERSION=v0.2 e2e/screenshots.sh`. In der Workspace-Aufnahme bearbeitet der Demo-Benutzer `user` gleichzeitig einen Bullet, damit Sperre und Präsenz zu sehen sind.
|
||||||
@@ -196,7 +196,10 @@ const main = async () => {
|
|||||||
await page.getByRole('button', { name: 'Heute' }).click().catch(() => {});
|
await page.getByRole('button', { name: 'Heute' }).click().catch(() => {});
|
||||||
await page.mouse.click(1000, 860);
|
await page.mouse.click(1000, 860);
|
||||||
await sleep(2500);
|
await sleep(2500);
|
||||||
|
// Round corners (setting "Ecken: rund") for this picture only
|
||||||
|
await page.evaluate(() => document.documentElement.setAttribute('data-corners', 'round'));
|
||||||
await plain(page, 'journal_desktop.png', 'dark');
|
await plain(page, 'journal_desktop.png', 'dark');
|
||||||
|
await page.evaluate(() => document.documentElement.removeAttribute('data-corners'));
|
||||||
await page.getByRole('button', { name: 'Journal', exact: true }).click(); // close it again
|
await page.getByRole('button', { name: 'Journal', exact: true }).click(); // close it again
|
||||||
|
|
||||||
await tab(page, 'Listen');
|
await tab(page, 'Listen');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots
|
# Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots
|
||||||
# in screenshots/ (title picture and colour-family grid: light and dark joined along a diagonal).
|
# in screenshots/ (title picture and colour-family grid: light and dark joined along a diagonal).
|
||||||
|
# The PNGs from the browser are scaled down (desktop 1280 px, phone 480 px wide) and saved as WebP.
|
||||||
# Requires podman (or docker via ENGINE=docker). Usage: e2e/screenshots.sh, other theme pair: SHOT_LIGHT=fjord SHOT_DARK=fjord-night
|
# Requires podman (or docker via ENGINE=docker). Usage: e2e/screenshots.sh, other theme pair: SHOT_LIGHT=fjord SHOT_DARK=fjord-night
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ENGINE="${ENGINE:-podman}"
|
ENGINE="${ENGINE:-podman}"
|
||||||
@@ -21,6 +22,7 @@ cleanup() {
|
|||||||
rm -rf "$WORK"
|
rm -rf "$WORK"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
mkdir -p "$WORK/png"
|
||||||
|
|
||||||
echo "Baue Image …"
|
echo "Baue Image …"
|
||||||
# Version in the header, e.g. APP_VERSION=v0.2 e2e/screenshots.sh
|
# Version in the header, e.g. APP_VERSION=v0.2 e2e/screenshots.sh
|
||||||
@@ -32,5 +34,14 @@ for _ in $(seq 1 60); do curl -s -m 1 "http://localhost:$PORT/api/v1/health" >/d
|
|||||||
echo "Erstelle Screenshots …"
|
echo "Erstelle Screenshots …"
|
||||||
"$ENGINE" run --rm --network host -e MINDSIFT_URL="http://localhost:$PORT" -e SHOTS_DIR=/shots \
|
"$ENGINE" run --rm --network host -e MINDSIFT_URL="http://localhost:$PORT" -e SHOTS_DIR=/shots \
|
||||||
-e SHOT_LIGHT="${SHOT_LIGHT:-light}" -e SHOT_DARK="${SHOT_DARK:-dark}" -e LANG=de_DE.UTF-8 -e TZ=Europe/Berlin \
|
-e SHOT_LIGHT="${SHOT_LIGHT:-light}" -e SHOT_DARK="${SHOT_DARK:-dark}" -e LANG=de_DE.UTF-8 -e TZ=Europe/Berlin \
|
||||||
-v "$ROOT/e2e":/e2e:Z -v "$ROOT/screenshots":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
-v "$ROOT/e2e":/e2e:Z -v "$WORK/png":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
||||||
sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs'
|
sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs'
|
||||||
|
|
||||||
|
echo "Wandle in WebP um …"
|
||||||
|
"$ENGINE" run --rm -v "$WORK/png":/png:Z -v "$ROOT/screenshots":/shots:Z docker.io/library/alpine:3.20 sh -c '
|
||||||
|
apk add -q libwebp-tools >/dev/null
|
||||||
|
for f in /png/*.png; do
|
||||||
|
name=$(basename "$f" .png)
|
||||||
|
case "$name" in Mobile_*) width=480 ;; *) width=1280 ;; esac
|
||||||
|
cwebp -quiet -resize "$width" 0 -q 90 -m 6 -metadata none "$f" -o "/shots/$name.webp"
|
||||||
|
done'
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.25.1",
|
"version": "0.26.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.25.1",
|
"version": "0.26.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.25.1",
|
"version": "0.26.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { WorkspaceView } from './features/workspace/WorkspaceView';
|
|||||||
import { SharedListsView } from './features/lists/SharedListsView';
|
import { SharedListsView } from './features/lists/SharedListsView';
|
||||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||||
import { OfflineBanner } from './components/OfflineBanner';
|
import { OfflineBanner } from './components/OfflineBanner';
|
||||||
|
import { useServerOnline } from './lib/useServerOnline';
|
||||||
import { SharedImportToast } from './components/SharedImportToast';
|
import { SharedImportToast } from './components/SharedImportToast';
|
||||||
import { outboxCount, clearOfflineData } from './lib/offline';
|
import { outboxCount, clearOfflineData } from './lib/offline';
|
||||||
import { DemoCountdown } from './components/DemoCountdown';
|
import { DemoCountdown } from './components/DemoCountdown';
|
||||||
@@ -179,6 +180,7 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
// Profile Dropdown State
|
// Profile Dropdown State
|
||||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||||
|
const serverOnline = useServerOnline();
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -947,7 +949,17 @@ export const App: React.FC = () => {
|
|||||||
}`}
|
}`}
|
||||||
title="Profil & Einstellungen"
|
title="Profil & Einstellungen"
|
||||||
>
|
>
|
||||||
<UserAvatar user={user} size="md" className="shadow-sm" />
|
<span className="relative">
|
||||||
|
<UserAvatar user={user} size="md" className="shadow-sm" />
|
||||||
|
{/* Connection status like in a chat app: green online, red without server */}
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-label={serverOnline ? 'Online' : 'Offline – keine Verbindung zum Server'}
|
||||||
|
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full ring-2 ring-canvas ${
|
||||||
|
serverOnline ? 'bg-status-ok' : 'bg-status-err'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1013,8 +1025,20 @@ export const App: React.FC = () => {
|
|||||||
title="Benutzermenü öffnen"
|
title="Benutzermenü öffnen"
|
||||||
>
|
>
|
||||||
<UserAvatar user={user} size="sm" />
|
<UserAvatar user={user} size="sm" />
|
||||||
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
<span className="flex flex-col items-start leading-tight">
|
||||||
{user.displayName}
|
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
||||||
|
{user.displayName}
|
||||||
|
</span>
|
||||||
|
{!serverOnline && (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
title="Keine Verbindung zum Server – Änderungen werden später synchronisiert"
|
||||||
|
className="hidden sm:inline-flex items-center gap-1 text-[10px] font-normal text-muted"
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-status-err" />
|
||||||
|
offline
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
|
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
|
let statusListener: ((connected: boolean) => void) | null = null;
|
||||||
|
let currentConnected = false;
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
onRealtimeStatus: (cb: (connected: boolean) => void) => {
|
||||||
|
statusListener = cb;
|
||||||
|
cb(currentConnected);
|
||||||
|
return () => (statusListener = null);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useServerOnline, SERVER_OFFLINE_GRACE_MS } from '../useServerOnline';
|
||||||
|
|
||||||
|
function setDeviceOnline(value: boolean) {
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useServerOnline', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
currentConnected = false;
|
||||||
|
setDeviceOnline(true);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('stays online during the grace period at app start', () => {
|
||||||
|
const { result } = renderHook(() => useServerOnline());
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
act(() => statusListener?.(true));
|
||||||
|
act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS + 100));
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns offline once the server stays unreachable and back online on reconnect', () => {
|
||||||
|
currentConnected = true;
|
||||||
|
const { result } = renderHook(() => useServerOnline());
|
||||||
|
act(() => statusListener?.(false));
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS + 100));
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
act(() => statusListener?.(true));
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repeated failed reconnects do not postpone the offline state', () => {
|
||||||
|
currentConnected = true;
|
||||||
|
const { result } = renderHook(() => useServerOnline());
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
act(() => statusListener?.(false));
|
||||||
|
act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS / 2));
|
||||||
|
}
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is offline right away without device network', () => {
|
||||||
|
currentConnected = true;
|
||||||
|
const { result } = renderHook(() => useServerOnline());
|
||||||
|
act(() => {
|
||||||
|
setDeviceOnline(false);
|
||||||
|
statusListener?.(false);
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { onRealtimeStatus } from './api';
|
||||||
|
|
||||||
|
// Grace period before a missing realtime connection counts as offline (app start, short reconnects)
|
||||||
|
export const SERVER_OFFLINE_GRACE_MS = 4000;
|
||||||
|
|
||||||
|
// Whether the server is reachable, judged by the app-wide realtime connection (WebSocket/SSE). Unlike
|
||||||
|
// navigator.onLine it also notices a server that is down while the device still has network. Without
|
||||||
|
// device network it is offline right away; otherwise only after the connection stayed away for a moment.
|
||||||
|
export function useServerOnline(): boolean {
|
||||||
|
const [online, setOnline] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let connected = false;
|
||||||
|
let timer: number | undefined;
|
||||||
|
// Reconnect attempts report "not connected" every few seconds; they must not restart the grace period
|
||||||
|
const update = () => {
|
||||||
|
if (connected) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
setOnline(true);
|
||||||
|
} else if (navigator.onLine === false) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
setOnline(false);
|
||||||
|
} else if (timer === undefined) {
|
||||||
|
timer = window.setTimeout(() => setOnline(false), SERVER_OFFLINE_GRACE_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const unsubscribe = onRealtimeStatus((isConnected) => {
|
||||||
|
connected = isConnected;
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
window.addEventListener('online', update);
|
||||||
|
window.addEventListener('offline', update);
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
window.removeEventListener('online', update);
|
||||||
|
window.removeEventListener('offline', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return online;
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 58 KiB |