Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| faf3b8e3cf | |||
| 74ff8eb16b | |||
| 81d3e3b777 | |||
| 97c5173e63 | |||
| 8b34044481 | |||
| d948325a45 | |||
| 8b8196f6e3 | |||
| 6593b320ee | |||
| 9a931024d6 | |||
| 4dfe2cea4e | |||
| 944f4518e9 | |||
| 0c765f712c | |||
| 676547686b | |||
| 66606c5eca | |||
| a30fac029d | |||
| 796e61f4ea | |||
| 594c65d1a5 | |||
| 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 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
+474
-9
@@ -1939,6 +1939,21 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logbook-card-right-group {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card-right-group .logbook-card-chevron {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.logbook-card .logbook-title-editable,
|
.logbook-card .logbook-title-editable,
|
||||||
.logbook-card .logbook-title-inline-edit,
|
.logbook-card .logbook-title-inline-edit,
|
||||||
.logbook-card .card-title-row {
|
.logbook-card .card-title-row {
|
||||||
@@ -2090,6 +2105,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 +2121,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 {
|
||||||
@@ -2163,6 +2180,16 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
color: var(--app-text-subtle);
|
color: var(--app-text-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry-count-badge {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.entry-sign-badge {
|
.entry-sign-badge {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -2956,6 +2983,12 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logbook-card-right-group .btn-pdf,
|
||||||
|
.logbook-card-right-group .btn-delete {
|
||||||
|
position: static;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.card-meta {
|
.card-meta {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@@ -3184,6 +3217,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 +3264,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 +3343,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 +3353,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 +3371,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 +3379,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 +4470,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,
|
||||||
@@ -6234,3 +6343,359 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Language Dropdown */
|
||||||
|
.lang-dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown-trigger-flag {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown-chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.75;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown.is-open .lang-dropdown-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
list-style: none;
|
||||||
|
border: 1px solid var(--app-input-border, rgba(255, 255, 255, 0.1));
|
||||||
|
border-radius: var(--app-radius-input, 12px);
|
||||||
|
box-shadow: var(--app-card-shadow, 0 10px 30px rgba(0, 0, 0, 0.3));
|
||||||
|
min-width: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
isolation: isolate;
|
||||||
|
animation: slideDownFade 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDownFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown.align-right .lang-dropdown-menu {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown.align-left .lang-dropdown-menu {
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-light .lang-dropdown-menu {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-dark .lang-dropdown-menu {
|
||||||
|
background: #1c1c1e;
|
||||||
|
color: #f8fafc;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: calc(var(--app-radius-input, 12px) - 4px);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-flag-svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-flag-svg.trigger-icon-only {
|
||||||
|
width: 24px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-light .lang-dropdown-option {
|
||||||
|
color: #334155;
|
||||||
|
-webkit-text-fill-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-dark .lang-dropdown-option {
|
||||||
|
color: #cbd5e1;
|
||||||
|
-webkit-text-fill-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown-option:hover {
|
||||||
|
background: var(--app-accent-bg, rgba(217, 119, 6, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-light .lang-dropdown-option:hover {
|
||||||
|
color: var(--app-accent, #d97706);
|
||||||
|
-webkit-text-fill-color: var(--app-accent, #d97706);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-dark .lang-dropdown-option:hover {
|
||||||
|
color: var(--app-accent-light, #fbbf24);
|
||||||
|
-webkit-text-fill-color: var(--app-accent-light, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown-option.is-selected {
|
||||||
|
background: var(--app-accent-bg, rgba(217, 119, 6, 0.15));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-light .lang-dropdown-option.is-selected {
|
||||||
|
color: var(--app-accent, #d97706);
|
||||||
|
-webkit-text-fill-color: var(--app-accent, #d97706);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.scheme-dark .lang-dropdown-option.is-selected {
|
||||||
|
color: var(--app-accent-light, #fbbf24);
|
||||||
|
-webkit-text-fill-color: var(--app-accent-light, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-trigger-name {
|
||||||
|
max-width: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
+4
-11
@@ -46,14 +46,14 @@ import { db } from './services/db.js'
|
|||||||
import { getLogbookAccess } from './services/logbookAccess.js'
|
import { getLogbookAccess } from './services/logbookAccess.js'
|
||||||
import type { LogbookAccessRole } from './services/logbook.js'
|
import type { LogbookAccessRole } from './services/logbook.js'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, BarChart2 } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||||
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
|
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
|
||||||
import AdminHeaderButton from './components/AdminHeaderButton.tsx'
|
import AdminHeaderButton from './components/AdminHeaderButton.tsx'
|
||||||
import { checkAdminAccess } from './services/adminApi.js'
|
import { checkAdminAccess } from './services/adminApi.js'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
import LanguageDropdown from './components/LanguageDropdown.tsx'
|
||||||
import {
|
import {
|
||||||
resolveTourLogbookContext,
|
resolveTourLogbookContext,
|
||||||
seedDemoLogbookIfNeeded
|
seedDemoLogbookIfNeeded
|
||||||
@@ -66,7 +66,7 @@ import { requestPersistentStorage } from './utils/storagePersist.js'
|
|||||||
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { confirmLeave } = useUnsavedChangesContext()
|
const { confirmLeave } = useUnsavedChangesContext()
|
||||||
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
@@ -555,10 +555,6 @@ function App() {
|
|||||||
localStorage.removeItem('active_logbook_title')
|
localStorage.removeItem('active_logbook_title')
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
cycleAppLanguage(i18n)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExitDemo = () => {
|
const handleExitDemo = () => {
|
||||||
window.history.replaceState({}, document.title, '/')
|
window.history.replaceState({}, document.title, '/')
|
||||||
syncRouteFromLocation()
|
syncRouteFromLocation()
|
||||||
@@ -715,10 +711,7 @@ function App() {
|
|||||||
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
|
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
|
||||||
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<LanguageDropdown variant="icon" align="right" />
|
||||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
|
||||||
<Languages size={18} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
|
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
import {
|
import {
|
||||||
registerUser,
|
registerUser,
|
||||||
loginUser,
|
loginUser,
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
logoutUser,
|
logoutUser,
|
||||||
resolveRestoreUsername
|
resolveRestoreUsername
|
||||||
} from '../services/auth.js'
|
} from '../services/auth.js'
|
||||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
import { KeyRound, ShieldAlert, HelpCircle, UserRound, X } from 'lucide-react'
|
||||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||||
import DisclaimerModal from './DisclaimerModal.tsx'
|
import DisclaimerModal from './DisclaimerModal.tsx'
|
||||||
import BetaBadge from './BetaBadge.tsx'
|
import BetaBadge from './BetaBadge.tsx'
|
||||||
@@ -37,7 +37,7 @@ export default function AuthOnboarding({
|
|||||||
onOpenDemo,
|
onOpenDemo,
|
||||||
restoreSession = false
|
restoreSession = false
|
||||||
}: AuthOnboardingProps) {
|
}: AuthOnboardingProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -267,9 +267,6 @@ export default function AuthOnboarding({
|
|||||||
setKnownUsers(getKnownUsernames())
|
setKnownUsers(getKnownUsernames())
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
cycleAppLanguage(i18n)
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyToClipboard = () => {
|
const copyToClipboard = () => {
|
||||||
if (recoveryPhrase) {
|
if (recoveryPhrase) {
|
||||||
@@ -780,10 +777,7 @@ export default function AuthOnboarding({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="auth-footer">
|
<div className="auth-footer">
|
||||||
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
|
<LanguageDropdown variant="text" align="left" />
|
||||||
<Languages size={18} />
|
|
||||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-icon-text link-sec"
|
className="btn-icon-text link-sec"
|
||||||
|
|||||||
@@ -45,11 +45,38 @@ 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]
|
||||||
name = snap.name || ''
|
|
||||||
photo = snap.photo || null
|
// Fallback: If not found directly by key, search by role or name or active user
|
||||||
role = snap.role || ''
|
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 || ''
|
||||||
|
photo = snap.photo || null
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||||
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||||
import type { LogbookCrewSelectionData } from '../types/person.js'
|
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||||
import { personToSnapshot } from '../utils/personSnapshots.js'
|
import { personToSnapshot } from '../utils/personSnapshots.js'
|
||||||
import LogEntriesList from './LogEntriesList.tsx'
|
import LogEntriesList from './LogEntriesList.tsx'
|
||||||
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
import { Ship, Users, FileText, Lock, ChevronLeft, UserPlus } from 'lucide-react'
|
||||||
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
||||||
import type { VesselData } from '../types/vessel.js'
|
import type { VesselData } from '../types/vessel.js'
|
||||||
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
||||||
@@ -52,9 +52,6 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
}
|
}
|
||||||
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
|
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
cycleAppLanguage(i18n)
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
@@ -111,10 +108,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
<UserPlus size={14} style={{ marginRight: '4px' }} />
|
<UserPlus size={14} style={{ marginRight: '4px' }} />
|
||||||
{t('demo.cta_register')}
|
{t('demo.cta_register')}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
|
<LanguageDropdown variant="secondary-button" align="right" />
|
||||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
|
||||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -172,7 +166,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
payloadId: v.payloadId,
|
payloadId: v.payloadId,
|
||||||
data: v.data as VesselData
|
data: v.data as VesselData
|
||||||
}))}
|
}))}
|
||||||
preloadedSelection={logbookVesselSelection as LogbookVesselSelectionData}
|
preloadedSelection={logbookVesselSelection as unknown as LogbookVesselSelectionData}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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,54 +91,78 @@ 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
|
||||||
<Users size={22} className="form-icon" />
|
className="form-header accordion-header"
|
||||||
<h3>{t('entry_crew.title')}</h3>
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
</div>
|
onKeyDown={(e) => {
|
||||||
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
<div className="input-group mb-3">
|
setCollapsed(!collapsed)
|
||||||
<label>{t('entry_crew.day_skipper')}</label>
|
}
|
||||||
{skippers.length === 0 ? (
|
}}
|
||||||
<p className="help-text">{t('entry_crew.no_skipper')}</p>
|
role="button"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="accordion-header-title">
|
||||||
|
<Users size={22} className="form-icon" />
|
||||||
|
<h3>{t('entry_crew.title')}</h3>
|
||||||
|
</div>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronDown size={20} className="accordion-chevron" />
|
||||||
) : (
|
) : (
|
||||||
<div className="crew-selection-list">
|
<ChevronUp size={20} className="accordion-chevron" />
|
||||||
{skippers.map(([id, data]) => (
|
|
||||||
<label key={id} className="crew-selection-item">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name={`entry-skipper-${logbookId}`}
|
|
||||||
checked={value.selectedSkipperId === id}
|
|
||||||
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
|
|
||||||
disabled={readOnly}
|
|
||||||
/>
|
|
||||||
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-group">
|
{!collapsed && (
|
||||||
<label>{t('entry_crew.day_crew')}</label>
|
<>
|
||||||
{crewEntries.length === 0 ? (
|
<p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
|
||||||
<p className="help-text">{t('entry_crew.no_crew')}</p>
|
|
||||||
) : (
|
<div className="input-group mb-3">
|
||||||
<div className="crew-selection-list">
|
<label>{t('entry_crew.day_skipper')}</label>
|
||||||
{crewEntries.map(([id, data]) => (
|
{skippers.length === 0 ? (
|
||||||
<label key={id} className="crew-selection-item">
|
<p className="help-text">{t('entry_crew.no_skipper')}</p>
|
||||||
<input
|
) : (
|
||||||
type="checkbox"
|
<div className="crew-selection-list">
|
||||||
checked={value.selectedCrewIds.includes(id)}
|
{skippers.map(([id, data]) => (
|
||||||
onChange={() => toggleCrew(id)}
|
<label key={id} className="crew-selection-item">
|
||||||
disabled={readOnly}
|
<input
|
||||||
/>
|
type="radio"
|
||||||
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
name={`entry-skipper-${logbookId}`}
|
||||||
</label>
|
checked={value.selectedSkipperId === id}
|
||||||
))}
|
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<div className="input-group">
|
||||||
|
<label>{t('entry_crew.day_crew')}</label>
|
||||||
|
{crewEntries.length === 0 ? (
|
||||||
|
<p className="help-text">{t('entry_crew.no_crew')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-selection-list">
|
||||||
|
{crewEntries.map(([id, data]) => (
|
||||||
|
<label key={id} className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value.selectedCrewIds.includes(id)}
|
||||||
|
onChange={() => toggleCrew(id)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</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 && (
|
||||||
<VoiceMemoPlayer
|
<div style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginTop: '4px' }}>
|
||||||
audioId={voiceId}
|
<VoiceMemoPlayer
|
||||||
logbookId={logbookId}
|
audioId={voiceId}
|
||||||
preloaded={preloaded}
|
logbookId={logbookId}
|
||||||
compact
|
preloaded={preloaded}
|
||||||
/>
|
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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
|
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, ArrowRight, KeyRound } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getActiveMasterKey,
|
getActiveMasterKey,
|
||||||
registerUser,
|
registerUser,
|
||||||
@@ -50,7 +50,7 @@ const hexToBuffer = (hex: string): ArrayBuffer => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
|
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [accepting, setAccepting] = useState(false)
|
const [accepting, setAccepting] = useState(false)
|
||||||
@@ -308,9 +308,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
setIsLoggedIn(true)
|
setIsLoggedIn(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
cycleAppLanguage(i18n)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recoveryPhrase) {
|
if (recoveryPhrase) {
|
||||||
return (
|
return (
|
||||||
@@ -510,10 +507,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
|
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
|
||||||
<button className="btn-icon-text" onClick={toggleLanguage}>
|
<LanguageDropdown variant="text" align="left" />
|
||||||
<Languages size={18} />
|
|
||||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Languages, Globe, ChevronDown } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
SUPPORTED_LANGUAGES,
|
||||||
|
changeAppLanguage,
|
||||||
|
normalizeAppLanguage,
|
||||||
|
type AppLanguage
|
||||||
|
} from '../utils/i18nLanguages.js'
|
||||||
|
|
||||||
|
function FlagIcon({ lang, className, style }: { lang: string; className?: string; style?: React.CSSProperties }) {
|
||||||
|
const baseStyle = {
|
||||||
|
display: 'inline-block',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
borderRadius: '2px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||||
|
boxSizing: 'border-box' as const,
|
||||||
|
...style
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (lang) {
|
||||||
|
case 'de':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 5 3" className={className} style={baseStyle}>
|
||||||
|
<rect width="5" height="3" fill="#FFCE00"/>
|
||||||
|
<rect width="5" height="2" fill="#DD0000"/>
|
||||||
|
<rect width="5" height="1" fill="#000000"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'en':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 60 30" className={className} style={baseStyle}>
|
||||||
|
<clipPath id="union-jack-clip">
|
||||||
|
<path d="M0,0 L60,30 M60,0 L0,30"/>
|
||||||
|
</clipPath>
|
||||||
|
<rect width="60" height="30" fill="#012169"/>
|
||||||
|
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6"/>
|
||||||
|
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" strokeWidth="4" clipPath="url(#union-jack-clip)"/>
|
||||||
|
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10"/>
|
||||||
|
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'da':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 37 28" className={className} style={baseStyle}>
|
||||||
|
<rect width="37" height="28" fill="#C8102E"/>
|
||||||
|
<path d="M12,0 h4 v28 h-4 z M0,12 h37 v4 h-37 z" fill="#FFFFFF"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'sv':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 10" className={className} style={baseStyle}>
|
||||||
|
<rect width="16" height="10" fill="#006AA7"/>
|
||||||
|
<path d="M5,0 h2 v10 h-2 z M0,4 h16 v2 h-16 z" fill="#FECC00"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'nb':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 22 16" className={className} style={baseStyle}>
|
||||||
|
<rect width="22" height="16" fill="#BA0C2F"/>
|
||||||
|
<path d="M6,0 h4 v16 h-4 z M0,6 h22 v4 h-22 z" fill="#FFFFFF"/>
|
||||||
|
<path d="M7,0 h2 v16 h-2 z M0,7 h22 v2 h-22 z" fill="#00205B"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'fr':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
|
||||||
|
<rect width="3" height="2" fill="#FFFFFF"/>
|
||||||
|
<rect width="1" height="2" fill="#002395"/>
|
||||||
|
<rect x="2" width="1" height="2" fill="#ED2939"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'es':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
|
||||||
|
<rect width="3" height="2" fill="#C1272D"/>
|
||||||
|
<rect y="0.5" width="3" height="1" fill="#FEE100"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanguageDropdownProps {
|
||||||
|
variant?: 'icon' | 'text' | 'secondary-button'
|
||||||
|
align?: 'left' | 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LanguageDropdown({
|
||||||
|
variant = 'icon',
|
||||||
|
align = 'right'
|
||||||
|
}: LanguageDropdownProps) {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const activeLang = normalizeAppLanguage(i18n.language)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', closeOnOutsideClick)
|
||||||
|
document.addEventListener('keydown', closeOnEscape)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', closeOnOutsideClick)
|
||||||
|
document.removeEventListener('keydown', closeOnEscape)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const selectLanguage = (lang: AppLanguage) => {
|
||||||
|
changeAppLanguage(i18n, lang)
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger button content based on variant
|
||||||
|
const renderTriggerContent = () => {
|
||||||
|
const name = t(`languages.${activeLang}`)
|
||||||
|
|
||||||
|
if (variant === 'icon') {
|
||||||
|
return (
|
||||||
|
<span className="lang-dropdown-trigger-flag" aria-hidden="true">
|
||||||
|
<FlagIcon lang={activeLang} className="lang-flag-svg trigger-icon-only" />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'secondary-button') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Globe size={14} style={{ marginRight: '4px' }} />
|
||||||
|
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ marginRight: '4px' }} />
|
||||||
|
<span className="lang-trigger-name">{name}</span>
|
||||||
|
<ChevronDown size={12} className="lang-dropdown-chevron" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default or "text" variant (used in footer)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Languages size={18} />
|
||||||
|
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ margin: '0 4px' }} />
|
||||||
|
<span>{name}</span>
|
||||||
|
<ChevronDown size={14} className="lang-dropdown-chevron" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerClass =
|
||||||
|
variant === 'icon'
|
||||||
|
? 'btn-icon'
|
||||||
|
: variant === 'secondary-button'
|
||||||
|
? 'btn secondary compact'
|
||||||
|
: 'btn-icon-text'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`lang-dropdown ${isOpen ? 'is-open' : ''} align-${align}`}
|
||||||
|
ref={rootRef}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={triggerClass}
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
title="Switch Language"
|
||||||
|
style={variant === 'secondary-button' ? { width: 'auto', padding: '6px 12px', fontSize: '13px' } : undefined}
|
||||||
|
>
|
||||||
|
{renderTriggerContent()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<ul className="lang-dropdown-menu" role="listbox">
|
||||||
|
{SUPPORTED_LANGUAGES.map((lang) => {
|
||||||
|
const isSelected = lang === activeLang
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={lang}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
className={`lang-dropdown-option ${isSelected ? 'is-selected' : ''}`}
|
||||||
|
onClick={() => selectLanguage(lang)}
|
||||||
|
>
|
||||||
|
<FlagIcon lang={lang} className="lang-flag-svg" />
|
||||||
|
<span className="lang-option-name">{t(`languages.${lang}`)}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</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,13 +712,27 @@ 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) {
|
||||||
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
if (err.code === 'OFFLINE') {
|
||||||
return
|
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
||||||
}
|
return
|
||||||
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
}
|
||||||
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
if (err.code === 'NO_KEY') {
|
||||||
return
|
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
||||||
|
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'))
|
||||||
@@ -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
|
logbookId={logbookId}
|
||||||
audioId={voiceId}
|
voiceMemoLookup={voiceMemoLookup}
|
||||||
logbookId={logbookId}
|
readOnly={false}
|
||||||
preloaded={voicePreloaded}
|
/>
|
||||||
compact
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -541,17 +541,17 @@ export default function LogEntriesList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
|
<div className="logbook-card-right-group">
|
||||||
|
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
||||||
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
<Download size={18} />
|
||||||
<Download size={18} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!readOnly && (
|
|
||||||
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
|
||||||
<Trash2 size={18} />
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
{!readOnly && (
|
||||||
|
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
|
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||||
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
|
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
|
||||||
@@ -11,7 +11,7 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
|||||||
import { getErrorMessage } from '../utils/errors.js'
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import { logoutUser } from '../services/auth.js'
|
import { logoutUser } from '../services/auth.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
import { BookOpen, Plus, Trash2, LogOut, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||||
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||||
@@ -35,10 +35,14 @@ function sortLogbooks(
|
|||||||
): DecryptedLogbook[] {
|
): DecryptedLogbook[] {
|
||||||
const sorted = [...items]
|
const sorted = [...items]
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
const cmp =
|
let cmp = 0
|
||||||
sortBy === 'name'
|
if (sortBy === 'name') {
|
||||||
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
|
cmp = a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
|
||||||
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
} else {
|
||||||
|
const timeA = a.lastTravelDate ? new Date(a.lastTravelDate).getTime() : new Date(a.updatedAt).getTime()
|
||||||
|
const timeB = b.lastTravelDate ? new Date(b.lastTravelDate).getTime() : new Date(b.updatedAt).getTime()
|
||||||
|
cmp = timeA - timeB
|
||||||
|
}
|
||||||
return direction === 'asc' ? cmp : -cmp
|
return direction === 'asc' ? cmp : -cmp
|
||||||
})
|
})
|
||||||
return sorted
|
return sorted
|
||||||
@@ -198,9 +202,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
onLogout()
|
onLogout()
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
cycleAppLanguage(i18n)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
||||||
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
||||||
@@ -291,8 +292,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
{lb.isDemo && (
|
{lb.isDemo && (
|
||||||
<span className="demo-badge">{t('demo.badge')}</span>
|
<span className="demo-badge">{t('demo.badge')}</span>
|
||||||
)}
|
)}
|
||||||
|
<span className="entry-count-badge" title={t('dashboard.travel_days_count', { count: lb.entryCount ?? 0 })}>
|
||||||
|
<CalendarDays size={12} style={{ marginRight: '4px' }} />
|
||||||
|
{lb.entryCount ?? 0}
|
||||||
|
</span>
|
||||||
<span className="date-badge">
|
<span className="date-badge">
|
||||||
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
|
{new Date(lb.lastTravelDate || lb.updatedAt).toLocaleDateString(i18n.language, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
@@ -392,10 +397,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
|
|
||||||
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
|
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
|
||||||
|
|
||||||
{/* Lang toggle */}
|
<LanguageDropdown variant="icon" align="right" />
|
||||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
|
||||||
<Languages size={18} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<DisclaimerHeaderButton />
|
<DisclaimerHeaderButton />
|
||||||
|
|
||||||
|
|||||||
@@ -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, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
|
||||||
|
|
||||||
interface PhotoCaptureProps {
|
interface PhotoCaptureProps {
|
||||||
entryId: string
|
entryId: string
|
||||||
@@ -27,12 +29,43 @@ interface DecryptedPhoto {
|
|||||||
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
|
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
const [caption, setCaption] = useState('')
|
const [caption, setCaption] = useState('')
|
||||||
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,93 +152,201 @@ 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
|
||||||
<Camera size={20} className="form-icon" />
|
className="form-header accordion-header"
|
||||||
<h3>{t('logs.photos_title')}</h3>
|
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">
|
||||||
|
<Camera size={20} className="form-icon" />
|
||||||
|
<h3>{t('logs.photos_title')}</h3>
|
||||||
|
</div>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronDown size={20} className="accordion-chevron" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp size={20} className="accordion-chevron" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="auth-error mb-4">{error}</div>}
|
{!collapsed && (
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
{/* Upload area */}
|
{/* Upload area */}
|
||||||
{/* Upload Form */}
|
{/* Upload Form */}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
||||||
<label>{t('logs.photo_caption_label')}</label>
|
<label>{t('logs.photo_caption_label')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('logs.photo_caption_placeholder')}
|
placeholder={t('logs.photo_caption_placeholder')}
|
||||||
className="input-text"
|
className="input-text"
|
||||||
value={caption}
|
value={caption}
|
||||||
onChange={(e) => setCaption(e.target.value)}
|
onChange={(e) => setCaption(e.target.value)}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<input
|
||||||
type="button"
|
type="file"
|
||||||
className="btn primary"
|
accept="image/*"
|
||||||
onClick={triggerSelect}
|
capture="environment"
|
||||||
disabled={uploading}
|
ref={cameraInputRef}
|
||||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
onChange={handleFileChange}
|
||||||
>
|
style={{ display: 'none' }}
|
||||||
{uploading ? (
|
/>
|
||||||
<span className="spin">⏳</span>
|
|
||||||
) : (
|
|
||||||
<Camera size={16} />
|
|
||||||
)}
|
|
||||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Photo Grid */}
|
{hasCamera ? (
|
||||||
{decryptedPhotos.length === 0 ? (
|
<>
|
||||||
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
|
<button
|
||||||
) : (
|
type="button"
|
||||||
<div className="photo-attachments-grid">
|
className="btn primary"
|
||||||
{decryptedPhotos.map((photo) => (
|
onClick={triggerCameraSelect}
|
||||||
<div key={photo.payloadId} className="photo-card glass">
|
disabled={uploading}
|
||||||
<div className="photo-container">
|
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
>
|
||||||
{!readOnly && (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="photo-btn-delete"
|
className="btn primary"
|
||||||
onClick={() => handleDelete(photo.payloadId)}
|
onClick={triggerGallerySelect}
|
||||||
title="Remove photo"
|
disabled={uploading}
|
||||||
|
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
{uploading ? (
|
||||||
|
<span className="spin">⏳</span>
|
||||||
|
) : (
|
||||||
|
<Camera size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{photo.caption && (
|
|
||||||
<div className="photo-caption-bar">
|
|
||||||
<span>{photo.caption}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{/* Photo Grid */}
|
||||||
|
{decryptedPhotos.length === 0 ? (
|
||||||
|
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="photo-attachments-grid">
|
||||||
|
{decryptedPhotos.map((photo) => (
|
||||||
|
<div
|
||||||
|
key={photo.payloadId}
|
||||||
|
className="photo-card glass"
|
||||||
|
onClick={() => setMaximizedPhoto(photo)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div className="photo-container">
|
||||||
|
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="photo-btn-delete"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(photo.payloadId)
|
||||||
|
}}
|
||||||
|
title="Remove photo"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{photo.caption && (
|
||||||
|
<div className="photo-caption-bar">
|
||||||
|
<span>{photo.caption}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
|
import { isGermanLocale } from '../utils/i18nLanguages.js'
|
||||||
|
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
import { decryptJson } from '../services/crypto.js'
|
import { decryptJson } from '../services/crypto.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||||
@@ -12,7 +13,7 @@ import { emptyLogbookCrewSelection } from '../types/person.js'
|
|||||||
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
|
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
|
||||||
import type { PersonData } from '../types/person.js'
|
import type { PersonData } from '../types/person.js'
|
||||||
import LogEntriesList from './LogEntriesList.tsx'
|
import LogEntriesList from './LogEntriesList.tsx'
|
||||||
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
|
import { Ship, Users, FileText, Lock, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
interface ReadOnlyViewerProps {
|
interface ReadOnlyViewerProps {
|
||||||
token: string
|
token: string
|
||||||
@@ -215,9 +216,6 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
cycleAppLanguage(i18n)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -258,10 +256,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
|
<LanguageDropdown variant="secondary-button" align="right" />
|
||||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
|
||||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import deJson from './locales/de.json'
|
|||||||
import daJson from './locales/da.json'
|
import daJson from './locales/da.json'
|
||||||
import svJson from './locales/sv.json'
|
import svJson from './locales/sv.json'
|
||||||
import nbJson from './locales/nb.json'
|
import nbJson from './locales/nb.json'
|
||||||
|
import frJson from './locales/fr.json'
|
||||||
|
import esJson from './locales/es.json'
|
||||||
import { initSeo } from '../utils/seo.js'
|
import { initSeo } from '../utils/seo.js'
|
||||||
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
|
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
|
||||||
|
|
||||||
@@ -15,7 +17,9 @@ const resources = {
|
|||||||
de: { translation: deJson.translation },
|
de: { translation: deJson.translation },
|
||||||
da: { translation: daJson.translation },
|
da: { translation: daJson.translation },
|
||||||
sv: { translation: svJson.translation },
|
sv: { translation: svJson.translation },
|
||||||
nb: { translation: nbJson.translation }
|
nb: { translation: nbJson.translation },
|
||||||
|
fr: { translation: frJson.translation },
|
||||||
|
es: { translation: esJson.translation }
|
||||||
}
|
}
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import enJson from '../i18n/locales/en.json'
|
|||||||
import daJson from '../i18n/locales/da.json'
|
import daJson from '../i18n/locales/da.json'
|
||||||
import svJson from '../i18n/locales/sv.json'
|
import svJson from '../i18n/locales/sv.json'
|
||||||
import nbJson from '../i18n/locales/nb.json'
|
import nbJson from '../i18n/locales/nb.json'
|
||||||
|
import frJson from '../i18n/locales/fr.json'
|
||||||
|
import esJson from '../i18n/locales/es.json'
|
||||||
|
|
||||||
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||||
const keys: string[] = []
|
const keys: string[] = []
|
||||||
@@ -23,7 +25,9 @@ const bundles = {
|
|||||||
en: enJson.translation,
|
en: enJson.translation,
|
||||||
da: daJson.translation,
|
da: daJson.translation,
|
||||||
sv: svJson.translation,
|
sv: svJson.translation,
|
||||||
nb: nbJson.translation
|
nb: nbJson.translation,
|
||||||
|
fr: frJson.translation,
|
||||||
|
es: esJson.translation
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
describe('i18n locale key parity', () => {
|
describe('i18n locale key parity', () => {
|
||||||
|
|||||||
+648
-626
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,9 @@
|
|||||||
"en": "English",
|
"en": "English",
|
||||||
"da": "Dansk",
|
"da": "Dansk",
|
||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk",
|
||||||
|
"fr": "Français",
|
||||||
|
"es": "Español"
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
@@ -186,6 +188,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 +302,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 +444,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?",
|
||||||
@@ -532,6 +542,9 @@
|
|||||||
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
|
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
|
||||||
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
||||||
"loading": "Logbücher werden geladen...",
|
"loading": "Logbücher werden geladen...",
|
||||||
|
"travel_days_count_zero": "Keine Reisetage",
|
||||||
|
"travel_days_count_one": "1 Reisetag",
|
||||||
|
"travel_days_count_other": "{{count}} Reisetage",
|
||||||
"status_synced": "Synchronisiert",
|
"status_synced": "Synchronisiert",
|
||||||
"status_local": "Nur lokaler Cache",
|
"status_local": "Nur lokaler Cache",
|
||||||
"delete_btn": "Logbuch löschen",
|
"delete_btn": "Logbuch löschen",
|
||||||
@@ -669,6 +682,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 +809,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)",
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
"en": "English",
|
"en": "English",
|
||||||
"da": "Dansk",
|
"da": "Dansk",
|
||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk",
|
||||||
|
"fr": "French",
|
||||||
|
"es": "Spanish"
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
@@ -186,6 +188,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 +302,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 +444,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?",
|
||||||
@@ -532,6 +542,9 @@
|
|||||||
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
|
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
|
||||||
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
||||||
"loading": "Loading logbooks...",
|
"loading": "Loading logbooks...",
|
||||||
|
"travel_days_count_zero": "No travel days",
|
||||||
|
"travel_days_count_one": "1 travel day",
|
||||||
|
"travel_days_count_other": "{{count}} travel days",
|
||||||
"status_synced": "Synced",
|
"status_synced": "Synced",
|
||||||
"status_local": "Local Cache Only",
|
"status_local": "Local Cache Only",
|
||||||
"delete_btn": "Delete logbook",
|
"delete_btn": "Delete logbook",
|
||||||
@@ -669,6 +682,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 +809,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)",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+602
-580
File diff suppressed because it is too large
Load Diff
+652
-630
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface DecryptedLogbook {
|
|||||||
isShared: boolean
|
isShared: boolean
|
||||||
accessRole: LogbookAccessRole
|
accessRole: LogbookAccessRole
|
||||||
isDemo?: boolean
|
isDemo?: boolean
|
||||||
|
lastTravelDate?: string
|
||||||
|
entryCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to decrypt a logbook's title using the active logbook key or master key
|
// Helper to decrypt a logbook's title using the active logbook key or master key
|
||||||
@@ -142,10 +144,24 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
|||||||
// Retrieve all from Dexie cache
|
// Retrieve all from Dexie cache
|
||||||
const cachedLogbooks = await db.logbooks.toArray()
|
const cachedLogbooks = await db.logbooks.toArray()
|
||||||
|
|
||||||
// Decrypt titles
|
// Decrypt titles and query last travel dates
|
||||||
const decrypted: DecryptedLogbook[] = []
|
const decrypted: DecryptedLogbook[] = []
|
||||||
for (const lb of cachedLogbooks) {
|
for (const lb of cachedLogbooks) {
|
||||||
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
|
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
|
||||||
|
|
||||||
|
// Find latest travel date from local entries cache
|
||||||
|
const entries = await db.entries.where({ logbookId: lb.id }).toArray()
|
||||||
|
let lastTravelDate: string | undefined = undefined
|
||||||
|
if (entries.length > 0) {
|
||||||
|
const dates = entries
|
||||||
|
.map((e) => e.listCache?.date)
|
||||||
|
.filter((d): d is string => typeof d === 'string' && d.length > 0)
|
||||||
|
if (dates.length > 0) {
|
||||||
|
dates.sort()
|
||||||
|
lastTravelDate = dates[dates.length - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
decrypted.push({
|
decrypted.push({
|
||||||
id: lb.id,
|
id: lb.id,
|
||||||
title,
|
title,
|
||||||
@@ -155,7 +171,9 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
|||||||
accessRole: lb.isShared === 1
|
accessRole: lb.isShared === 1
|
||||||
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
|
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
|
||||||
: 'OWNER',
|
: 'OWNER',
|
||||||
isDemo: lb.isDemo === 1
|
isDemo: lb.isDemo === 1,
|
||||||
|
lastTravelDate,
|
||||||
|
entryCount: entries.length
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -20,14 +20,13 @@ vi.mock('../services/analytics.js', async (importOriginal) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function createMockI18n(language: string): I18nInstance {
|
function createMockI18n(language: string): I18nInstance {
|
||||||
let current = language
|
const mock = {
|
||||||
return {
|
language,
|
||||||
language: current,
|
|
||||||
changeLanguage: vi.fn(async (lng: string) => {
|
changeLanguage: vi.fn(async (lng: string) => {
|
||||||
current = lng
|
mock.language = lng
|
||||||
;(this as { language: string }).language = lng
|
|
||||||
})
|
})
|
||||||
} as unknown as I18nInstance
|
} as unknown as I18nInstance
|
||||||
|
return mock
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('i18nLanguages', () => {
|
describe('i18nLanguages', () => {
|
||||||
@@ -72,11 +71,11 @@ describe('i18nLanguages', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('cycleAppLanguage tracks the next language', () => {
|
it('cycleAppLanguage tracks the next language', () => {
|
||||||
const i18n = createMockI18n('nb')
|
const i18n = createMockI18n('es')
|
||||||
cycleAppLanguage(i18n)
|
cycleAppLanguage(i18n)
|
||||||
|
|
||||||
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
|
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
|
||||||
from: 'nb',
|
from: 'es',
|
||||||
to: 'de'
|
to: 'de'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,10 +2,20 @@ import type { i18n as I18nInstance } from 'i18next'
|
|||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
/** Supported UI languages (ISO 639-1, language-only). */
|
/** Supported UI languages (ISO 639-1, language-only). */
|
||||||
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb'] as const
|
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb', 'fr', 'es'] as const
|
||||||
|
|
||||||
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
|
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
|
||||||
|
|
||||||
|
export const LANGUAGE_FLAGS: Record<AppLanguage, string> = {
|
||||||
|
de: '🇩🇪',
|
||||||
|
en: '🇬🇧',
|
||||||
|
da: '🇩🇰',
|
||||||
|
sv: '🇸🇪',
|
||||||
|
nb: '🇳🇴',
|
||||||
|
fr: '🇫🇷',
|
||||||
|
es: '🇪🇸'
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeAppLanguage(language?: string): AppLanguage {
|
export function normalizeAppLanguage(language?: string): AppLanguage {
|
||||||
const base = (language ?? 'en').split('-')[0].toLowerCase()
|
const base = (language ?? 'en').split('-')[0].toLowerCase()
|
||||||
if ((SUPPORTED_LANGUAGES as readonly string[]).includes(base)) {
|
if ((SUPPORTED_LANGUAGES as readonly string[]).includes(base)) {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const OG_LOCALES: Record<SeoLang, string> = {
|
|||||||
en: 'en_GB',
|
en: 'en_GB',
|
||||||
da: 'da_DK',
|
da: 'da_DK',
|
||||||
sv: 'sv_SE',
|
sv: 'sv_SE',
|
||||||
nb: 'nb_NO'
|
nb: 'nb_NO',
|
||||||
|
fr: 'fr_FR',
|
||||||
|
es: 'es_ES'
|
||||||
}
|
}
|
||||||
|
|
||||||
let i18nRef: I18nInstance | null = null
|
let i18nRef: I18nInstance | null = null
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ const defaultSource = resolve(repoRoot, 'client/src/i18n/locales/de.json')
|
|||||||
const TARGETS = {
|
const TARGETS = {
|
||||||
da: 'DA',
|
da: 'DA',
|
||||||
sv: 'SV',
|
sv: 'SV',
|
||||||
nb: 'NB'
|
nb: 'NB',
|
||||||
|
fr: 'FR',
|
||||||
|
es: 'ES'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keys whose values stay identical to source (language names, brand). */
|
/** Keys whose values stay identical to source (language names, brand). */
|
||||||
|
|||||||
+31
-18
@@ -70,6 +70,11 @@ DEFAULT_VERSION="0.1.0.0"
|
|||||||
MAX_WAIT=90
|
MAX_WAIT=90
|
||||||
|
|
||||||
REMOTE_USER="${REMOTE_USER:-root}"
|
REMOTE_USER="${REMOTE_USER:-root}"
|
||||||
|
# GIT_REMOTE="${GIT_REMOTE:-github}"
|
||||||
|
# GIT_REMOTE_URL="${GIT_REMOTE_URL:-https://github.com/elpatron68/kapteins-daagbok.git}"
|
||||||
|
GIT_REMOTE="${GIT_REMOTE:-origin}"
|
||||||
|
GIT_REMOTE_URL="${GIT_REMOTE_URL:-https://gitea.elpatron.me/elpatron/kapteins-daagbok.git}"
|
||||||
|
|
||||||
|
|
||||||
if [[ "$DEST" == "stage" ]]; then
|
if [[ "$DEST" == "stage" ]]; then
|
||||||
REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}"
|
REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}"
|
||||||
@@ -186,34 +191,34 @@ ensure_local_sync_with_origin() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Syncing with origin..."
|
echo "Syncing with ${GIT_REMOTE}..."
|
||||||
git fetch --tags origin
|
git fetch --tags "${GIT_REMOTE}"
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo "Error: git fetch origin failed." >&2
|
echo "Error: git fetch ${GIT_REMOTE} failed." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then
|
if ! git rev-parse --verify "${GIT_REMOTE}/${branch}" >/dev/null 2>&1; then
|
||||||
echo "Error: origin/${branch} does not exist." >&2
|
echo "Error: ${GIT_REMOTE}/${branch} does not exist." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local_sha="$(git rev-parse HEAD)"
|
local_sha="$(git rev-parse HEAD)"
|
||||||
origin_sha="$(git rev-parse "origin/${branch}")"
|
origin_sha="$(git rev-parse "${GIT_REMOTE}/${branch}")"
|
||||||
|
|
||||||
if [ "$local_sha" = "$origin_sha" ]; then
|
if [ "$local_sha" = "$origin_sha" ]; then
|
||||||
echo "Local branch '$branch' matches origin/${branch} ($(git rev-parse --short HEAD))."
|
echo "Local branch '$branch' matches ${GIT_REMOTE}/${branch} ($(git rev-parse --short HEAD))."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Error: Local '$branch' is not in sync with origin/${branch}." >&2
|
echo "Error: Local '$branch' is not in sync with ${GIT_REMOTE}/${branch}." >&2
|
||||||
echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&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
|
echo " ${GIT_REMOTE}: $(git rev-parse --short "${GIT_REMOTE}/${branch}") $(git log -1 --format='%s' "${GIT_REMOTE}/${branch}")" >&2
|
||||||
|
|
||||||
if git merge-base --is-ancestor "$local_sha" "origin/${branch}" 2>/dev/null; then
|
if git merge-base --is-ancestor "$local_sha" "${GIT_REMOTE}/${branch}" 2>/dev/null; then
|
||||||
echo "Hint: run 'git pull' to fast-forward." >&2
|
echo "Hint: run 'git pull' to fast-forward." >&2
|
||||||
elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then
|
elif git merge-base --is-ancestor "${GIT_REMOTE}/${branch}" "$local_sha" 2>/dev/null; then
|
||||||
echo "Hint: run 'git push origin ${branch}' before deploying." >&2
|
echo "Hint: run 'git push ${GIT_REMOTE} ${branch}' before deploying." >&2
|
||||||
else
|
else
|
||||||
echo "Hint: branches have diverged — reconcile manually before deploying." >&2
|
echo "Hint: branches have diverged — reconcile manually before deploying." >&2
|
||||||
fi
|
fi
|
||||||
@@ -246,12 +251,12 @@ prepare_release() {
|
|||||||
echo " Next prep: v${next_version}"
|
echo " Next prep: v${next_version}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
read -r -p "Push commit and tag to origin? [Y/n] " push_answer
|
read -r -p "Push commit and tag to ${GIT_REMOTE}? [Y/n] " push_answer
|
||||||
if [[ ! "$push_answer" =~ ^[nN]$ ]]; then
|
if [[ ! "$push_answer" =~ ^[nN]$ ]]; then
|
||||||
current_branch="$(git branch --show-current)"
|
current_branch="$(git branch --show-current)"
|
||||||
git push origin "$current_branch"
|
git push "${GIT_REMOTE}" "$current_branch"
|
||||||
git push origin "$tag_name"
|
git push "${GIT_REMOTE}" "$tag_name"
|
||||||
echo "Pushed ${current_branch} and ${tag_name} to origin."
|
echo "Pushed ${current_branch} and ${tag_name} to ${GIT_REMOTE}."
|
||||||
else
|
else
|
||||||
echo "Skipped push. Remote host must receive this commit/tag manually."
|
echo "Skipped push. Remote host must receive this commit/tag manually."
|
||||||
fi
|
fi
|
||||||
@@ -281,7 +286,7 @@ echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
|||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
|
||||||
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
|
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
|
||||||
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" <<'REMOTE_SCRIPT'
|
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" "$GIT_REMOTE_URL" <<'REMOTE_SCRIPT'
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
REMOTE_DIR="$1"
|
REMOTE_DIR="$1"
|
||||||
@@ -291,10 +296,18 @@ 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:-}"
|
||||||
|
GIT_REMOTE_URL="${9:-https://github.com/elpatron68/kapteins-daagbok.git}"
|
||||||
|
|
||||||
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; }
|
||||||
|
|
||||||
|
echo "Configuring git remote 'origin' URL to ${GIT_REMOTE_URL} on remote host..."
|
||||||
|
if git remote | grep -q "^origin$"; then
|
||||||
|
git remote set-url origin "$GIT_REMOTE_URL"
|
||||||
|
else
|
||||||
|
git remote add origin "$GIT_REMOTE_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
|
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
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { flattenTranslation } from './lib/deepl-translate.mjs'
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const localesDir = resolve(__dirname, '../client/src/i18n/locales')
|
const localesDir = resolve(__dirname, '../client/src/i18n/locales')
|
||||||
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json']
|
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json', 'fr.json', 'es.json']
|
||||||
|
|
||||||
async function loadKeys(filename) {
|
async function loadKeys(filename) {
|
||||||
const raw = await readFile(resolve(localesDir, filename), 'utf8')
|
const raw = await readFile(resolve(localesDir, filename), 'utf8')
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ model UserNotificationPrefs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model UserAppearancePrefs {
|
model UserAppearancePrefs {
|
||||||
userId String @id
|
userId String @id
|
||||||
theme String @default("auto")
|
theme String @default("auto")
|
||||||
colorScheme String @default("auto")
|
colorScheme String @default("auto")
|
||||||
updatedAt DateTime @updatedAt
|
aiAuthorized Boolean @default(false)
|
||||||
|
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