Compare commits
38 Commits
f0c3cacb06
...
v0.1.1.25
| Author | SHA1 | Date | |
|---|---|---|---|
| fafefff29b | |||
| 4fd7f3c6cf | |||
| 262c48a01a | |||
| 9ad3c2cf38 | |||
| 6848390ffa | |||
| 65d2215a35 | |||
| f321e5bbd1 | |||
| d2961b050a | |||
| 6943fd2dc4 | |||
| f332eccf22 | |||
| 9d2a19dbf8 | |||
| e3cd89be5d | |||
| a86da72b04 | |||
| 7d6f381f55 | |||
| 878be33b7c | |||
| 318f5e65da | |||
| 8c6ab59d67 | |||
| a9c3e9ce3e | |||
| 3eaf59e2b3 | |||
| b1e17be7fd | |||
| ac7e7c92d1 | |||
| e10cef4b05 | |||
| 0ec5c51102 | |||
| 57b93b7ce7 | |||
| a4b3515711 | |||
| 41acbaebac | |||
| 6c83cd7d36 | |||
| 9089e1c6f9 | |||
| 1504960d85 | |||
| 599f090895 | |||
| 4eb2b4c517 | |||
| be3b23ed8c | |||
| 697c5781b7 | |||
| 4c36c9160a | |||
| d559a762d2 | |||
| a2180a302c | |||
| cd29115233 | |||
| e4b07ca896 |
@@ -34,6 +34,8 @@ ORIGIN=http://localhost:5173
|
|||||||
# POSTGRES_USER=postgres
|
# POSTGRES_USER=postgres
|
||||||
# POSTGRES_PASSWORD=
|
# POSTGRES_PASSWORD=
|
||||||
# POSTGRES_DB=daagbox
|
# POSTGRES_DB=daagbox
|
||||||
|
# Optional: lock Docker Compose to a specific configuration file (e.g. staging or production) on the server:
|
||||||
|
# COMPOSE_FILE=docker-compose.staging.yml
|
||||||
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
|
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
|
||||||
# CORS_ORIGINS=http://localhost:5173
|
# CORS_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
|||||||
@@ -251,13 +251,15 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führ
|
|||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/update-prod.sh -dest prod
|
./scripts/update-remotes.sh -dest prod
|
||||||
```
|
```
|
||||||
|
|
||||||
Standard-Ziel Prod: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
Standard-Ziel Prod: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
||||||
|
|
||||||
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
||||||
|
|
||||||
|
Prod-Deploy legt vor dem Update automatisch ein Server-Backup an (DB, `.env`, Compose, App-Code). Tägliches Cron-Backup und Restore: [docs/deployment/backup.md](docs/deployment/backup.md).
|
||||||
|
|
||||||
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
|
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
|
||||||
|
|
||||||
### Staging
|
### Staging
|
||||||
@@ -265,7 +267,7 @@ Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deploymen
|
|||||||
Testumgebung unter [staging.kapteins-daagbok.eu](https://staging.kapteins-daagbok.eu) — Deploy ohne Release-Tag:
|
Testumgebung unter [staging.kapteins-daagbok.eu](https://staging.kapteins-daagbok.eu) — Deploy ohne Release-Tag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/update-prod.sh -dest stage
|
./scripts/update-remotes.sh -dest stage
|
||||||
```
|
```
|
||||||
|
|
||||||
Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `REMOTE_HOST`, `REMOTE_DIR`, `DEPLOY_BRANCH` überschreibbar. Details: [docs/deployment/staging.md](docs/deployment/staging.md).
|
Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `REMOTE_HOST`, `REMOTE_DIR`, `DEPLOY_BRANCH` überschreibbar. Details: [docs/deployment/staging.md](docs/deployment/staging.md).
|
||||||
@@ -277,6 +279,7 @@ Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `R
|
|||||||
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
|
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
|
||||||
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
|
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
|
||||||
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
|
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
|
||||||
|
| [docs/deployment/backup.md](docs/deployment/backup.md) | Server-Backup, Crontab, Restore (Prod) |
|
||||||
| [docs/deployment/staging.md](docs/deployment/staging.md) | Staging-VM, Deploy, `.env` |
|
| [docs/deployment/staging.md](docs/deployment/staging.md) | Staging-VM, Deploy, `.env` |
|
||||||
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
|
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
|
||||||
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
||||||
|
|||||||
+304
-12
@@ -2090,6 +2090,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logbook-title-editable:hover {
|
.logbook-title-editable:hover {
|
||||||
@@ -2105,6 +2106,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-icon {
|
.card-icon {
|
||||||
@@ -3184,6 +3186,7 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
background: #0b0c10;
|
background: #0b0c10;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-container img {
|
.photo-container img {
|
||||||
@@ -3230,6 +3233,78 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Photo Maximized Overlay */
|
||||||
|
.photo-maximized-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(11, 12, 16, 0.9);
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 11000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-container {
|
||||||
|
position: relative;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-img {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -48px;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #f1f5f9;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: #ffffff;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-caption {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #f1f5f9;
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
max-width: 80%;
|
||||||
|
text-align: center;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom Dialog Modals Styling */
|
/* Custom Dialog Modals Styling */
|
||||||
.custom-dialog-overlay {
|
.custom-dialog-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -3237,9 +3312,9 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(11, 12, 16, 0.75);
|
background: rgba(11, 12, 16, 0.45);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: var(--app-backdrop);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: var(--app-backdrop);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -3247,13 +3322,15 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-dialog-card {
|
.custom-dialog-card {
|
||||||
background: rgba(15, 23, 42, 0.85);
|
background: var(--app-surface-hover, var(--app-surface));
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
backdrop-filter: var(--app-backdrop);
|
||||||
border-radius: 16px;
|
-webkit-backdrop-filter: var(--app-backdrop);
|
||||||
|
border: 1px solid var(--app-border-subtle);
|
||||||
|
border-radius: var(--app-radius-card, 16px);
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
|
box-shadow: var(--app-shadow);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -3263,7 +3340,7 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
.custom-dialog-title {
|
.custom-dialog-title {
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fbbf24;
|
color: var(--app-accent-light);
|
||||||
margin: 0 0 14px 0;
|
margin: 0 0 14px 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -3271,7 +3348,7 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
|
|
||||||
.custom-dialog-message {
|
.custom-dialog-message {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: #e2e8f0;
|
color: var(--app-text);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0 0 24px 0;
|
margin: 0 0 24px 0;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
@@ -4362,6 +4439,7 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
.consumption-grid .input-group .input-text {
|
.consumption-grid .input-group .input-text {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
.consumption-grid .input-text::-webkit-outer-spin-button,
|
.consumption-grid .input-text::-webkit-outer-spin-button,
|
||||||
@@ -6034,13 +6112,15 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
.app-tour-root {
|
.app-tour-root {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 10000;
|
/* Above .app-tour-target-active (10001) so tooltip/backdrop stay topmost */
|
||||||
|
z-index: 10010;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-tour-backdrop {
|
.app-tour-backdrop {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
background: rgba(2, 6, 23, 0.62);
|
background: rgba(2, 6, 23, 0.62);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
@@ -6058,7 +6138,7 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
0 0 32px rgba(56, 189, 248, 0.5),
|
0 0 32px rgba(56, 189, 248, 0.5),
|
||||||
0 12px 40px rgba(0, 0, 0, 0.35);
|
0 12px 40px rgba(0, 0, 0, 0.35);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10001;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.app-tour-active .app-tour-target-active {
|
body.app-tour-active .app-tour-target-active {
|
||||||
@@ -6069,7 +6149,8 @@ body.app-tour-active .app-tour-target-active {
|
|||||||
|
|
||||||
.app-tour-tooltip {
|
.app-tour-tooltip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 10002;
|
/* Layer above backdrop/spotlight inside .app-tour-root (not vs. root's 10010) */
|
||||||
|
z-index: 3;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: min(420px, calc(100vw - 32px));
|
width: min(420px, calc(100vw - 32px));
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
@@ -6231,3 +6312,214 @@ body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
|
|||||||
.crew-selection-item input {
|
.crew-selection-item input {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive Event Cards */
|
||||||
|
.events-desktop-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-mobile-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.events-desktop-only {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-mobile-only {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-mobile-card {
|
||||||
|
background: var(--app-surface-alt);
|
||||||
|
border: 1px solid var(--app-border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-mobile-card:hover {
|
||||||
|
border-color: var(--app-border);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-time {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--app-border-subtle);
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-chip {
|
||||||
|
background: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
|
||||||
|
border: 1px solid var(--app-border-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-chip svg {
|
||||||
|
color: var(--app-text-muted, #94a3b8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-weather-img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-remarks {
|
||||||
|
background: var(--app-surface-inset, rgba(11, 12, 16, 0.2));
|
||||||
|
border-left: 3px solid var(--app-accent, #fbbf24);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accordion Styling */
|
||||||
|
.accordion-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: -8px -12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header:hover {
|
||||||
|
background-color: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-chevron {
|
||||||
|
color: var(--app-text-muted, #94a3b8);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific styling for nested member-editor-card header */
|
||||||
|
.member-editor-card .accordion-header {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.01);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-editor-card .accordion-header:hover {
|
||||||
|
background: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column Selector / Customizer Popover */
|
||||||
|
.column-selector-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: 40px;
|
||||||
|
right: 0;
|
||||||
|
width: 240px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--app-surface-alt, rgba(18, 20, 26, 0.98));
|
||||||
|
border: 1px solid var(--app-border, rgba(255, 255, 255, 0.1));
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-title {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-accent, #fbbf24);
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--app-border-subtle, rgba(255, 255, 255, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text-muted, #cbd5e1);
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-item:hover {
|
||||||
|
background: var(--app-surface-hover, rgba(255, 255, 255, 0.04));
|
||||||
|
color: var(--app-text, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-item input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--app-accent, #fbbf24);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,12 +7,22 @@ import {
|
|||||||
type AdminTimeSeriesResponse,
|
type AdminTimeSeriesResponse,
|
||||||
type AdminTimeBucket
|
type AdminTimeBucket
|
||||||
} from '../services/adminApi.js'
|
} from '../services/adminApi.js'
|
||||||
import { BarChart2, Bookmark, ChevronLeft, Image, MapPin, Mic, Users } from 'lucide-react'
|
import { BarChart2, Bookmark, ChevronLeft, Database, Image, MapPin, Mic, Users } from 'lucide-react'
|
||||||
|
|
||||||
function formatNumber(value: number): string {
|
function formatNumber(value: number): string {
|
||||||
return value.toLocaleString()
|
return value.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | undefined): string {
|
||||||
|
if (bytes === undefined || bytes === null) return '—'
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
const num = bytes / Math.pow(k, i)
|
||||||
|
return `${num.toFixed(1)} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
function KpiCard({
|
function KpiCard({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
@@ -20,14 +30,14 @@ function KpiCard({
|
|||||||
}: {
|
}: {
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
label: string
|
label: string
|
||||||
value: number
|
value: number | string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="stats-kpi-card glass">
|
<div className="stats-kpi-card glass">
|
||||||
<div className="stats-kpi-icon">{icon}</div>
|
<div className="stats-kpi-icon">{icon}</div>
|
||||||
<div className="stats-kpi-body">
|
<div className="stats-kpi-body">
|
||||||
<span className="stats-kpi-label">{label}</span>
|
<span className="stats-kpi-label">{label}</span>
|
||||||
<span className="stats-kpi-value">{formatNumber(value)}</span>
|
<span className="stats-kpi-value">{typeof value === 'number' ? formatNumber(value) : value}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -194,6 +204,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
|
|||||||
label="Einträge mit AI-Zusammenfassung"
|
label="Einträge mit AI-Zusammenfassung"
|
||||||
value={summary.aiSummaryEntries}
|
value={summary.aiSummaryEntries}
|
||||||
/>
|
/>
|
||||||
|
<KpiCard icon={<Database size={20} />} label="Datenbankgröße" value={formatBytes(summary.dbSize)} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-controls">
|
<section className="admin-controls">
|
||||||
@@ -233,6 +244,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
|
|||||||
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
|
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
|
||||||
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
|
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
|
||||||
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
|
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
|
||||||
|
<TimeSeriesChart title="Datenbankgröße (MB)" seriesKey="database_size" data={timeSeries} />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,12 +45,39 @@ export default function CreatorAvatar({
|
|||||||
let photo: string | null = null
|
let photo: string | null = null
|
||||||
let role = ''
|
let role = ''
|
||||||
|
|
||||||
if (creatorId && crewSnapshotsById && crewSnapshotsById[creatorId]) {
|
if (creatorId && crewSnapshotsById) {
|
||||||
const snap = crewSnapshotsById[creatorId]
|
let snap: PersonSnapshot | undefined = crewSnapshotsById[creatorId]
|
||||||
|
|
||||||
|
// Fallback: If not found directly by key, search by role or name or active user
|
||||||
|
if (!snap) {
|
||||||
|
if (creatorId === 'skipper') {
|
||||||
|
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
|
||||||
|
} else {
|
||||||
|
// Try to match name case-insensitively
|
||||||
|
snap = Object.values(crewSnapshotsById).find(
|
||||||
|
(s) => (s.name || '').trim().toLowerCase() === creatorId.trim().toLowerCase()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Try to match active username/userid to the skipper snapshot
|
||||||
|
if (!snap) {
|
||||||
|
const activeUsername = localStorage.getItem('active_username')
|
||||||
|
const activeUserId = localStorage.getItem('active_userid')
|
||||||
|
if (
|
||||||
|
(activeUsername && creatorId.toLowerCase() === activeUsername.toLowerCase()) ||
|
||||||
|
(activeUserId && creatorId === activeUserId)
|
||||||
|
) {
|
||||||
|
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snap) {
|
||||||
name = snap.name || ''
|
name = snap.name || ''
|
||||||
photo = snap.photo || null
|
photo = snap.photo || null
|
||||||
role = snap.role || ''
|
role = snap.role || ''
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback to active username if owner or no crew pool matches
|
// Fallback to active username if owner or no crew pool matches
|
||||||
if (!name) {
|
if (!name) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Users } from 'lucide-react'
|
import { Users, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
|
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
|
||||||
import { loadPersonPool } from '../services/personPool.js'
|
import { loadPersonPool } from '../services/personPool.js'
|
||||||
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
|
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
|
||||||
@@ -24,6 +24,7 @@ export default function EntryCrewSection({
|
|||||||
preloadedPool
|
preloadedPool
|
||||||
}: EntryCrewSectionProps) {
|
}: EntryCrewSectionProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
|
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,11 +91,33 @@ export default function EntryCrewSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-card" data-tour="entry-crew">
|
<div className="form-card" data-tour="entry-crew">
|
||||||
<div className="form-header">
|
<div
|
||||||
|
className="form-header accordion-header"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
setCollapsed(!collapsed)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="accordion-header-title">
|
||||||
<Users size={22} className="form-icon" />
|
<Users size={22} className="form-icon" />
|
||||||
<h3>{t('entry_crew.title')}</h3>
|
<h3>{t('entry_crew.title')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
|
{collapsed ? (
|
||||||
|
<ChevronDown size={20} className="accordion-chevron" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp size={20} className="accordion-chevron" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
|
||||||
|
|
||||||
<div className="input-group mb-3">
|
<div className="input-group mb-3">
|
||||||
<label>{t('entry_crew.day_skipper')}</label>
|
<label>{t('entry_crew.day_skipper')}</label>
|
||||||
@@ -138,6 +161,8 @@ export default function EntryCrewSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,97 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Mic, Loader2 } from 'lucide-react'
|
||||||
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
||||||
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||||
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
||||||
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
|
import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||||
|
|
||||||
interface EventRemarksCellProps {
|
interface EventRemarksCellProps {
|
||||||
event: LogEventPayload
|
event: LogEventPayload
|
||||||
logbookId: string
|
logbookId: string
|
||||||
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
|
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventRemarksCell({
|
export default function EventRemarksCell({
|
||||||
event,
|
event,
|
||||||
logbookId,
|
logbookId,
|
||||||
voiceMemoLookup
|
voiceMemoLookup,
|
||||||
|
readOnly = false
|
||||||
}: EventRemarksCellProps) {
|
}: EventRemarksCellProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { showAlert } = useDialog()
|
||||||
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
||||||
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
|
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
|
||||||
|
|
||||||
|
const [transcribing, setTranscribing] = useState(false)
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOnline(true)
|
||||||
|
const handleOffline = () => setIsOnline(false)
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTranscribe = async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (transcribing || !preloaded?.audio || !voiceId) return
|
||||||
|
if (!getAiAuthorized()) {
|
||||||
|
void showAlert(
|
||||||
|
t('profile.ai_unauthorized_alert_desc'),
|
||||||
|
t('profile.ai_unauthorized_alert_title')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTranscribing(true)
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 15000)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/transcribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ audioDataUrl: preloaded.audio }),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Server returned status ${res.status}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
const text = (data.text || '').trim()
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Transcription returned empty text')
|
||||||
|
}
|
||||||
|
await updateVoiceMemoTranscript(logbookId, voiceId, text)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||||
|
status: 'success',
|
||||||
|
mode: 'manual'
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
console.error('[EventRemarksCell] Transcription failed:', err)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||||
|
status: 'failed',
|
||||||
|
mode: 'manual'
|
||||||
|
})
|
||||||
|
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
|
||||||
|
} finally {
|
||||||
|
setTranscribing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let summary = formatEventSummary(event, t)
|
let summary = formatEventSummary(event, t)
|
||||||
if (voiceId && preloaded?.caption) {
|
if (voiceId && preloaded?.caption) {
|
||||||
summary = t('logs.live_voice_entry', { caption: preloaded.caption })
|
summary = t('logs.live_voice_entry', { caption: preloaded.caption })
|
||||||
@@ -28,12 +101,39 @@ export default function EventRemarksCell({
|
|||||||
<div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
|
<div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
|
||||||
<span>{summary}</span>
|
<span>{summary}</span>
|
||||||
{voiceId && (
|
{voiceId && (
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginTop: '4px' }}>
|
||||||
<VoiceMemoPlayer
|
<VoiceMemoPlayer
|
||||||
audioId={voiceId}
|
audioId={voiceId}
|
||||||
logbookId={logbookId}
|
logbookId={logbookId}
|
||||||
preloaded={preloaded}
|
preloaded={preloaded}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
|
{!readOnly && preloaded && preloaded.transcribed === false && isOnline && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon-text link-sec"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
padding: '2px 6px',
|
||||||
|
height: 'auto',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
margin: 0
|
||||||
|
}}
|
||||||
|
onClick={handleTranscribe}
|
||||||
|
disabled={transcribing}
|
||||||
|
title={t('logs.live_voice_transcribe_action')}
|
||||||
|
>
|
||||||
|
{transcribing ? (
|
||||||
|
<Loader2 size={12} className="spin" />
|
||||||
|
) : (
|
||||||
|
<Mic size={12} />
|
||||||
|
)}
|
||||||
|
{transcribing ? t('logs.live_voice_transcribing') : t('logs.live_voice_transcribe_action')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Zap
|
Zap
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||||
import {
|
import {
|
||||||
appendQuickEvent as apiAppendQuickEvent,
|
appendQuickEvent as apiAppendQuickEvent,
|
||||||
appendQuickEvents as apiAppendQuickEvents,
|
appendQuickEvents as apiAppendQuickEvents,
|
||||||
@@ -31,7 +32,6 @@ import {
|
|||||||
removeLastEvent
|
removeLastEvent
|
||||||
} from '../services/quickEventLog.js'
|
} from '../services/quickEventLog.js'
|
||||||
import CreatorAvatar from './CreatorAvatar.tsx'
|
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||||
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
|
||||||
import {
|
import {
|
||||||
getLastAutoPositionMs,
|
getLastAutoPositionMs,
|
||||||
getLastLoggedPositionWithin,
|
getLastLoggedPositionWithin,
|
||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
liveFuelRemark,
|
liveFuelRemark,
|
||||||
livePhotoRemark,
|
livePhotoRemark,
|
||||||
liveVoiceRemark,
|
liveVoiceRemark,
|
||||||
parseLiveVoiceRemark,
|
|
||||||
livePrecipRemark,
|
livePrecipRemark,
|
||||||
liveSailsRemark,
|
liveSailsRemark,
|
||||||
liveSogRemark,
|
liveSogRemark,
|
||||||
@@ -80,7 +79,7 @@ import CourseDialInput from './CourseDialInput.tsx'
|
|||||||
import GpsSignalHint from './GpsSignalHint.tsx'
|
import GpsSignalHint from './GpsSignalHint.tsx'
|
||||||
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
||||||
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
|
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
|
||||||
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx'
|
import EventRemarksCell from './EventRemarksCell.tsx'
|
||||||
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
||||||
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||||
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||||
@@ -713,14 +712,28 @@ export default function LiveLogView({
|
|||||||
{ analyticsSource: 'live_log' }
|
{ analyticsSource: 'live_log' }
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
|
if (err instanceof WeatherApiError) {
|
||||||
|
if (err.code === 'OFFLINE') {
|
||||||
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
if (err.code === 'NO_KEY') {
|
||||||
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (err.code === 'UNAUTHORIZED') {
|
||||||
|
void showAlert(t('settings.weather_unauthorized'), t('logs.live_weather_owm_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err.code === 'NOT_FOUND') {
|
||||||
|
void showAlert(t('settings.weather_not_found'), t('logs.live_weather_owm_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err.code === 'BAD_REQUEST') {
|
||||||
|
void showAlert(t('settings.weather_bad_request'), t('logs.live_weather_owm_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
console.error('Live log OWM weather failed:', err)
|
console.error('Live log OWM weather failed:', err)
|
||||||
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
|
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
|
||||||
return
|
return
|
||||||
@@ -822,13 +835,50 @@ export default function LiveLogView({
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const audioDataUrl = await blobToAudioDataUrl(blob)
|
const audioDataUrl = await blobToAudioDataUrl(blob)
|
||||||
|
const authorized = getAiAuthorized()
|
||||||
|
let transcriptionText = ''
|
||||||
|
let transcribed = true
|
||||||
|
let transcriptionError = false
|
||||||
|
|
||||||
|
if (authorized) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 4000)
|
||||||
|
|
||||||
|
const res = await fetch('/api/ai/transcribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ audioDataUrl }),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (!res.ok) throw new Error(`Status ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
transcriptionText = (data.text || '').trim()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
|
||||||
|
transcriptionError = true
|
||||||
|
transcribed = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
transcribed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalCaption = caption
|
||||||
|
if (transcriptionText) {
|
||||||
|
finalCaption = caption
|
||||||
|
? `${caption}\n(Transkript: ${transcriptionText})`
|
||||||
|
: transcriptionText
|
||||||
|
}
|
||||||
|
|
||||||
const voiceId = await saveEntryVoiceMemo({
|
const voiceId = await saveEntryVoiceMemo({
|
||||||
logbookId,
|
logbookId,
|
||||||
entryId,
|
entryId,
|
||||||
audioDataUrl,
|
audioDataUrl,
|
||||||
mimeType,
|
mimeType,
|
||||||
durationSec,
|
durationSec,
|
||||||
caption,
|
caption: finalCaption,
|
||||||
|
transcribed,
|
||||||
analyticsContext: 'live_log'
|
analyticsContext: 'live_log'
|
||||||
})
|
})
|
||||||
await appendQuickEvent(logbookId, entryId, {
|
await appendQuickEvent(logbookId, entryId, {
|
||||||
@@ -840,6 +890,23 @@ export default function LiveLogView({
|
|||||||
setVoiceCaption('')
|
setVoiceCaption('')
|
||||||
showUndo('voice')
|
showUndo('voice')
|
||||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
|
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
|
||||||
|
if (transcriptionError) {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||||
|
status: 'failed',
|
||||||
|
mode: 'auto'
|
||||||
|
})
|
||||||
|
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
|
||||||
|
} else if (authorized) {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||||
|
status: 'success',
|
||||||
|
mode: 'auto'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
void showAlert(
|
||||||
|
t('profile.ai_unauthorized_alert_desc'),
|
||||||
|
t('profile.ai_unauthorized_alert_title')
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Live log voice save failed:', err)
|
console.error('Live log voice save failed:', err)
|
||||||
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
|
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
|
||||||
@@ -1211,12 +1278,6 @@ export default function LiveLogView({
|
|||||||
) : (
|
) : (
|
||||||
<ol className="live-log-stream">
|
<ol className="live-log-stream">
|
||||||
{events.map((event, index) => {
|
{events.map((event, index) => {
|
||||||
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
|
||||||
const voicePreloaded = voiceId ? voiceMemoLookup.get(voiceId) : undefined
|
|
||||||
let summary = formatEventSummary(event, t)
|
|
||||||
if (voiceId && voicePreloaded?.caption) {
|
|
||||||
summary = t('logs.live_voice_entry', { caption: voicePreloaded.caption })
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<li key={`${event.time}-${index}`} className="live-log-entry">
|
<li key={`${event.time}-${index}`} className="live-log-entry">
|
||||||
<time className="live-log-time">{event.time}</time>
|
<time className="live-log-time">{event.time}</time>
|
||||||
@@ -1226,15 +1287,12 @@ export default function LiveLogView({
|
|||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
<div className="live-log-summary-block">
|
<div className="live-log-summary-block">
|
||||||
<span className="live-log-summary">{summary}</span>
|
<EventRemarksCell
|
||||||
{voiceId && (
|
event={event}
|
||||||
<VoiceMemoPlayer
|
|
||||||
audioId={voiceId}
|
|
||||||
logbookId={logbookId}
|
logbookId={logbookId}
|
||||||
preloaded={voicePreloaded}
|
voiceMemoLookup={voiceMemoLookup}
|
||||||
compact
|
readOnly={false}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { getActiveMasterKey } from '../services/auth.js'
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
@@ -8,7 +9,8 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
|
|||||||
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { Camera, Trash2 } from 'lucide-react'
|
import { Camera, Image, Trash2, X } from 'lucide-react'
|
||||||
|
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
|
||||||
|
|
||||||
interface PhotoCaptureProps {
|
interface PhotoCaptureProps {
|
||||||
entryId: string
|
entryId: string
|
||||||
@@ -31,8 +33,38 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
|
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
|
||||||
|
const [hasCamera, setHasCamera] = useState(false)
|
||||||
|
const [maximizedPhoto, setMaximizedPhoto] = useState<DecryptedPhoto | null>(null)
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const cameraInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!maximizedPhoto) return
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setMaximizedPhoto(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [maximizedPhoto])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
probeCameraAvailability().then((avail) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setHasCamera(avail === 'available')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Reactively query local photos database
|
// Reactively query local photos database
|
||||||
const localPhotos = useLiveQuery(
|
const localPhotos = useLiveQuery(
|
||||||
@@ -119,12 +151,18 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerSelect = () => {
|
const triggerGallerySelect = () => {
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.click()
|
fileInputRef.current.click()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const triggerCameraSelect = () => {
|
||||||
|
if (cameraInputRef.current) {
|
||||||
|
cameraInputRef.current.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-card mt-6">
|
<div className="form-card mt-6">
|
||||||
<div className="form-header mb-4">
|
<div className="form-header mb-4">
|
||||||
@@ -159,10 +197,51 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
ref={cameraInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasCamera ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
onClick={triggerSelect}
|
onClick={triggerCameraSelect}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<span className="spin">⏳</span>
|
||||||
|
) : (
|
||||||
|
<Camera size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={triggerGallerySelect}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<span className="spin">⏳</span>
|
||||||
|
) : (
|
||||||
|
<Image size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={triggerGallerySelect}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
@@ -173,6 +252,7 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
)}
|
)}
|
||||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -183,14 +263,22 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
) : (
|
) : (
|
||||||
<div className="photo-attachments-grid">
|
<div className="photo-attachments-grid">
|
||||||
{decryptedPhotos.map((photo) => (
|
{decryptedPhotos.map((photo) => (
|
||||||
<div key={photo.payloadId} className="photo-card glass">
|
<div
|
||||||
|
key={photo.payloadId}
|
||||||
|
className="photo-card glass"
|
||||||
|
onClick={() => setMaximizedPhoto(photo)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
<div className="photo-container">
|
<div className="photo-container">
|
||||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="photo-btn-delete"
|
className="photo-btn-delete"
|
||||||
onClick={() => handleDelete(photo.payloadId)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(photo.payloadId)
|
||||||
|
}}
|
||||||
title="Remove photo"
|
title="Remove photo"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
@@ -206,6 +294,35 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{maximizedPhoto && createPortal(
|
||||||
|
<div
|
||||||
|
className="photo-maximized-overlay"
|
||||||
|
onClick={() => setMaximizedPhoto(null)}
|
||||||
|
>
|
||||||
|
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="photo-maximized-close"
|
||||||
|
onClick={() => setMaximizedPhoto(null)}
|
||||||
|
aria-label={t('common.close') || 'Close'}
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={maximizedPhoto.image}
|
||||||
|
alt={maximizedPhoto.caption || 'Maximized Attachment'}
|
||||||
|
className="photo-maximized-img"
|
||||||
|
/>
|
||||||
|
{maximizedPhoto.caption && (
|
||||||
|
<div className="photo-maximized-caption">
|
||||||
|
{maximizedPhoto.caption}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Compass, Palette, Save, Check, Cloud } from 'lucide-react'
|
import { Compass, Palette, Save, Check, Cloud, Brain } from 'lucide-react'
|
||||||
import ThemedSelect from './ThemedSelect.tsx'
|
import ThemedSelect from './ThemedSelect.tsx'
|
||||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
getThemePreference,
|
getThemePreference,
|
||||||
setColorSchemePreference,
|
setColorSchemePreference,
|
||||||
setOwmApiKey,
|
setOwmApiKey,
|
||||||
setThemePreference
|
setThemePreference,
|
||||||
|
getAiAuthorized,
|
||||||
|
setAiAuthorized
|
||||||
} from '../services/userPreferences.js'
|
} from '../services/userPreferences.js'
|
||||||
|
|
||||||
interface UserProfilePreferencesProps {
|
interface UserProfilePreferencesProps {
|
||||||
@@ -28,12 +30,25 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
|||||||
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
|
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
|
||||||
const [savingOwm, setSavingOwm] = useState(false)
|
const [savingOwm, setSavingOwm] = useState(false)
|
||||||
const [owmSaved, setOwmSaved] = useState(false)
|
const [owmSaved, setOwmSaved] = useState(false)
|
||||||
|
const [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleChanged = () => {
|
||||||
|
setTheme(getThemePreference(userId))
|
||||||
|
setColorScheme(getColorSchemePreference(userId))
|
||||||
|
setAiAuthorizedState(getAiAuthorized(userId))
|
||||||
|
}
|
||||||
|
window.addEventListener('appearance-changed', handleChanged)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('appearance-changed', handleChanged)
|
||||||
|
}
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
|
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
|
||||||
setThemePreference(userId, nextTheme)
|
setThemePreference(userId, nextTheme)
|
||||||
setColorSchemePreference(userId, nextColorScheme)
|
setColorSchemePreference(userId, nextColorScheme)
|
||||||
notifyAppearanceChanged()
|
notifyAppearanceChanged()
|
||||||
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
|
void saveAppearancePrefsToServer(nextTheme, nextColorScheme, aiAuthorized, userId).catch((err) => {
|
||||||
console.warn('Failed to save appearance prefs to server:', err)
|
console.warn('Failed to save appearance prefs to server:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -58,6 +73,15 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
|||||||
window.setTimeout(() => setOwmSaved(false), 3000)
|
window.setTimeout(() => setOwmSaved(false), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAiToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const nextVal = e.target.checked
|
||||||
|
setAiAuthorizedState(nextVal)
|
||||||
|
setAiAuthorized(userId, nextVal)
|
||||||
|
void saveAppearancePrefsToServer(theme, colorScheme, nextVal, userId).catch((err) => {
|
||||||
|
console.warn('Failed to save ai preference to server:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="member-editor-card glass">
|
<section className="member-editor-card glass">
|
||||||
@@ -152,6 +176,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="member-editor-card glass">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<Brain size={20} style={{ color: 'var(--app-accent-light)' }} />
|
||||||
|
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||||
|
{t('profile.ai_title')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 12px 0' }}>
|
||||||
|
{t('profile.ai_desc')}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted" style={{ fontSize: '13px', lineHeight: '145%', margin: '0 0 16px 0', whiteSpace: 'pre-line' }}>
|
||||||
|
{t('profile.ai_help')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="switch-label"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#f1f5f9'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="profile-ai-authorize"
|
||||||
|
type="checkbox"
|
||||||
|
checked={aiAuthorized}
|
||||||
|
onChange={handleAiToggle}
|
||||||
|
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<span>{t('profile.ai_enable_label')}</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
<PushNotificationSettings />
|
<PushNotificationSettings />
|
||||||
<PwaInstallPrompt variant="inline" />
|
<PwaInstallPrompt variant="inline" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface PreloadedVoiceMemo {
|
|||||||
mimeType?: string
|
mimeType?: string
|
||||||
durationSec?: number
|
durationSec?: number
|
||||||
caption?: string
|
caption?: string
|
||||||
|
transcribed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VoiceMemoPlayerProps {
|
interface VoiceMemoPlayerProps {
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export function useEntryVoiceMemos(
|
|||||||
audio: String(decrypted.audio),
|
audio: String(decrypted.audio),
|
||||||
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
|
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
|
||||||
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
|
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
|
||||||
caption: decrypted.caption ? String(decrypted.caption) : ''
|
caption: decrypted.caption ? String(decrypted.caption) : '',
|
||||||
|
transcribed: decrypted.transcribed !== false
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// skip corrupt memo
|
// skip corrupt memo
|
||||||
|
|||||||
@@ -185,7 +185,10 @@
|
|||||||
"travel_day_number": "Rejsedag {{number}}",
|
"travel_day_number": "Rejsedag {{number}}",
|
||||||
"departure": "Starthavn (rejse fra)",
|
"departure": "Starthavn (rejse fra)",
|
||||||
"destination": "Destinationsport (til)",
|
"destination": "Destinationsport (til)",
|
||||||
"route": "Rejse fra/til",
|
"route": "Reje fra/til",
|
||||||
|
"tanks": "Tanke",
|
||||||
|
"customize_columns": "Tilpas kolonner",
|
||||||
|
"column_selector_title": "Kolonner at vise",
|
||||||
"freshwater": "Ferskvand (liter)",
|
"freshwater": "Ferskvand (liter)",
|
||||||
"fuel": "Treibstoff / Brændstof (liter)",
|
"fuel": "Treibstoff / Brændstof (liter)",
|
||||||
"greywater": "Gråt vand (liter)",
|
"greywater": "Gråt vand (liter)",
|
||||||
@@ -297,6 +300,9 @@
|
|||||||
"live_voice_entry_plain": "Stemmenotat",
|
"live_voice_entry_plain": "Stemmenotat",
|
||||||
"live_voice_caption_label": "Billedtekst (valgfrit)",
|
"live_voice_caption_label": "Billedtekst (valgfrit)",
|
||||||
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester",
|
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester",
|
||||||
|
"live_voice_transcribe_action": "Transkribere",
|
||||||
|
"live_voice_transcribing": "Transkriberer…",
|
||||||
|
"live_voice_transcribe_failed": "Stemmebesked gemt, men transkribering mislykkedes.",
|
||||||
"live_undo_voice_hint": "Stemmenotat gemt",
|
"live_undo_voice_hint": "Stemmenotat gemt",
|
||||||
"live_comment_btn": "Kommentar",
|
"live_comment_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Indtast tekst…",
|
"live_comment_placeholder": "Indtast tekst…",
|
||||||
@@ -436,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
|
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
|
||||||
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
|
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
|
||||||
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
|
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
|
||||||
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
|
"photos_title": "Vedhæftede billeder",
|
||||||
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
|
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
|
||||||
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
|
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
|
||||||
"photo_btn": "Tag foto / upload",
|
"photo_btn": "Tag foto / upload",
|
||||||
|
"photo_camera_btn": "Tag foto",
|
||||||
|
"photo_gallery_btn": "Vælg fra galleri",
|
||||||
"photo_processing": "Er ved at blive behandlet...",
|
"photo_processing": "Er ved at blive behandlet...",
|
||||||
"no_photos": "Der er endnu ingen billeder knyttet til denne rejsedag.",
|
"no_photos": "Der er endnu ingen billeder knyttet til denne rejsedag.",
|
||||||
"photo_delete_confirm": "Er du sikker på, at du vil slette dette foto permanent?",
|
"photo_delete_confirm": "Er du sikker på, at du vil slette dette foto permanent?",
|
||||||
@@ -669,6 +677,12 @@
|
|||||||
"integrations_title": "Integrationer",
|
"integrations_title": "Integrationer",
|
||||||
"owm_key": "OpenWeatherMap API-nøgle",
|
"owm_key": "OpenWeatherMap API-nøgle",
|
||||||
"owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.",
|
"owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.",
|
||||||
|
"ai_title": "AI-funktioner og privatliv",
|
||||||
|
"ai_desc": "Autoriser integrationer af kunstig intelligens for dine logbøger.",
|
||||||
|
"ai_help": "Aktivering af AI-funktioner giver appen mulighed for at opsummere dine rejsedage og transkribere optagede stemmememoer. For at behandle disse anmodninger sendes rå stemmedata og rejselogfiler sikkert løbende til OpenRouter. Der gemmes ingen data permanent af AI-modellen.\n\nDisse cloud-ressourcer koster penge at køre. Hvis du kan lide at bruge dem, bedes du overveje at støtte projektet frivilligt med en donation via Ko-fi-linket i footeren for at holde dem gratis og bæredygtige for alle.",
|
||||||
|
"ai_enable_label": "Aktiver transkribering og resuméer af rejsedage",
|
||||||
|
"ai_unauthorized_alert_title": "AI-funktioner er ikke autoriseret",
|
||||||
|
"ai_unauthorized_alert_desc": "For at bruge transkribering eller rejsedagsresuméer skal du autorisere dataoverførslen til OpenRouter i din brugerprofil under 'AI-funktioner og privatliv'.",
|
||||||
"prefs_save": "Gemme",
|
"prefs_save": "Gemme",
|
||||||
"prefs_saving": "Vil blive reddet...",
|
"prefs_saving": "Vil blive reddet...",
|
||||||
"prefs_saved": "Gemt",
|
"prefs_saved": "Gemt",
|
||||||
@@ -790,6 +804,9 @@
|
|||||||
"no_key": "Ingen OpenWeatherMap API-nøgle tilgængelig. Gem din egen nøgle i brugerprofilen, eller kontakt operatøren.",
|
"no_key": "Ingen OpenWeatherMap API-nøgle tilgængelig. Gem din egen nøgle i brugerprofilen, eller kontakt operatøren.",
|
||||||
"weather_success": "Vejrdata hentet med succes!",
|
"weather_success": "Vejrdata hentet med succes!",
|
||||||
"weather_error": "Hentning af vejrdata mislykkedes. Tjek API-nøglen og forbindelsen.",
|
"weather_error": "Hentning af vejrdata mislykkedes. Tjek API-nøglen og forbindelsen.",
|
||||||
|
"weather_unauthorized": "Hentning af vejrdata mislykkedes. API-nøglen er ugyldig eller ikke autoriseret.",
|
||||||
|
"weather_not_found": "Hentning af vejrdata mislykkedes. Den angivne placering eller koordinater blev ikke fundet.",
|
||||||
|
"weather_bad_request": "Hentning af vejrdata mislykkedes. Ingen placering eller GPS-position blev angivet.",
|
||||||
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
|
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
|
||||||
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
|
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
|
||||||
"share_title": "Del logbog (skrivebeskyttet)",
|
"share_title": "Del logbog (skrivebeskyttet)",
|
||||||
|
|||||||
@@ -186,6 +186,9 @@
|
|||||||
"departure": "Start-Hafen (Reise von)",
|
"departure": "Start-Hafen (Reise von)",
|
||||||
"destination": "Ziel-Hafen (nach)",
|
"destination": "Ziel-Hafen (nach)",
|
||||||
"route": "Reise von/nach",
|
"route": "Reise von/nach",
|
||||||
|
"tanks": "Tanks",
|
||||||
|
"customize_columns": "Spalten anpassen",
|
||||||
|
"column_selector_title": "Anzuzeigende Spalten",
|
||||||
"freshwater": "Frischwasser (Liter)",
|
"freshwater": "Frischwasser (Liter)",
|
||||||
"fuel": "Treibstoff / Fuel (Liter)",
|
"fuel": "Treibstoff / Fuel (Liter)",
|
||||||
"greywater": "Grauwasser (Liter)",
|
"greywater": "Grauwasser (Liter)",
|
||||||
@@ -297,6 +300,9 @@
|
|||||||
"live_voice_entry_plain": "Sprachnotiz",
|
"live_voice_entry_plain": "Sprachnotiz",
|
||||||
"live_voice_caption_label": "Beschriftung (optional)",
|
"live_voice_caption_label": "Beschriftung (optional)",
|
||||||
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
|
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
|
||||||
|
"live_voice_transcribe_action": "Transkribieren",
|
||||||
|
"live_voice_transcribing": "Transkribiere...",
|
||||||
|
"live_voice_transcribe_failed": "Sprachmemo gespeichert, aber Transkription fehlgeschlagen.",
|
||||||
"live_undo_voice_hint": "Sprachnotiz gespeichert",
|
"live_undo_voice_hint": "Sprachnotiz gespeichert",
|
||||||
"live_comment_btn": "Kommentar",
|
"live_comment_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Freitext eingeben…",
|
"live_comment_placeholder": "Freitext eingeben…",
|
||||||
@@ -436,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
|
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
|
||||||
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
||||||
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||||
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
|
"photos_title": "Foto-Anhänge",
|
||||||
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
||||||
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
||||||
"photo_btn": "Foto aufnehmen / Hochladen",
|
"photo_btn": "Foto aufnehmen / Hochladen",
|
||||||
|
"photo_camera_btn": "Foto aufnehmen",
|
||||||
|
"photo_gallery_btn": "Aus Galerie wählen",
|
||||||
"photo_processing": "Wird verarbeitet...",
|
"photo_processing": "Wird verarbeitet...",
|
||||||
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
|
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
|
||||||
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
|
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
|
||||||
@@ -669,6 +677,12 @@
|
|||||||
"integrations_title": "Integrationen",
|
"integrations_title": "Integrationen",
|
||||||
"owm_key": "OpenWeatherMap API-Schlüssel",
|
"owm_key": "OpenWeatherMap API-Schlüssel",
|
||||||
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
|
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
|
||||||
|
"ai_title": "KI-Funktionen & Datenschutz",
|
||||||
|
"ai_desc": "Autorisiere die Nutzung von künstlicher Intelligenz (lokale/Cloud-Integrationen) für deine Logbücher.",
|
||||||
|
"ai_help": "Die Aktivierung ermöglicht es, Reiseberichte automatisch zusammenzufassen und Sprachnotizen zu transkribieren. Zur Verarbeitung werden Sprachaufnahmen und Logbucheinträge verschlüsselt an OpenRouter übertragen. Die Daten werden dort nicht dauerhaft gespeichert.\n\nDa der Betrieb dieser Cloud-Ressourcen Kosten verursacht, freuen wir uns über eine freiwillige Unterstützung über den Ko-fi-Spenden-Link im Footer, um diese Funktionen dauerhaft für alle kostenlos anbieten zu können.",
|
||||||
|
"ai_enable_label": "Transkribierung und Tageszusammenfassungen aktivieren",
|
||||||
|
"ai_unauthorized_alert_title": "KI-Funktionen nicht autorisiert",
|
||||||
|
"ai_unauthorized_alert_desc": "Um Sprachnotizen zu transkribieren oder Reiseberichte zusammenzufassen, musst du der Datenübermittlung an OpenRouter in deinem Benutzerprofil unter 'KI-Funktionen & Datenschutz' zustimmen.",
|
||||||
"prefs_save": "Speichern",
|
"prefs_save": "Speichern",
|
||||||
"prefs_saving": "Wird gespeichert…",
|
"prefs_saving": "Wird gespeichert…",
|
||||||
"prefs_saved": "Gespeichert",
|
"prefs_saved": "Gespeichert",
|
||||||
@@ -790,6 +804,9 @@
|
|||||||
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
|
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
|
||||||
"weather_success": "Wetterdaten erfolgreich abgerufen!",
|
"weather_success": "Wetterdaten erfolgreich abgerufen!",
|
||||||
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
|
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
|
||||||
|
"weather_unauthorized": "Wetterdatenabruf fehlgeschlagen. Der API-Schlüssel ist ungültig oder nicht autorisiert.",
|
||||||
|
"weather_not_found": "Wetterdatenabruf fehlgeschlagen. Der angegebene Ort oder die Koordinaten wurden nicht gefunden.",
|
||||||
|
"weather_bad_request": "Wetterdatenabruf fehlgeschlagen. Es wurde kein Ort und keine GPS-Position angegeben.",
|
||||||
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
|
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
|
||||||
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
|
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
|
||||||
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
||||||
|
|||||||
@@ -186,6 +186,9 @@
|
|||||||
"departure": "Departure Port (von)",
|
"departure": "Departure Port (von)",
|
||||||
"destination": "Destination Port (nach)",
|
"destination": "Destination Port (nach)",
|
||||||
"route": "Route / Journey",
|
"route": "Route / Journey",
|
||||||
|
"tanks": "Tanks",
|
||||||
|
"customize_columns": "Customize columns",
|
||||||
|
"column_selector_title": "Columns to Show",
|
||||||
"freshwater": "Freshwater (Liters)",
|
"freshwater": "Freshwater (Liters)",
|
||||||
"fuel": "Fuel (Liters)",
|
"fuel": "Fuel (Liters)",
|
||||||
"greywater": "Greywater (Liters)",
|
"greywater": "Greywater (Liters)",
|
||||||
@@ -297,6 +300,9 @@
|
|||||||
"live_voice_entry_plain": "Voice memo",
|
"live_voice_entry_plain": "Voice memo",
|
||||||
"live_voice_caption_label": "Caption (optional)",
|
"live_voice_caption_label": "Caption (optional)",
|
||||||
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
|
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
|
||||||
|
"live_voice_transcribe_action": "Transcribe",
|
||||||
|
"live_voice_transcribing": "Transcribing…",
|
||||||
|
"live_voice_transcribe_failed": "Voice memo saved, but transcription failed.",
|
||||||
"live_undo_voice_hint": "Voice memo saved",
|
"live_undo_voice_hint": "Voice memo saved",
|
||||||
"live_comment_btn": "Comment",
|
"live_comment_btn": "Comment",
|
||||||
"live_comment_placeholder": "Enter text…",
|
"live_comment_placeholder": "Enter text…",
|
||||||
@@ -436,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
|
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
|
||||||
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
|
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
|
||||||
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
||||||
"photos_title": "Photo Attachments (E2E Encrypted)",
|
"photos_title": "Photo Attachments",
|
||||||
"photo_caption_label": "Photo Caption / Label (Optional)",
|
"photo_caption_label": "Photo Caption / Label (Optional)",
|
||||||
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
||||||
"photo_btn": "Take Photo / Upload",
|
"photo_btn": "Take Photo / Upload",
|
||||||
|
"photo_camera_btn": "Take Photo",
|
||||||
|
"photo_gallery_btn": "Choose from Gallery",
|
||||||
"photo_processing": "Processing...",
|
"photo_processing": "Processing...",
|
||||||
"no_photos": "No photos attached to this journal entry yet.",
|
"no_photos": "No photos attached to this journal entry yet.",
|
||||||
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
|
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
|
||||||
@@ -669,6 +677,12 @@
|
|||||||
"integrations_title": "Integrations",
|
"integrations_title": "Integrations",
|
||||||
"owm_key": "OpenWeatherMap API key",
|
"owm_key": "OpenWeatherMap API key",
|
||||||
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
|
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
|
||||||
|
"ai_title": "AI Features & Privacy",
|
||||||
|
"ai_desc": "Authorize artificial intelligence integrations for your logbooks.",
|
||||||
|
"ai_help": "Enabling AI features allows the app to summarize travel days and transcribe recorded voice memos. To process these requests, raw voice data and travel logs are sent securely on-the-fly to OpenRouter. No data is stored permanently by the AI model.\n\nThese cloud resources cost money to run; if you enjoy using them, please consider supporting the project voluntarily with a donation via the Ko-fi link in the footer to keep them free and sustainable for everyone.",
|
||||||
|
"ai_enable_label": "Enable transcription and travel day summaries",
|
||||||
|
"ai_unauthorized_alert_title": "AI Features Not Authorized",
|
||||||
|
"ai_unauthorized_alert_desc": "To use transcription or travel day summaries, please authorize the data transmission to OpenRouter in your User Profile under 'AI Features & Privacy'.",
|
||||||
"prefs_save": "Save",
|
"prefs_save": "Save",
|
||||||
"prefs_saving": "Saving…",
|
"prefs_saving": "Saving…",
|
||||||
"prefs_saved": "Saved",
|
"prefs_saved": "Saved",
|
||||||
@@ -790,6 +804,9 @@
|
|||||||
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
|
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
|
||||||
"weather_success": "Weather details fetched successfully!",
|
"weather_success": "Weather details fetched successfully!",
|
||||||
"weather_error": "Failed to fetch weather. Check your API key and connection.",
|
"weather_error": "Failed to fetch weather. Check your API key and connection.",
|
||||||
|
"weather_unauthorized": "Failed to fetch weather. The API key is invalid or unauthorized.",
|
||||||
|
"weather_not_found": "Failed to fetch weather. The specified location or coordinates were not found.",
|
||||||
|
"weather_bad_request": "Failed to fetch weather. No location or GPS position was specified.",
|
||||||
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
|
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
|
||||||
"gps_error": "Please enter a location or fetch GPS coordinates first.",
|
"gps_error": "Please enter a location or fetch GPS coordinates first.",
|
||||||
"share_title": "Share Logbook (Read-Only)",
|
"share_title": "Share Logbook (Read-Only)",
|
||||||
|
|||||||
@@ -186,6 +186,9 @@
|
|||||||
"departure": "Starthavn (reise fra)",
|
"departure": "Starthavn (reise fra)",
|
||||||
"destination": "Destinasjonsport (til)",
|
"destination": "Destinasjonsport (til)",
|
||||||
"route": "Reise fra/til",
|
"route": "Reise fra/til",
|
||||||
|
"tanks": "Tanker",
|
||||||
|
"customize_columns": "Tilpass kolonner",
|
||||||
|
"column_selector_title": "Kolonner å vise",
|
||||||
"freshwater": "Ferskvann (liter)",
|
"freshwater": "Ferskvann (liter)",
|
||||||
"fuel": "Drivstoff / Drivstoff (liter)",
|
"fuel": "Drivstoff / Drivstoff (liter)",
|
||||||
"greywater": "Gråvann (liter)",
|
"greywater": "Gråvann (liter)",
|
||||||
@@ -297,6 +300,9 @@
|
|||||||
"live_voice_entry_plain": "Talemelding",
|
"live_voice_entry_plain": "Talemelding",
|
||||||
"live_voice_caption_label": "Bildetekst (valgfritt)",
|
"live_voice_caption_label": "Bildetekst (valgfritt)",
|
||||||
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef",
|
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef",
|
||||||
|
"live_voice_transcribe_action": "Transkribere",
|
||||||
|
"live_voice_transcribing": "Transkriberer…",
|
||||||
|
"live_voice_transcribe_failed": "Taleopptak lagret, men transkribering mislyktes.",
|
||||||
"live_undo_voice_hint": "Talemelding lagret",
|
"live_undo_voice_hint": "Talemelding lagret",
|
||||||
"live_comment_btn": "Kommentar",
|
"live_comment_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Skriv inn tekst…",
|
"live_comment_placeholder": "Skriv inn tekst…",
|
||||||
@@ -436,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
|
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
|
||||||
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
|
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
|
||||||
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
|
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
|
||||||
"photos_title": "Bildevedlegg (E2E-kryptert)",
|
"photos_title": "Bildevedlegg",
|
||||||
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
|
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
|
||||||
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
|
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
|
||||||
"photo_btn": "Ta bilde / last opp",
|
"photo_btn": "Ta bilde / last opp",
|
||||||
|
"photo_camera_btn": "Ta bilde",
|
||||||
|
"photo_gallery_btn": "Velg fra galleri",
|
||||||
"photo_processing": "...blir behandlet...",
|
"photo_processing": "...blir behandlet...",
|
||||||
"no_photos": "Ingen bilder knyttet til denne reisedagen ennå.",
|
"no_photos": "Ingen bilder knyttet til denne reisedagen ennå.",
|
||||||
"photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?",
|
"photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?",
|
||||||
@@ -669,6 +677,12 @@
|
|||||||
"integrations_title": "Integrasjoner",
|
"integrations_title": "Integrasjoner",
|
||||||
"owm_key": "OpenWeatherMap API-nøkkel",
|
"owm_key": "OpenWeatherMap API-nøkkel",
|
||||||
"owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.",
|
"owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.",
|
||||||
|
"ai_title": "KI-funksjoner og personvern",
|
||||||
|
"ai_desc": "Autoriser integrasjoner av kunstig intelligens for loggbøkene dine.",
|
||||||
|
"ai_help": "Aktivering av KI-funksjoner gjør det mulig for appen å oppsummere reisedagene dine og transkribere innspilte talememoer. For å behandle disse forespørslene sendes rå stemmedata og reiselogger sikkert løpende til OpenRouter. Ingen data lagres permanent av KI-modellen.\n\nDisse nettskyressursene koster penger å drifte. Hvis du har glede av å bruke dem, kan du vurdere å støtte prosjektet frivillig med en donasjon via Ko-fi-lenken i bunnteksten for å holde dem gratis og bærekraftige for alle.",
|
||||||
|
"ai_enable_label": "Aktiver transkribering og oppsummeringer av reisedager",
|
||||||
|
"ai_unauthorized_alert_title": "KI-funktionen er ikke autorisert",
|
||||||
|
"ai_unauthorized_alert_desc": "For å bruke transkribering eller reisedagsoppsummeringer, må du autorisere dataoverføringen til OpenRouter i brukerprofilen din under 'KI-funksjoner og personvern'.",
|
||||||
"prefs_save": "Spar",
|
"prefs_save": "Spar",
|
||||||
"prefs_saving": "...vil bli reddet...",
|
"prefs_saving": "...vil bli reddet...",
|
||||||
"prefs_saved": "Reddet",
|
"prefs_saved": "Reddet",
|
||||||
@@ -790,6 +804,9 @@
|
|||||||
"no_key": "Ingen OpenWeatherMap API-nøkkel tilgjengelig. Lagre din egen nøkkel i brukerprofilen, eller kontakt operatøren.",
|
"no_key": "Ingen OpenWeatherMap API-nøkkel tilgjengelig. Lagre din egen nøkkel i brukerprofilen, eller kontakt operatøren.",
|
||||||
"weather_success": "Værdata vellykket hentet!",
|
"weather_success": "Værdata vellykket hentet!",
|
||||||
"weather_error": "Henting av værdata mislyktes. Kontroller API-nøkkelen og tilkoblingen.",
|
"weather_error": "Henting av værdata mislyktes. Kontroller API-nøkkelen og tilkoblingen.",
|
||||||
|
"weather_unauthorized": "Henting av værdata mislyktes. API-nøkkelen er ugyldig eller ikke autorisert.",
|
||||||
|
"weather_not_found": "Henting av værdata mislyktes. Den angitte posisjonen eller koordinatene ble ikke funnet.",
|
||||||
|
"weather_bad_request": "Henting av værdata mislyktes. Ingen posisjon eller GPS-koordinater ble angitt.",
|
||||||
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
|
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
|
||||||
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
|
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
|
||||||
"share_title": "Del loggbok (skrivebeskyttet)",
|
"share_title": "Del loggbok (skrivebeskyttet)",
|
||||||
|
|||||||
@@ -186,6 +186,9 @@
|
|||||||
"departure": "Starthamn (resa från)",
|
"departure": "Starthamn (resa från)",
|
||||||
"destination": "Destinationsport (till)",
|
"destination": "Destinationsport (till)",
|
||||||
"route": "Resa från/till",
|
"route": "Resa från/till",
|
||||||
|
"tanks": "Tankar",
|
||||||
|
"customize_columns": "Anpassa kolumner",
|
||||||
|
"column_selector_title": "Kolumner att visa",
|
||||||
"freshwater": "Färskvatten (liter)",
|
"freshwater": "Färskvatten (liter)",
|
||||||
"fuel": "Treibstoff / Bränsle (liter)",
|
"fuel": "Treibstoff / Bränsle (liter)",
|
||||||
"greywater": "Gråvatten (liter)",
|
"greywater": "Gråvatten (liter)",
|
||||||
@@ -297,6 +300,9 @@
|
|||||||
"live_voice_entry_plain": "Röstanteckning",
|
"live_voice_entry_plain": "Röstanteckning",
|
||||||
"live_voice_caption_label": "Bildtext (valfritt)",
|
"live_voice_caption_label": "Bildtext (valfritt)",
|
||||||
"live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare",
|
"live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare",
|
||||||
|
"live_voice_transcribe_action": "Transkribera",
|
||||||
|
"live_voice_transcribing": "Transkriberar…",
|
||||||
|
"live_voice_transcribe_failed": "Röstanteckning sparad, men transkribering misslyckades.",
|
||||||
"live_undo_voice_hint": "Röstanteckning sparad",
|
"live_undo_voice_hint": "Röstanteckning sparad",
|
||||||
"live_comment_btn": "Kommentar",
|
"live_comment_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Ange text…",
|
"live_comment_placeholder": "Ange text…",
|
||||||
@@ -436,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
|
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
|
||||||
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
|
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
|
||||||
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
|
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
|
||||||
"photos_title": "Fotobilagor (E2E-krypterade)",
|
"photos_title": "Fotobilagor",
|
||||||
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
|
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
|
||||||
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
||||||
"photo_btn": "Ta foto / ladda upp",
|
"photo_btn": "Ta foto / ladda upp",
|
||||||
|
"photo_camera_btn": "Ta foto",
|
||||||
|
"photo_gallery_btn": "Välj från galleri",
|
||||||
"photo_processing": "Håller på att bearbetas...",
|
"photo_processing": "Håller på att bearbetas...",
|
||||||
"no_photos": "Inga foton kopplade till denna resdag ännu.",
|
"no_photos": "Inga foton kopplade till denna resdag ännu.",
|
||||||
"photo_delete_confirm": "Är du säker på att du vill radera det här fotot permanent?",
|
"photo_delete_confirm": "Är du säker på att du vill radera det här fotot permanent?",
|
||||||
@@ -669,6 +677,12 @@
|
|||||||
"integrations_title": "Integrationer",
|
"integrations_title": "Integrationer",
|
||||||
"owm_key": "OpenWeatherMap API-nyckel",
|
"owm_key": "OpenWeatherMap API-nyckel",
|
||||||
"owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.",
|
"owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.",
|
||||||
|
"ai_title": "AI-funktioner och integritet",
|
||||||
|
"ai_desc": "Auktorisera integrationer av artificiell intelligens för dina loggböcker.",
|
||||||
|
"ai_help": "Genom at aktivera AI-funktioner kan appen sammanfatta dina rejsdagar och transkribera röstmemon. För att bearbeta dessa förfrågningar skickas röstdata och rejsloggar säkert och tillfälligt till OpenRouter. Inga data sparas permanent av AI-modellen.\n\nDessa molnresurser kostar pengar att driva. Om du gillar att använda dem, överväg att frivilligt stödja projektet med en donation via Ko-fi-länken i sidfoten för att hålla dem gratis och hållbara för alla.",
|
||||||
|
"ai_enable_label": "Aktivera transkribering och sammanfattningar av rejsdagar",
|
||||||
|
"ai_unauthorized_alert_title": "AI-funktioner är inte auktoriserade",
|
||||||
|
"ai_unauthorized_alert_desc": "För att använda transkribering eller rejsdagsöversikter måste du auktorisera dataöverföringen till OpenRouter i din användarprofil under 'AI-funktioner och integritet'.",
|
||||||
"prefs_save": "Spara",
|
"prefs_save": "Spara",
|
||||||
"prefs_saving": "Kommer att sparas...",
|
"prefs_saving": "Kommer att sparas...",
|
||||||
"prefs_saved": "Sparade",
|
"prefs_saved": "Sparade",
|
||||||
@@ -790,6 +804,9 @@
|
|||||||
"no_key": "Ingen OpenWeatherMap API-nyckel tillgänglig. Spara din egen nyckel i användarprofilen eller kontakta operatören.",
|
"no_key": "Ingen OpenWeatherMap API-nyckel tillgänglig. Spara din egen nyckel i användarprofilen eller kontakta operatören.",
|
||||||
"weather_success": "Väderdata har hämtats framgångsrikt!",
|
"weather_success": "Väderdata har hämtats framgångsrikt!",
|
||||||
"weather_error": "Hämtning av väderdata misslyckades. Kontrollera API-nyckeln och anslutningen.",
|
"weather_error": "Hämtning av väderdata misslyckades. Kontrollera API-nyckeln och anslutningen.",
|
||||||
|
"weather_unauthorized": "Hämtning av väderdata misslyckades. API-nyckeln är ogiltig eller inte auktoriserad.",
|
||||||
|
"weather_not_found": "Hämtning av väderdata misslyckades. Den angivna platsen eller koordinaterna hittades inte.",
|
||||||
|
"weather_bad_request": "Hämtning av väderdata misslyckades. Ingen plats eller GPS-position angavs.",
|
||||||
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
|
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
|
||||||
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
|
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
|
||||||
"share_title": "Aktieloggbok (skrivskyddad)",
|
"share_title": "Aktieloggbok (skrivskyddad)",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface AdminSummary {
|
|||||||
totalCollaborations: number
|
totalCollaborations: number
|
||||||
totalInvitations: number
|
totalInvitations: number
|
||||||
aiSummaryEntries: number
|
aiSummaryEntries: number
|
||||||
|
dbSize: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminTimeBucket = 'day' | 'week' | 'month'
|
export type AdminTimeBucket = 'day' | 'week' | 'month'
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const PlausibleEvents = {
|
|||||||
LIVE_LOG_OPENED: 'Live Log Opened',
|
LIVE_LOG_OPENED: 'Live Log Opened',
|
||||||
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
||||||
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
|
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
|
||||||
|
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
|
||||||
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
||||||
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
||||||
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe('appearancePrefs', () => {
|
|||||||
await expect(fetchAppearancePrefs()).resolves.toEqual({
|
await expect(fetchAppearancePrefs()).resolves.toEqual({
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
colorScheme: 'auto',
|
colorScheme: 'auto',
|
||||||
|
aiAuthorized: false,
|
||||||
persisted: false
|
persisted: false
|
||||||
})
|
})
|
||||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
@@ -36,6 +37,7 @@ describe('appearancePrefs', () => {
|
|||||||
mockedApiJson.mockResolvedValueOnce({
|
mockedApiJson.mockResolvedValueOnce({
|
||||||
theme: 'ocean',
|
theme: 'ocean',
|
||||||
colorScheme: 'dark',
|
colorScheme: 'dark',
|
||||||
|
aiAuthorized: true,
|
||||||
persisted: true
|
persisted: true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ describe('appearancePrefs', () => {
|
|||||||
|
|
||||||
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
||||||
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
|
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
|
||||||
|
expect(localStorage.getItem(`user_pref_ai_authorized_${USER_ID}`)).toBe('true')
|
||||||
expect(changed).toHaveBeenCalledTimes(1)
|
expect(changed).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -53,20 +56,20 @@ describe('appearancePrefs', () => {
|
|||||||
localStorage.setItem('active_userid', USER_ID)
|
localStorage.setItem('active_userid', USER_ID)
|
||||||
setThemePreference(USER_ID, 'material')
|
setThemePreference(USER_ID, 'material')
|
||||||
mockedApiJson
|
mockedApiJson
|
||||||
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
|
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false })
|
||||||
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
|
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', aiAuthorized: false, persisted: true })
|
||||||
|
|
||||||
await syncAppearancePrefs(USER_ID)
|
await syncAppearancePrefs(USER_ID)
|
||||||
|
|
||||||
expect(mockedApiJson).toHaveBeenCalledTimes(2)
|
expect(mockedApiJson).toHaveBeenCalledTimes(2)
|
||||||
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
|
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' })
|
body: JSON.stringify({ theme: 'material', colorScheme: 'auto', aiAuthorized: false })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
|
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
|
||||||
await saveAppearancePrefsToServer('ocean', 'light')
|
await saveAppearancePrefsToServer('ocean', 'light', true)
|
||||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -76,6 +79,7 @@ describe('appearancePrefs', () => {
|
|||||||
mockedApiJson.mockResolvedValue({
|
mockedApiJson.mockResolvedValue({
|
||||||
theme: 'material',
|
theme: 'material',
|
||||||
colorScheme: 'dark',
|
colorScheme: 'dark',
|
||||||
|
aiAuthorized: false,
|
||||||
persisted: true
|
persisted: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
getColorSchemePreference,
|
getColorSchemePreference,
|
||||||
getThemePreference,
|
getThemePreference,
|
||||||
setColorSchemePreference,
|
setColorSchemePreference,
|
||||||
setThemePreference
|
setThemePreference,
|
||||||
|
getAiAuthorized,
|
||||||
|
setAiAuthorized
|
||||||
} from './userPreferences.js'
|
} from './userPreferences.js'
|
||||||
|
|
||||||
const API_BASE = '/api/auth/appearance-prefs'
|
const API_BASE = '/api/auth/appearance-prefs'
|
||||||
@@ -13,13 +15,15 @@ const API_BASE = '/api/auth/appearance-prefs'
|
|||||||
export interface AppearancePrefs {
|
export interface AppearancePrefs {
|
||||||
theme: string
|
theme: string
|
||||||
colorScheme: string
|
colorScheme: string
|
||||||
|
aiAuthorized: boolean
|
||||||
persisted: boolean
|
persisted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasLocalAppearancePrefs(userId: string): boolean {
|
function hasLocalAppearancePrefs(userId: string): boolean {
|
||||||
return (
|
return (
|
||||||
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
|
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
|
||||||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null
|
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null ||
|
||||||
|
localStorage.getItem(`user_pref_ai_authorized_${userId}`) != null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +39,7 @@ function resolveSyncedUserId(userId?: string | null): string | null {
|
|||||||
|
|
||||||
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
|
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
|
||||||
if (!resolveSyncedUserId(userId)) {
|
if (!resolveSyncedUserId(userId)) {
|
||||||
return { theme: 'auto', colorScheme: 'auto', persisted: false }
|
return { theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiJson<AppearancePrefs>(API_BASE)
|
return apiJson<AppearancePrefs>(API_BASE)
|
||||||
@@ -44,13 +48,14 @@ export async function fetchAppearancePrefs(userId?: string | null): Promise<Appe
|
|||||||
export async function saveAppearancePrefsToServer(
|
export async function saveAppearancePrefsToServer(
|
||||||
theme: string,
|
theme: string,
|
||||||
colorScheme: string,
|
colorScheme: string,
|
||||||
|
aiAuthorized: boolean,
|
||||||
userId?: string | null
|
userId?: string | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!resolveSyncedUserId(userId)) return
|
if (!resolveSyncedUserId(userId)) return
|
||||||
|
|
||||||
await apiJson<AppearancePrefs>(API_BASE, {
|
await apiJson<AppearancePrefs>(API_BASE, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ theme, colorScheme })
|
body: JSON.stringify({ theme, colorScheme, aiAuthorized })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +70,14 @@ export async function syncAppearancePrefs(userId?: string | null): Promise<void>
|
|||||||
if (server.persisted) {
|
if (server.persisted) {
|
||||||
setThemePreference(id, server.theme)
|
setThemePreference(id, server.theme)
|
||||||
setColorSchemePreference(id, server.colorScheme)
|
setColorSchemePreference(id, server.colorScheme)
|
||||||
|
setAiAuthorized(id, server.aiAuthorized)
|
||||||
} else if (hasLocalAppearancePrefs(id)) {
|
} else if (hasLocalAppearancePrefs(id)) {
|
||||||
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id)
|
await saveAppearancePrefsToServer(
|
||||||
|
getThemePreference(id),
|
||||||
|
getColorSchemePreference(id),
|
||||||
|
getAiAuthorized(id),
|
||||||
|
id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to sync appearance preferences:', err)
|
console.warn('Failed to sync appearance preferences:', err)
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
getThemePreference,
|
getThemePreference,
|
||||||
setColorSchemePreference,
|
setColorSchemePreference,
|
||||||
setOwmApiKey,
|
setOwmApiKey,
|
||||||
setThemePreference
|
setThemePreference,
|
||||||
|
getAiAuthorized,
|
||||||
|
setAiAuthorized
|
||||||
} from './userPreferences.js'
|
} from './userPreferences.js'
|
||||||
|
|
||||||
const USER_ID = 'test-user-123'
|
const USER_ID = 'test-user-123'
|
||||||
@@ -58,4 +60,13 @@ describe('userPreferences', () => {
|
|||||||
expect(getThemePreference(USER_ID)).toBe('ocean')
|
expect(getThemePreference(USER_ID)).toBe('ocean')
|
||||||
expect(getColorSchemePreference(USER_ID)).toBe('light')
|
expect(getColorSchemePreference(USER_ID)).toBe('light')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('stores AI authorization preference per user', () => {
|
||||||
|
localStorage.setItem('active_userid', USER_ID)
|
||||||
|
expect(getAiAuthorized()).toBe(false)
|
||||||
|
setAiAuthorized(USER_ID, true)
|
||||||
|
expect(getAiAuthorized()).toBe(true)
|
||||||
|
expect(getAiAuthorized(USER_ID)).toBe(true)
|
||||||
|
expect(getAiAuthorized('other-user')).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -89,3 +89,20 @@ export function setOwmApiKey(userId: string, value: string): void {
|
|||||||
localStorage.removeItem(owmKey(userId))
|
localStorage.removeItem(owmKey(userId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function aiAuthorizedKey(userId: string): string {
|
||||||
|
return `user_pref_ai_authorized_${userId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAiAuthorized(userId?: string | null): boolean {
|
||||||
|
const id = resolveUserId(userId)
|
||||||
|
if (id) {
|
||||||
|
return localStorage.getItem(aiAuthorizedKey(id)) === 'true'
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAiAuthorized(userId: string, value: boolean): void {
|
||||||
|
localStorage.setItem(aiAuthorizedKey(userId), String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db } from './db.js'
|
import { db } from './db.js'
|
||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { encryptJson } from './crypto.js'
|
import { encryptJson, decryptJson } from './crypto.js'
|
||||||
import { syncLogbook } from './sync.js'
|
import { syncLogbook } from './sync.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ export async function saveEntryVoiceMemo(options: {
|
|||||||
mimeType: string
|
mimeType: string
|
||||||
durationSec: number
|
durationSec: number
|
||||||
caption?: string
|
caption?: string
|
||||||
|
transcribed?: boolean
|
||||||
analyticsContext?: string
|
analyticsContext?: string
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const {
|
const {
|
||||||
@@ -27,6 +28,7 @@ export async function saveEntryVoiceMemo(options: {
|
|||||||
mimeType,
|
mimeType,
|
||||||
durationSec,
|
durationSec,
|
||||||
caption = '',
|
caption = '',
|
||||||
|
transcribed = true,
|
||||||
analyticsContext = 'logbook'
|
analyticsContext = 'logbook'
|
||||||
} = options
|
} = options
|
||||||
const masterKey = await getEncryptionKey(logbookId)
|
const masterKey = await getEncryptionKey(logbookId)
|
||||||
@@ -35,7 +37,8 @@ export async function saveEntryVoiceMemo(options: {
|
|||||||
audio: audioDataUrl,
|
audio: audioDataUrl,
|
||||||
mimeType,
|
mimeType,
|
||||||
durationSec,
|
durationSec,
|
||||||
caption: caption.trim()
|
caption: caption.trim(),
|
||||||
|
transcribed: !!transcribed
|
||||||
}
|
}
|
||||||
|
|
||||||
const encrypted = await encryptJson(voicePayload, masterKey)
|
const encrypted = await encryptJson(voicePayload, masterKey)
|
||||||
@@ -98,3 +101,55 @@ export async function removeLastVoiceMemoForEntry(
|
|||||||
await deleteEntryVoiceMemo(logbookId, lastId)
|
await deleteEntryVoiceMemo(logbookId, lastId)
|
||||||
return lastId
|
return lastId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Updates an existing voice memo payload with a new transcript and sets transcribed: true. */
|
||||||
|
export async function updateVoiceMemoTranscript(
|
||||||
|
logbookId: string,
|
||||||
|
voiceId: string,
|
||||||
|
transcript: string
|
||||||
|
): Promise<void> {
|
||||||
|
const masterKey = await getEncryptionKey(logbookId)
|
||||||
|
const record = await db.voiceMemos.get(voiceId)
|
||||||
|
if (!record) throw new Error('Voice memo not found')
|
||||||
|
|
||||||
|
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
|
||||||
|
if (!decrypted) throw new Error('Failed to decrypt voice memo')
|
||||||
|
|
||||||
|
const manualCaption = decrypted.caption ? String(decrypted.caption).trim() : ''
|
||||||
|
const finalCaption = manualCaption
|
||||||
|
? `${manualCaption}\n(Transkript: ${transcript.trim()})`
|
||||||
|
: transcript.trim()
|
||||||
|
|
||||||
|
const updatedPayload = {
|
||||||
|
...decrypted,
|
||||||
|
caption: finalCaption,
|
||||||
|
transcribed: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const encrypted = await encryptJson(updatedPayload, masterKey)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
await db.voiceMemos.put({
|
||||||
|
...record,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.syncQueue.put({
|
||||||
|
action: 'update',
|
||||||
|
type: 'voiceMemo',
|
||||||
|
payloadId: voiceId,
|
||||||
|
logbookId,
|
||||||
|
data: JSON.stringify({
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
entryId: record.entryId
|
||||||
|
}),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,4 +69,51 @@ describe('fetchOpenWeatherCurrent', () => {
|
|||||||
|
|
||||||
expect(trackPlausibleEvent).not.toHaveBeenCalled()
|
expect(trackPlausibleEvent).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('throws UNAUTHORIZED when status is 401', async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
json: async () => ({ error: 'Unauthorized' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||||
|
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
|
||||||
|
expect(err).toBeInstanceOf(WeatherApiError)
|
||||||
|
expect((err as any).code).toBe('UNAUTHORIZED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws NOT_FOUND when status is 404', async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({ error: 'Not Found' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||||
|
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
|
||||||
|
expect(err).toBeInstanceOf(WeatherApiError)
|
||||||
|
expect((err as any).code).toBe('NOT_FOUND')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when status is 400', async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
json: async () => ({ error: 'Bad Request' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||||
|
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
|
||||||
|
expect(err).toBeInstanceOf(WeatherApiError)
|
||||||
|
expect((err as any).code).toBe('BAD_REQUEST')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when coordinates or query are missing', async () => {
|
||||||
|
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||||
|
const err = await fetchOpenWeatherCurrent({}).catch((e) => e)
|
||||||
|
expect(err).toBeInstanceOf(WeatherApiError)
|
||||||
|
expect((err as any).code).toBe('BAD_REQUEST')
|
||||||
|
expect(apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import {
|
|||||||
} from './analytics.js'
|
} from './analytics.js'
|
||||||
|
|
||||||
export class WeatherApiError extends Error {
|
export class WeatherApiError extends Error {
|
||||||
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED'
|
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST'
|
||||||
|
|
||||||
constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST' = 'REQUEST_FAILED'
|
||||||
|
) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'WeatherApiError'
|
this.name = 'WeatherApiError'
|
||||||
this.code = code
|
this.code = code
|
||||||
@@ -38,7 +41,7 @@ export async function fetchOpenWeatherCurrent(
|
|||||||
} else if (params.q?.trim()) {
|
} else if (params.q?.trim()) {
|
||||||
searchParams.set('q', params.q.trim())
|
searchParams.set('q', params.q.trim())
|
||||||
} else {
|
} else {
|
||||||
throw new WeatherApiError('lat/lon or location query required')
|
throw new WeatherApiError('lat/lon or location query required', 'BAD_REQUEST')
|
||||||
}
|
}
|
||||||
|
|
||||||
const userKey = getOwmApiKeyForActiveUser().trim()
|
const userKey = getOwmApiKeyForActiveUser().trim()
|
||||||
@@ -65,6 +68,15 @@ export async function fetchOpenWeatherCurrent(
|
|||||||
if (res.status === 503) {
|
if (res.status === 503) {
|
||||||
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
||||||
}
|
}
|
||||||
|
if (res.status === 401) {
|
||||||
|
throw new WeatherApiError('Invalid OpenWeatherMap API key', 'UNAUTHORIZED')
|
||||||
|
}
|
||||||
|
if (res.status === 404) {
|
||||||
|
throw new WeatherApiError('Location or coordinates not found', 'NOT_FOUND')
|
||||||
|
}
|
||||||
|
if (res.status === 400) {
|
||||||
|
throw new WeatherApiError('Invalid or missing location parameters', 'BAD_REQUEST')
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Server-Backup (Produktion)
|
||||||
|
|
||||||
|
Automatische und manuelle Sicherung von PostgreSQL, `.env`, `docker-compose.yml` und App-Code (Git-Archiv) auf der Prod-VM.
|
||||||
|
|
||||||
|
**Staging:** Kein automatisches Backup — Daten sind bewusst wegwerfbar. Deploy via `update-remotes.sh -dest stage` legt kein Backup an. Zum manuellen Testen auf Staging: `-dest stage` (oder Auto-Fallback, wenn nur `daagbox-staging-db` läuft).
|
||||||
|
|
||||||
|
## Was wird gesichert?
|
||||||
|
|
||||||
|
| Inhalt | Beschreibung |
|
||||||
|
|--------|--------------|
|
||||||
|
| `database.sql.gz` | `pg_dump` aus dem laufenden DB-Container |
|
||||||
|
| `.env` | Server-Secrets (Sessions, DB-Passwort, VAPID, …) |
|
||||||
|
| `docker-compose.yml` | Aktive Compose-Datei |
|
||||||
|
| `app.tar.gz` | `git archive HEAD` — Code-Snapshot |
|
||||||
|
| `manifest.json` | Timestamp, Git-Tag, SHA, Grund (`cron` / `pre-deploy` / `manual`) |
|
||||||
|
|
||||||
|
Backups liegen in `/var/backups/kapteins-daagbok/` (mode 700, root-only). Es werden **maximal 5** Archive aufbewahrt.
|
||||||
|
|
||||||
|
## Einmalige Einrichtung (Prod-Server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@10.0.0.25
|
||||||
|
mkdir -p /var/backups/kapteins-daagbok
|
||||||
|
chmod 700 /var/backups/kapteins-daagbok
|
||||||
|
cd /opt/kapteins-daagbok
|
||||||
|
git pull
|
||||||
|
chmod +x scripts/backup.sh scripts/restore-backup.sh
|
||||||
|
./scripts/backup.sh --reason manual
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manuell sichern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/kapteins-daagbok
|
||||||
|
./scripts/backup.sh
|
||||||
|
./scripts/backup.sh --reason manual --dry-run # Vorschau ohne Schreiben
|
||||||
|
```
|
||||||
|
|
||||||
|
### Staging (manueller Test)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/kapteins-daagbok-staging
|
||||||
|
./scripts/backup.sh -dest stage --reason manual
|
||||||
|
# oder: Auto-Fallback, wenn nur daagbox-staging-db läuft
|
||||||
|
./scripts/backup.sh --reason manual
|
||||||
|
```
|
||||||
|
|
||||||
|
## Crontab (unbeaufsichtigt)
|
||||||
|
|
||||||
|
Beispiel: [`scripts/crontab.prod.example`](../../scripts/crontab.prod.example)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
# Zeile einfügen:
|
||||||
|
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-Deploy-Backup
|
||||||
|
|
||||||
|
Bei `./scripts/update-remotes.sh -dest prod` wird **vor** dem Git-Sync auf dem Server automatisch ein Backup mit Tag `v{VERSION}-predeploy` erstellt. Schlägt das Backup fehl, wird das Deploy abgebrochen.
|
||||||
|
|
||||||
|
Staging-Deploys (`-dest stage`) erstellen **kein** Backup.
|
||||||
|
|
||||||
|
## Wiederherstellen
|
||||||
|
|
||||||
|
Verfügbare Backups anzeigen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --list
|
||||||
|
```
|
||||||
|
|
||||||
|
Vollständige Wiederherstellung (DB + `.env`, optional Git-Tag checkout):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_YYYYMMDD-HHMMSS_vX.Y.Z.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Nur Datenbank:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --restore PATH --db-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Nur `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --restore PATH --env-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne Rückfragen (Notfall):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --restore PATH --full --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vor Passwort-Rotation
|
||||||
|
|
||||||
|
Vor [`rotate-postgres-password.sh`](../../scripts/rotate-postgres-password.sh) ein Backup anlegen — siehe auch [postgres-password.md](postgres-password.md):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh --reason manual
|
||||||
|
```
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Prod (default) | Staging (`-dest stage`) |
|
||||||
|
|----------|----------------|-------------------------|
|
||||||
|
| `COMPOSE_FILE` | `docker-compose.yml` | `docker-compose.staging.yml` |
|
||||||
|
| `DB_CONTAINER` | `daagbox-prod-db` | `daagbox-staging-db` |
|
||||||
|
| `BACKUP_DIR` | `/var/backups/kapteins-daagbok` | gleich |
|
||||||
|
| `RETENTION` | `5` | `5` |
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Empfohlene Schritte
|
## Empfohlene Schritte
|
||||||
|
|
||||||
1. **Backup/Snapshot** (hast du laut Vorgabe).
|
1. **Backup/Snapshot** — auf dem Server: `./scripts/backup.sh --reason manual` (Details: [backup.md](backup.md)).
|
||||||
2. Auf dem Server im Repo:
|
2. Auf dem Server im Repo:
|
||||||
```bash
|
```bash
|
||||||
cd /opt/kapteins-daagbok
|
cd /opt/kapteins-daagbok
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ cd server && npm test
|
|||||||
|
|
||||||
## Nach erfolgreichem Check
|
## Nach erfolgreichem Check
|
||||||
|
|
||||||
[`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy).
|
[`scripts/update-remotes.sh`](../../scripts/update-remotes.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung bei Prod, vor dem SSH-Deploy).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/update-prod.sh -dest prod
|
./scripts/update-remotes.sh -dest prod
|
||||||
```
|
```
|
||||||
|
|
||||||
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh -dest prod`
|
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest prod`
|
||||||
|
|
||||||
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
|
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Staging läuft auf **VM3** (`10.0.0.27`) unter **https://staging.kapteins-daagbo
|
|||||||
| Host | `10.0.0.27` | `10.0.0.25` |
|
| Host | `10.0.0.27` | `10.0.0.25` |
|
||||||
| Verzeichnis | `/opt/kapteins-daagbok-staging` | `/opt/kapteins-daagbok` |
|
| Verzeichnis | `/opt/kapteins-daagbok-staging` | `/opt/kapteins-daagbok` |
|
||||||
| Compose | `docker-compose.staging.yml` | `docker-compose.yml` |
|
| Compose | `docker-compose.staging.yml` | `docker-compose.yml` |
|
||||||
| Deploy-Skript | `./scripts/update-prod.sh -dest stage` | `./scripts/update-prod.sh -dest prod` |
|
| Deploy-Skript | `./scripts/update-remotes.sh -dest stage` | `./scripts/update-remotes.sh -dest prod` |
|
||||||
| Release-Tag | nein | ja (`v*`) |
|
| Release-Tag | nein | ja (`v*`) |
|
||||||
| Datenbank-Volume | `daagbox-staging-pgdata` | `daagbox-prod-pgdata` |
|
| Datenbank-Volume | `daagbox-staging-pgdata` | `daagbox-prod-pgdata` |
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ Optional: `VAPID_*`, `OpenWeatherMapAPIKey`, `OpenRouterAPIKey`, `ADMIN_USER_IDS
|
|||||||
Führt `npm run check` aus, dann SSH-Deploy ohne Release-Tag:
|
Führt `npm run check` aus, dann SSH-Deploy ohne Release-Tag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/update-prod.sh -dest stage
|
./scripts/update-remotes.sh -dest stage
|
||||||
```
|
```
|
||||||
|
|
||||||
Konfiguration via Umgebungsvariablen:
|
Konfiguration via Umgebungsvariablen:
|
||||||
@@ -69,10 +69,10 @@ Konfiguration via Umgebungsvariablen:
|
|||||||
REMOTE_HOST=10.0.0.27 \
|
REMOTE_HOST=10.0.0.27 \
|
||||||
REMOTE_DIR=/opt/kapteins-daagbok-staging \
|
REMOTE_DIR=/opt/kapteins-daagbok-staging \
|
||||||
DEPLOY_BRANCH=master \
|
DEPLOY_BRANCH=master \
|
||||||
./scripts/update-prod.sh -dest stage
|
./scripts/update-remotes.sh -dest stage
|
||||||
```
|
```
|
||||||
|
|
||||||
Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh -dest stage`
|
Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest stage`
|
||||||
|
|
||||||
## NPM (VM1)
|
## NPM (VM1)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 621 KiB After Width: | Height: | Size: 359 KiB |
@@ -47,6 +47,7 @@ Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der ak
|
|||||||
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
||||||
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
||||||
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
|
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
|
||||||
|
| Voice Memo Transcribed | Sprachmemo transkribiert (`LiveLogView.tsx`, `EventRemarksCell.tsx`) | `status`: `success` \| `failed`, `mode`: `auto` (beim Speichern) \| `manual` (nachträglich) |
|
||||||
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
|
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
|
||||||
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
|
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
|
||||||
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
|
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
|
||||||
@@ -161,6 +162,7 @@ trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
|
|||||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
|
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
|
||||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
|
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
|
||||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, { status: 'success', mode: 'auto' })
|
||||||
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
|
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
|
||||||
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
|
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
|
||||||
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
|
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
|
||||||
|
|||||||
Executable
+255
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create a local backup of PostgreSQL, .env, docker-compose and app (git archive).
|
||||||
|
#
|
||||||
|
# Run on the server in repo root (/opt/kapteins-daagbok on production).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/backup.sh
|
||||||
|
# ./scripts/backup.sh -dest stage # Staging-Container (daagbox-staging-db)
|
||||||
|
# ./scripts/backup.sh --reason cron
|
||||||
|
# ./scripts/backup.sh --reason pre-deploy --tag v0.1.1.20
|
||||||
|
# ./scripts/backup.sh --dry-run
|
||||||
|
#
|
||||||
|
# Environment overrides:
|
||||||
|
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, RETENTION, ENV_FILE
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
|
||||||
|
ENV_FILE="${ENV_FILE:-.env}"
|
||||||
|
RETENTION="${RETENTION:-5}"
|
||||||
|
DEST="prod"
|
||||||
|
REASON="manual"
|
||||||
|
EXPLICIT_TAG=""
|
||||||
|
DRY_RUN=0
|
||||||
|
COMPOSE_FILE=""
|
||||||
|
DB_CONTAINER=""
|
||||||
|
|
||||||
|
apply_dest_config() {
|
||||||
|
local dest="$1"
|
||||||
|
local force="${2:-0}"
|
||||||
|
if [[ "$dest" == "stage" ]]; then
|
||||||
|
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||||
|
COMPOSE_FILE="docker-compose.staging.yml"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||||
|
DB_CONTAINER="daagbox-staging-db"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||||
|
DB_CONTAINER="daagbox-prod-db"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,14p' "$0"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " -dest prod|stage Target environment (default: prod)"
|
||||||
|
echo " --reason cron|pre-deploy|manual Backup trigger (default: manual)"
|
||||||
|
echo " --tag TAG Git tag label (e.g. v0.1.1.20 for pre-deploy)"
|
||||||
|
echo " --dry-run Show actions without writing backup"
|
||||||
|
echo " -h, --help Show this help"
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-dest)
|
||||||
|
DEST="${2:?-dest requires an argument}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-dest=*)
|
||||||
|
DEST="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--reason)
|
||||||
|
REASON="${2:?--reason requires an argument}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--tag)
|
||||||
|
EXPLICIT_TAG="${2:?--tag requires an argument}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$DEST" in
|
||||||
|
prod|stage) ;;
|
||||||
|
*)
|
||||||
|
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
apply_dest_config "$DEST"
|
||||||
|
|
||||||
|
case "$REASON" in
|
||||||
|
cron|pre-deploy|manual) ;;
|
||||||
|
*)
|
||||||
|
echo "Error: invalid --reason '$REASON' (use cron, pre-deploy, or manual)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||||
|
echo "Error: $COMPOSE_FILE not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||||
|
GIT_TAG="$EXPLICIT_TAG"
|
||||||
|
if [ -z "$GIT_TAG" ]; then
|
||||||
|
GIT_TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [ -z "$GIT_TAG" ] && [ -f VERSION ]; then
|
||||||
|
GIT_TAG="v$(tr -d '[:space:]' < VERSION)"
|
||||||
|
fi
|
||||||
|
if [ -z "$GIT_TAG" ]; then
|
||||||
|
GIT_TAG="unknown"
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_VERSION="$(tr -d '[:space:]' < VERSION 2>/dev/null || echo unknown)"
|
||||||
|
TAG_SLUG="${GIT_TAG}"
|
||||||
|
if [ "$REASON" = "pre-deploy" ]; then
|
||||||
|
TAG_SLUG="${GIT_TAG}-predeploy"
|
||||||
|
fi
|
||||||
|
TAG_SLUG="${TAG_SLUG//\//-}"
|
||||||
|
|
||||||
|
ARCHIVE_NAME="kapteins-daagbok_${TIMESTAMP}_${TAG_SLUG}.tar.gz"
|
||||||
|
ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}"
|
||||||
|
|
||||||
|
echo "Backup: reason=$REASON tag=$GIT_TAG sha=${GIT_SHA:0:8} → $ARCHIVE_PATH"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
|
echo "[dry-run] Would dump database from $DB_CONTAINER"
|
||||||
|
echo "[dry-run] Would copy $ENV_FILE and $COMPOSE_FILE"
|
||||||
|
echo "[dry-run] Would create git archive"
|
||||||
|
echo "[dry-run] Would write manifest and pack to $ARCHIVE_PATH"
|
||||||
|
echo "[dry-run] Would apply retention (keep $RETENTION)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Error: $ENV_FILE not found (run from repo root)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||||
|
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
|
||||||
|
|
||||||
|
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
|
||||||
|
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
|
||||||
|
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
|
||||||
|
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
|
||||||
|
apply_dest_config stage 1
|
||||||
|
DEST="stage"
|
||||||
|
else
|
||||||
|
echo "Error: DB container '$DB_CONTAINER' not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(docker inspect -f '{{.State.Running}}' "$DB_CONTAINER")" != "true" ]; then
|
||||||
|
echo "Error: DB container '$DB_CONTAINER' is not running" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
chmod 700 "$BACKUP_DIR"
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "Dumping PostgreSQL ($POSTGRES_DB)..."
|
||||||
|
export PGPASSWORD="$POSTGRES_PASSWORD"
|
||||||
|
if ! docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
|
||||||
|
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-acl \
|
||||||
|
| gzip > "$WORK_DIR/database.sql.gz"; then
|
||||||
|
echo "Error: pg_dump failed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
unset PGPASSWORD
|
||||||
|
|
||||||
|
cp "$ENV_FILE" "$WORK_DIR/.env"
|
||||||
|
chmod 600 "$WORK_DIR/.env"
|
||||||
|
cp "$COMPOSE_FILE" "$WORK_DIR/docker-compose.yml"
|
||||||
|
|
||||||
|
echo "Creating app snapshot (git archive)..."
|
||||||
|
if git archive --format=tar HEAD | gzip > "$WORK_DIR/app.tar.gz"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
echo "Warning: git archive failed — backup continues without app.tar.gz" >&2
|
||||||
|
rm -f "$WORK_DIR/app.tar.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$WORK_DIR/manifest.json" <<PY
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"local_timestamp": "${TIMESTAMP}",
|
||||||
|
"destination": "${DEST}",
|
||||||
|
"reason": "${REASON}",
|
||||||
|
"git_tag": "${GIT_TAG}",
|
||||||
|
"git_sha": "${GIT_SHA}",
|
||||||
|
"app_version": "${APP_VERSION}",
|
||||||
|
"compose_file": "${COMPOSE_FILE}",
|
||||||
|
"db_container": "${DB_CONTAINER}",
|
||||||
|
"postgres_db": "${POSTGRES_DB}",
|
||||||
|
"hostname": socket.gethostname(),
|
||||||
|
"archive_name": "${ARCHIVE_NAME}",
|
||||||
|
}
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8") as f:
|
||||||
|
json.dump(manifest, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "Packing backup archive..."
|
||||||
|
tar -czf "$ARCHIVE_PATH" -C "$WORK_DIR" \
|
||||||
|
manifest.json database.sql.gz .env docker-compose.yml \
|
||||||
|
$( [ -f "$WORK_DIR/app.tar.gz" ] && echo app.tar.gz )
|
||||||
|
chmod 600 "$ARCHIVE_PATH"
|
||||||
|
|
||||||
|
echo "Applying retention (keep last $RETENTION backups)..."
|
||||||
|
mapfile -t ALL_BACKUPS < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
|
||||||
|
if [ "${#ALL_BACKUPS[@]}" -gt "$RETENTION" ]; then
|
||||||
|
for ((i = RETENTION; i < ${#ALL_BACKUPS[@]}; i++)); do
|
||||||
|
echo "Removing old backup: ${ALL_BACKUPS[$i]}"
|
||||||
|
rm -f "${ALL_BACKUPS[$i]}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Backup complete: $ARCHIVE_PATH"
|
||||||
|
echo "$ARCHIVE_PATH"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Kapteins Daagbok — Production backup cron (install on 10.0.0.25)
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# crontab -e
|
||||||
|
# (paste the line below)
|
||||||
|
#
|
||||||
|
# Ensure log directory exists:
|
||||||
|
# touch /var/log/kapteins-backup.log && chmod 600 /var/log/kapteins-backup.log
|
||||||
|
|
||||||
|
# Daily backup at 03:00 UTC — keeps last 5 in /var/backups/kapteins-daagbok/
|
||||||
|
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
|
||||||
Executable
+345
@@ -0,0 +1,345 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Restore server backup created by scripts/backup.sh
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/restore-backup.sh --list
|
||||||
|
# ./scripts/restore-backup.sh -dest stage --restore PATH
|
||||||
|
# ./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_....tar.gz
|
||||||
|
#
|
||||||
|
# Environment overrides:
|
||||||
|
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, BACKEND_CONTAINER, ENV_FILE
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
|
||||||
|
ENV_FILE="${ENV_FILE:-.env}"
|
||||||
|
MAX_WAIT=90
|
||||||
|
DEST="prod"
|
||||||
|
COMPOSE_FILE=""
|
||||||
|
DB_CONTAINER=""
|
||||||
|
BACKEND_CONTAINER=""
|
||||||
|
|
||||||
|
apply_dest_config() {
|
||||||
|
local dest="$1"
|
||||||
|
local force="${2:-0}"
|
||||||
|
if [[ "$dest" == "stage" ]]; then
|
||||||
|
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||||
|
COMPOSE_FILE="docker-compose.staging.yml"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||||
|
DB_CONTAINER="daagbox-staging-db"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
|
||||||
|
BACKEND_CONTAINER="daagbox-staging-backend"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||||
|
DB_CONTAINER="daagbox-prod-db"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
|
||||||
|
BACKEND_CONTAINER="daagbox-prod-backend"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
MODE="full"
|
||||||
|
RESTORE_PATH=""
|
||||||
|
LIST=0
|
||||||
|
ASSUME_YES=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,10p' "$0"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " -dest prod|stage Target environment (default: prod)"
|
||||||
|
echo " --list List available backups"
|
||||||
|
echo " --restore PATH Backup archive to restore"
|
||||||
|
echo " --full Restore DB + .env (default)"
|
||||||
|
echo " --db-only Restore database only"
|
||||||
|
echo " --env-only Restore .env only"
|
||||||
|
echo " --yes Skip confirmation prompts"
|
||||||
|
echo " -h, --help Show this help"
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
local prompt="$1"
|
||||||
|
if [ "$ASSUME_YES" -eq 1 ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
read -r -p "$prompt [y/N] " answer
|
||||||
|
[[ "$answer" =~ ^[yY]$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-dest)
|
||||||
|
DEST="${2:?-dest requires an argument}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-dest=*)
|
||||||
|
DEST="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--list)
|
||||||
|
LIST=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--restore)
|
||||||
|
RESTORE_PATH="${2:?--restore requires a path}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--full)
|
||||||
|
MODE="full"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--db-only)
|
||||||
|
MODE="db-only"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--env-only)
|
||||||
|
MODE="env-only"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--yes)
|
||||||
|
ASSUME_YES=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$DEST" in
|
||||||
|
prod|stage) ;;
|
||||||
|
*)
|
||||||
|
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
apply_dest_config "$DEST"
|
||||||
|
|
||||||
|
list_backups() {
|
||||||
|
if [ ! -d "$BACKUP_DIR" ]; then
|
||||||
|
echo "No backup directory: $BACKUP_DIR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local found=0
|
||||||
|
while IFS= read -r archive; do
|
||||||
|
found=1
|
||||||
|
echo "=== $archive ==="
|
||||||
|
tar -xOf "$archive" manifest.json 2>/dev/null | python3 -m json.tool 2>/dev/null || echo "(no manifest)"
|
||||||
|
echo ""
|
||||||
|
done < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ "$found" -eq 0 ]; then
|
||||||
|
echo "No backups found in $BACKUP_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$LIST" -eq 1 ]; then
|
||||||
|
list_backups
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$RESTORE_PATH" ]; then
|
||||||
|
echo "Error: --restore PATH or --list required" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$RESTORE_PATH" ]; then
|
||||||
|
echo "Error: backup archive not found: $RESTORE_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "Extracting $RESTORE_PATH..."
|
||||||
|
tar -xzf "$RESTORE_PATH" -C "$WORK_DIR"
|
||||||
|
|
||||||
|
if [ ! -f "$WORK_DIR/manifest.json" ]; then
|
||||||
|
echo "Error: manifest.json missing in backup archive" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
MANIFEST="$WORK_DIR/manifest.json"
|
||||||
|
echo "Backup manifest:"
|
||||||
|
python3 -m json.tool "$MANIFEST"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read_manifest_field() {
|
||||||
|
python3 -c "import json; print(json.load(open('$MANIFEST')).get('$1', '') or '')"
|
||||||
|
}
|
||||||
|
|
||||||
|
MANIFEST_COMPOSE="$(read_manifest_field compose_file)"
|
||||||
|
MANIFEST_DB="$(read_manifest_field db_container)"
|
||||||
|
MANIFEST_DEST="$(read_manifest_field destination)"
|
||||||
|
|
||||||
|
if [ -n "$MANIFEST_COMPOSE" ]; then
|
||||||
|
COMPOSE_FILE="$MANIFEST_COMPOSE"
|
||||||
|
fi
|
||||||
|
if [ -n "$MANIFEST_DB" ]; then
|
||||||
|
DB_CONTAINER="$MANIFEST_DB"
|
||||||
|
fi
|
||||||
|
if [ -n "$MANIFEST_DEST" ]; then
|
||||||
|
if [[ "$MANIFEST_DEST" == "stage" ]]; then
|
||||||
|
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-staging-backend}"
|
||||||
|
else
|
||||||
|
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
|
||||||
|
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
|
||||||
|
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
|
||||||
|
apply_dest_config stage 1
|
||||||
|
DEST="stage"
|
||||||
|
else
|
||||||
|
echo "Error: DB container '$DB_CONTAINER' not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
GIT_TAG="$(read_manifest_field git_tag)"
|
||||||
|
GIT_SHA="$(read_manifest_field git_sha)"
|
||||||
|
BACKUP_TS="$(read_manifest_field local_timestamp)"
|
||||||
|
|
||||||
|
if ! confirm "Restore backup from $BACKUP_TS (tag: $GIT_TAG)? Mode: $MODE"; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
restore_env() {
|
||||||
|
if [ ! -f "$WORK_DIR/.env" ]; then
|
||||||
|
echo "Error: .env missing in backup archive" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
BAK="${ENV_FILE}.bak-restore.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$ENV_FILE" "$BAK"
|
||||||
|
echo "Current $ENV_FILE saved to $BAK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$WORK_DIR/.env" "$ENV_FILE"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
echo "Restored $ENV_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_db() {
|
||||||
|
if [ ! -f "$WORK_DIR/database.sql.gz" ]; then
|
||||||
|
echo "Error: database.sql.gz missing in backup archive" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Error: $ENV_FILE required for database restore" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||||
|
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
|
||||||
|
|
||||||
|
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
|
||||||
|
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
|
||||||
|
echo "Error: DB container '$DB_CONTAINER' not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Stopping backend before database restore..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" stop backend || true
|
||||||
|
|
||||||
|
echo "Resetting public schema..."
|
||||||
|
export PGPASSWORD="$POSTGRES_PASSWORD"
|
||||||
|
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
|
||||||
|
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 <<SQL
|
||||||
|
DROP SCHEMA IF EXISTS public CASCADE;
|
||||||
|
CREATE SCHEMA public;
|
||||||
|
GRANT ALL ON SCHEMA public TO "${POSTGRES_USER}";
|
||||||
|
GRANT ALL ON SCHEMA public TO public;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Importing database dump..."
|
||||||
|
gunzip -c "$WORK_DIR/database.sql.gz" | docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
|
||||||
|
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1
|
||||||
|
unset PGPASSWORD
|
||||||
|
|
||||||
|
echo "Database restore complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_healthy() {
|
||||||
|
echo "Starting stack and waiting for health..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
|
||||||
|
local counter=0
|
||||||
|
while [ "$counter" -lt "$MAX_WAIT" ]; do
|
||||||
|
local status
|
||||||
|
status="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)"
|
||||||
|
if [ "$status" = "healthy" ]; then
|
||||||
|
echo "Backend is healthy."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if curl -sf "http://127.0.0.1/api/health" | grep -q '"status":"ok"'; then
|
||||||
|
echo "API health check OK."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
counter=$((counter + 1))
|
||||||
|
printf "."
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Warning: backend did not become healthy in time." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
env-only)
|
||||||
|
restore_env
|
||||||
|
;;
|
||||||
|
db-only)
|
||||||
|
restore_db
|
||||||
|
wait_for_healthy || exit 1
|
||||||
|
;;
|
||||||
|
full)
|
||||||
|
restore_env
|
||||||
|
restore_db
|
||||||
|
wait_for_healthy || exit 1
|
||||||
|
if [ -f "$WORK_DIR/app.tar.gz" ] && [ "$GIT_TAG" != "unknown" ]; then
|
||||||
|
if confirm "Checkout app code at tag $GIT_TAG? (git fetch + checkout)"; then
|
||||||
|
git fetch --tags origin
|
||||||
|
git checkout "$GIT_TAG"
|
||||||
|
echo "Checked out $GIT_TAG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: unknown mode $MODE" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "Restore finished (mode: $MODE, tag: $GIT_TAG, sha: ${GIT_SHA:0:8})."
|
||||||
@@ -81,7 +81,7 @@ require_node_toolchain() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo "On the production host, prefer updating the running stack:"
|
echo "On the production host, prefer updating the running stack:"
|
||||||
echo " docker compose -f docker-compose.yml up -d --build"
|
echo " docker compose -f docker-compose.yml up -d --build"
|
||||||
echo " # or from your workstation: ./scripts/update-prod.sh"
|
echo " # or from your workstation: ./scripts/update-remotes.sh -dest prod"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ Environment overrides (optional):
|
|||||||
DEPLOY_BRANCH (stage only, default: master)
|
DEPLOY_BRANCH (stage only, default: master)
|
||||||
SKIP_PREDEPLOY_CHECK=1
|
SKIP_PREDEPLOY_CHECK=1
|
||||||
|
|
||||||
|
Local repo must be clean and match origin before deploy (git fetch + compare HEAD).
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") -dest prod
|
$(basename "$0") -dest prod
|
||||||
$(basename "$0") -dest stage
|
$(basename "$0") -dest stage
|
||||||
@@ -158,6 +160,66 @@ ensure_clean_git_tree() {
|
|||||||
git commit -m "$commit_message"
|
git commit -m "$commit_message"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_local_sync_with_origin() {
|
||||||
|
local branch="$1"
|
||||||
|
local local_sha origin_sha current_branch
|
||||||
|
|
||||||
|
if [ -z "$branch" ]; then
|
||||||
|
echo "Error: deploy branch is not set." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo "Error: Working tree is not clean. Commit or stash changes before deploying." >&2
|
||||||
|
git status --short
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
current_branch="$(git branch --show-current)"
|
||||||
|
if [ -z "$current_branch" ]; then
|
||||||
|
echo "Error: Detached HEAD — checkout branch '$branch' before deploying." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$current_branch" != "$branch" ]; then
|
||||||
|
echo "Error: On branch '$current_branch', expected '$branch'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Syncing with origin..."
|
||||||
|
git fetch --tags origin
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: git fetch origin failed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then
|
||||||
|
echo "Error: origin/${branch} does not exist." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local_sha="$(git rev-parse HEAD)"
|
||||||
|
origin_sha="$(git rev-parse "origin/${branch}")"
|
||||||
|
|
||||||
|
if [ "$local_sha" = "$origin_sha" ]; then
|
||||||
|
echo "Local branch '$branch' matches origin/${branch} ($(git rev-parse --short HEAD))."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Error: Local '$branch' is not in sync with origin/${branch}." >&2
|
||||||
|
echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2
|
||||||
|
echo " origin: $(git rev-parse --short "origin/${branch}") $(git log -1 --format='%s' "origin/${branch}")" >&2
|
||||||
|
|
||||||
|
if git merge-base --is-ancestor "$local_sha" "origin/${branch}" 2>/dev/null; then
|
||||||
|
echo "Hint: run 'git pull' to fast-forward." >&2
|
||||||
|
elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then
|
||||||
|
echo "Hint: run 'git push origin ${branch}' before deploying." >&2
|
||||||
|
else
|
||||||
|
echo "Hint: branches have diverged — reconcile manually before deploying." >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
prepare_release() {
|
prepare_release() {
|
||||||
local current_version release_version next_version tag_name
|
local current_version release_version next_version tag_name
|
||||||
|
|
||||||
@@ -199,7 +261,9 @@ prepare_release() {
|
|||||||
|
|
||||||
if [[ "$DEST" == "prod" ]]; then
|
if [[ "$DEST" == "prod" ]]; then
|
||||||
prepare_release
|
prepare_release
|
||||||
|
ensure_local_sync_with_origin "$(git branch --show-current)"
|
||||||
else
|
else
|
||||||
|
ensure_local_sync_with_origin "$DEPLOY_BRANCH"
|
||||||
APP_VERSION="$(read_current_version)"
|
APP_VERSION="$(read_current_version)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -227,7 +291,7 @@ MAX_WAIT="$4"
|
|||||||
APP_URL="$5"
|
APP_URL="$5"
|
||||||
APP_VERSION="$6"
|
APP_VERSION="$6"
|
||||||
DEST="$7"
|
DEST="$7"
|
||||||
DEPLOY_BRANCH="$8"
|
DEPLOY_BRANCH="${8:-}"
|
||||||
|
|
||||||
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
|
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
|
||||||
|
|
||||||
@@ -235,6 +299,18 @@ if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
|
|||||||
echo "Warning: Local changes on deployment host will be discarded."
|
echo "Warning: Local changes on deployment host will be discarded."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$DEST" == "prod" ]]; then
|
||||||
|
echo "Creating pre-deploy backup..."
|
||||||
|
if [ -x "./scripts/backup.sh" ]; then
|
||||||
|
if ! ./scripts/backup.sh --reason pre-deploy --tag "v${APP_VERSION}"; then
|
||||||
|
echo "Error: Pre-deploy backup failed. Aborting update."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Warning: scripts/backup.sh not found or not executable — skipping backup."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$DEST" == "stage" ]]; then
|
if [[ "$DEST" == "stage" ]]; then
|
||||||
echo "Syncing repository from origin/${DEPLOY_BRANCH}..."
|
echo "Syncing repository from origin/${DEPLOY_BRANCH}..."
|
||||||
git fetch origin
|
git fetch origin
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Backward-compatible wrapper — prefer: ./scripts/update-prod.sh -dest stage
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
-dest|-dest=*)
|
|
||||||
echo "Error: update-staging.sh always deploys to staging." >&2
|
|
||||||
echo " Use ./scripts/update-prod.sh -dest prod for production." >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
exec "$SCRIPT_DIR/update-prod.sh" -dest stage "$@"
|
|
||||||
@@ -55,6 +55,7 @@ model UserAppearancePrefs {
|
|||||||
userId String @id
|
userId String @id
|
||||||
theme String @default("auto")
|
theme String @default("auto")
|
||||||
colorScheme String @default("auto")
|
colorScheme String @default("auto")
|
||||||
|
aiAuthorized Boolean @default(false)
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -59,4 +59,12 @@ describe('API smoke', () => {
|
|||||||
expect(res.status).toBe(401)
|
expect(res.status).toBe(401)
|
||||||
expect(res.body.error).toMatch(/Unauthorized/i)
|
expect(res.body.error).toMatch(/Unauthorized/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('POST /api/ai/transcribe requires session', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/ai/transcribe')
|
||||||
|
.send({ audioDataUrl: 'data:audio/webm;base64,abcdef' })
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toMatch(/Unauthorized/i)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
|
|||||||
prisma.aiSummaryUsage.count()
|
prisma.aiSummaryUsage.count()
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const rawDbSize = await prisma.$queryRaw<[{ size: string }]>`
|
||||||
|
SELECT pg_database_size(current_database())::text as size
|
||||||
|
`
|
||||||
|
const dbSize = Number(rawDbSize[0]?.size || '0')
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
totalUsers,
|
totalUsers,
|
||||||
totalLogbooks,
|
totalLogbooks,
|
||||||
@@ -31,7 +36,8 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
|
|||||||
totalGpsTracks,
|
totalGpsTracks,
|
||||||
totalCollaborations,
|
totalCollaborations,
|
||||||
totalInvitations,
|
totalInvitations,
|
||||||
aiSummaryEntries
|
aiSummaryEntries,
|
||||||
|
dbSize
|
||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('admin/summary error', error)
|
console.error('admin/summary error', error)
|
||||||
@@ -91,7 +97,7 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
|
|||||||
const since = new Date()
|
const since = new Date()
|
||||||
since.setUTCDate(since.getUTCDate() - windowDays)
|
since.setUTCDate(since.getUTCDate() - windowDays)
|
||||||
|
|
||||||
const [users, logbooks, photos] = await Promise.all([
|
const [users, logbooks, photos, dbSizeRaw, photosSize, voiceSize, tracksSize, entriesSize] = await Promise.all([
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
where: { createdAt: { gte: since } },
|
where: { createdAt: { gte: since } },
|
||||||
select: { createdAt: true }
|
select: { createdAt: true }
|
||||||
@@ -103,9 +109,72 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
|
|||||||
prisma.photoPayload.findMany({
|
prisma.photoPayload.findMany({
|
||||||
where: { updatedAt: { gte: since } },
|
where: { updatedAt: { gte: since } },
|
||||||
select: { updatedAt: true }
|
select: { updatedAt: true }
|
||||||
|
}),
|
||||||
|
prisma.$queryRaw<[{ size: string }]>`
|
||||||
|
SELECT pg_database_size(current_database())::text as size
|
||||||
|
`,
|
||||||
|
prisma.photoPayload.findMany({
|
||||||
|
select: { updatedAt: true, encryptedData: true }
|
||||||
|
}),
|
||||||
|
prisma.voiceMemoPayload.findMany({
|
||||||
|
select: { updatedAt: true, encryptedData: true }
|
||||||
|
}),
|
||||||
|
prisma.gpsTrackPayload.findMany({
|
||||||
|
select: { updatedAt: true, encryptedData: true }
|
||||||
|
}),
|
||||||
|
prisma.entryPayload.findMany({
|
||||||
|
select: { updatedAt: true, encryptedData: true }
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const dbSizeVal = Number(dbSizeRaw[0]?.size || '0')
|
||||||
|
|
||||||
|
const payloads: { date: Date; size: number }[] = []
|
||||||
|
for (const p of photosSize) {
|
||||||
|
payloads.push({ date: p.updatedAt, size: p.encryptedData.length })
|
||||||
|
}
|
||||||
|
for (const v of voiceSize) {
|
||||||
|
payloads.push({ date: v.updatedAt, size: v.encryptedData.length })
|
||||||
|
}
|
||||||
|
for (const g of tracksSize) {
|
||||||
|
payloads.push({ date: g.updatedAt, size: g.encryptedData.length })
|
||||||
|
}
|
||||||
|
for (const e of entriesSize) {
|
||||||
|
payloads.push({ date: e.updatedAt, size: e.encryptedData.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPayloadsSize = payloads.reduce((acc, p) => acc + p.size, 0)
|
||||||
|
const baseDbSize = Math.max(0, dbSizeVal - totalPayloadsSize)
|
||||||
|
|
||||||
|
payloads.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||||
|
|
||||||
|
// Generate complete list of date keys for the window
|
||||||
|
const dateKeys: string[] = []
|
||||||
|
const current = new Date(since)
|
||||||
|
const todayStr = bucketDate(new Date(), bucket)
|
||||||
|
while (true) {
|
||||||
|
const key = bucketDate(current, bucket)
|
||||||
|
if (!dateKeys.includes(key)) {
|
||||||
|
dateKeys.push(key)
|
||||||
|
}
|
||||||
|
if (key >= todayStr) break
|
||||||
|
current.setUTCDate(current.getUTCDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbSizePoints = dateKeys.map((key) => {
|
||||||
|
let sizeSum = 0
|
||||||
|
for (const p of payloads) {
|
||||||
|
if (bucketDate(p.date, bucket) <= key) {
|
||||||
|
sizeSum += p.size
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const totalBytes = baseDbSize + sizeSum
|
||||||
|
const sizeInMb = Math.round((totalBytes / (1024 * 1024)) * 10) / 10
|
||||||
|
return { date: key, count: sizeInMb }
|
||||||
|
})
|
||||||
|
|
||||||
function aggregate(dates: Date[], metric: string): TimeSeries {
|
function aggregate(dates: Date[], metric: string): TimeSeries {
|
||||||
const map = new Map<string, number>()
|
const map = new Map<string, number>()
|
||||||
for (const d of dates) {
|
for (const d of dates) {
|
||||||
@@ -130,7 +199,11 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
|
|||||||
aggregate(
|
aggregate(
|
||||||
photos.map((p) => p.updatedAt),
|
photos.map((p) => p.updatedAt),
|
||||||
'photos_updated'
|
'photos_updated'
|
||||||
)
|
),
|
||||||
|
{
|
||||||
|
metric: 'database_size',
|
||||||
|
points: dbSizePoints
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+74
-1
@@ -3,7 +3,6 @@ import { prisma } from '../db.js'
|
|||||||
import { requireUser } from '../middleware/auth.js'
|
import { requireUser } from '../middleware/auth.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
const MAX_ATTEMPTS_PER_ENTRY = 3
|
const MAX_ATTEMPTS_PER_ENTRY = 3
|
||||||
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
|
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
|
||||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
||||||
@@ -230,4 +229,78 @@ router.post('/summary', async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/transcribe', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { audioDataUrl } = req.body ?? {}
|
||||||
|
if (!audioDataUrl || typeof audioDataUrl !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'audioDataUrl is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = audioDataUrl.match(/^data:(.+);base64,(.+)$/)
|
||||||
|
if (!match) {
|
||||||
|
return res.status(400).json({ error: 'Invalid audio data URL format' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, fullMimeType, base64Data] = match
|
||||||
|
const mimeType = fullMimeType.split(';')[0]
|
||||||
|
|
||||||
|
let ext = 'webm'
|
||||||
|
if (mimeType.includes('mp4')) ext = 'mp4'
|
||||||
|
else if (mimeType.includes('ogg')) ext = 'ogg'
|
||||||
|
else if (mimeType.includes('wav')) ext = 'wav'
|
||||||
|
|
||||||
|
const apiKey = resolveOpenRouterApiKey()
|
||||||
|
if (!apiKey) {
|
||||||
|
console.warn('[server] OpenRouter API key not configured, transcription unavailable')
|
||||||
|
return res.status(503).json({ error: 'Transcription service not configured' })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[server] Forwarding ASR request to OpenRouter (${ext}, ${base64Data.length} chars)`)
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openRouterRes = await fetch('https://openrouter.ai/api/v1/audio/transcriptions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'openai/whisper-large-v3-turbo',
|
||||||
|
input_audio: {
|
||||||
|
data: base64Data,
|
||||||
|
format: ext
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!openRouterRes.ok) {
|
||||||
|
const errorText = await openRouterRes.text().catch(() => '')
|
||||||
|
console.error(`[server] OpenRouter ASR error response (status=${openRouterRes.status}):`, errorText)
|
||||||
|
throw new Error(`OpenRouter returned status ${openRouterRes.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await openRouterRes.json()
|
||||||
|
const text = (data?.text || '').trim()
|
||||||
|
|
||||||
|
console.log(`[server] OpenRouter ASR completed successfully: "${text}"`)
|
||||||
|
return res.json({ text })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
console.error('[server] OpenRouter ASR request timed out')
|
||||||
|
return res.status(504).json({ error: 'Transcription request timed out' })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('ASR transcription failed:', error)
|
||||||
|
return res.status(503).json({ error: 'Transcription service unavailable' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ function isMissingAppearancePrefsTable(error: unknown): boolean {
|
|||||||
const DEFAULT_APPEARANCE_PREFS = {
|
const DEFAULT_APPEARANCE_PREFS = {
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
colorScheme: 'auto',
|
colorScheme: 'auto',
|
||||||
|
aiAuthorized: false,
|
||||||
persisted: false
|
persisted: false
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -454,6 +455,7 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
|
|||||||
return res.json({
|
return res.json({
|
||||||
theme: prefs?.theme ?? 'auto',
|
theme: prefs?.theme ?? 'auto',
|
||||||
colorScheme: prefs?.colorScheme ?? 'auto',
|
colorScheme: prefs?.colorScheme ?? 'auto',
|
||||||
|
aiAuthorized: prefs?.aiAuthorized ?? false,
|
||||||
persisted: prefs != null
|
persisted: prefs != null
|
||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -469,6 +471,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
|||||||
try {
|
try {
|
||||||
const theme = parseThemePreference(req.body?.theme)
|
const theme = parseThemePreference(req.body?.theme)
|
||||||
const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
|
const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
|
||||||
|
const aiAuthorized = req.body?.aiAuthorized === true
|
||||||
if (!theme || !colorScheme) {
|
if (!theme || !colorScheme) {
|
||||||
return res.status(400).json({ error: 'Invalid theme or colorScheme' })
|
return res.status(400).json({ error: 'Invalid theme or colorScheme' })
|
||||||
}
|
}
|
||||||
@@ -479,11 +482,13 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
|||||||
userId: req.userId,
|
userId: req.userId,
|
||||||
theme,
|
theme,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
aiAuthorized,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
theme,
|
theme,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
aiAuthorized,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -491,6 +496,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
|||||||
return res.json({
|
return res.json({
|
||||||
theme: prefs.theme,
|
theme: prefs.theme,
|
||||||
colorScheme: prefs.colorScheme,
|
colorScheme: prefs.colorScheme,
|
||||||
|
aiAuthorized: prefs.aiAuthorized,
|
||||||
persisted: true
|
persisted: true
|
||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user