feat(feedback): Feedback-Formular mit Ntfy-Versand

Nutzer können Feedback aus dem Header senden; der Server leitet Nachrichten über Ntfy weiter (NTFY_* in .env).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-30 12:58:25 +02:00
co-authored by Cursor
parent 4541c81d3b
commit f1f90da069
12 changed files with 457 additions and 1 deletions
+53
View File
@@ -388,6 +388,59 @@ html.scheme-dark .themed-select-option.is-selected {
max-height: min(90vh, 820px);
}
.feedback-modal .auth-actions {
margin-top: 0;
}
.feedback-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.feedback-form__field {
display: flex;
flex-direction: column;
gap: 6px;
text-align: left;
}
.feedback-form__field > span {
font-size: 13px;
font-weight: 600;
color: var(--app-text-heading, #f1f5f9);
}
.feedback-form__field select,
.feedback-form__field textarea {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--app-input-border, rgba(148, 163, 184, 0.25));
background: var(--app-input-bg, rgba(15, 23, 42, 0.6));
color: var(--app-text, #e2e8f0);
font: inherit;
resize: vertical;
}
.feedback-form__field select:focus,
.feedback-form__field textarea:focus {
outline: none;
border-color: var(--app-accent, #38bdf8);
}
.feedback-form__actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.feedback-form__actions .btn {
width: auto;
min-width: 100px;
margin: 0;
}
.registration-disclaimer__intro {
margin: 0 0 16px;
font-size: 14px;
+6
View File
@@ -34,6 +34,7 @@ import type { LogbookAccessRole } from './services/logbook.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import {
getStoredDemoFirstEntryId,
@@ -439,6 +440,11 @@ function App() {
<DisclaimerHeaderButton />
<FeedbackHeaderButton
logbookId={activeLogbookId}
logbookTitle={activeLogbookTitle}
/>
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
</button>
@@ -0,0 +1,37 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MessageSquarePlus } from 'lucide-react'
import FeedbackModal from './FeedbackModal.tsx'
interface FeedbackHeaderButtonProps {
logbookId?: string | null
logbookTitle?: string | null
}
export default function FeedbackHeaderButton({
logbookId,
logbookTitle
}: FeedbackHeaderButtonProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<>
<button
type="button"
className="btn-icon"
onClick={() => setOpen(true)}
title={t('feedback.button_title')}
aria-label={t('feedback.button_title')}
>
<MessageSquarePlus size={18} />
</button>
<FeedbackModal
open={open}
onClose={() => setOpen(false)}
logbookId={logbookId}
logbookTitle={logbookTitle}
/>
</>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MessageSquarePlus, X } from 'lucide-react'
import { FeedbackApiError, sendFeedback, type FeedbackCategory } from '../services/feedback.js'
import { useDialog } from './ModalDialog.tsx'
interface FeedbackModalProps {
open: boolean
onClose: () => void
logbookId?: string | null
logbookTitle?: string | null
}
export default function FeedbackModal({
open,
onClose,
logbookId,
logbookTitle
}: FeedbackModalProps) {
const { t } = useTranslation()
const { showAlert } = useDialog()
const [category, setCategory] = useState<FeedbackCategory>('general')
const [message, setMessage] = useState('')
const [sending, setSending] = useState(false)
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !sending) onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [open, onClose, sending])
useEffect(() => {
if (!open) {
setCategory('general')
setMessage('')
setSending(false)
}
}, [open])
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!message.trim() || sending) return
setSending(true)
try {
await sendFeedback({
category,
message: message.trim(),
logbookId,
logbookTitle
})
await showAlert(t('feedback.success'), t('feedback.title'))
onClose()
} catch (error) {
const msg =
error instanceof FeedbackApiError && error.code === 'NOT_CONFIGURED'
? t('feedback.error_not_configured')
: t('feedback.error_send')
await showAlert(msg, t('feedback.title'))
} finally {
setSending(false)
}
}
if (!open) return null
return (
<div className="disclaimer-modal-overlay" onClick={sending ? undefined : onClose}>
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
<div className="auth-header">
<MessageSquarePlus className="auth-icon accent" size={48} />
<h2>{t('feedback.title')}</h2>
<button
type="button"
className="registration-disclaimer__close"
onClick={onClose}
disabled={sending}
aria-label={t('feedback.cancel')}
>
<X size={18} />
</button>
</div>
<p className="registration-disclaimer__intro">{t('feedback.intro')}</p>
<form className="feedback-form" onSubmit={handleSubmit}>
<label className="feedback-form__field">
<span>{t('feedback.category_label')}</span>
<select
value={category}
onChange={(event) => setCategory(event.target.value as FeedbackCategory)}
disabled={sending}
>
<option value="general">{t('feedback.category_general')}</option>
<option value="bug">{t('feedback.category_bug')}</option>
<option value="feature">{t('feedback.category_feature')}</option>
</select>
</label>
<label className="feedback-form__field">
<span>{t('feedback.message_label')}</span>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder={t('feedback.message_placeholder')}
rows={6}
maxLength={2000}
required
disabled={sending}
/>
</label>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={onClose} disabled={sending}>
{t('feedback.cancel')}
</button>
<button type="submit" className="btn primary" disabled={sending || !message.trim()}>
{sending ? t('feedback.sending') : t('feedback.send')}
</button>
</div>
</form>
</div>
</div>
</div>
)
}
@@ -10,6 +10,7 @@ import { useDialog } from './ModalDialog.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
@@ -217,6 +218,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<DisclaimerHeaderButton />
<FeedbackHeaderButton />
{/* Logout */}
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
+17
View File
@@ -397,6 +397,23 @@
"close": "Schließen",
"button_title": "Hinweise & Haftungsausschluss"
},
"feedback": {
"button_title": "Feedback senden",
"title": "Feedback",
"intro": "Teilen Sie Fehler, Ideen oder allgemeines Feedback. Ihre Nachricht wird über einen sicheren Benachrichtigungskanal an das Projektteam gesendet.",
"category_label": "Kategorie",
"category_general": "Allgemein",
"category_bug": "Fehler melden",
"category_feature": "Feature-Wunsch",
"message_label": "Nachricht",
"message_placeholder": "Beschreiben Sie Ihr Feedback…",
"send": "Senden",
"sending": "Wird gesendet…",
"cancel": "Abbrechen",
"success": "Vielen Dank! Ihr Feedback wurde gesendet.",
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.",
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar."
},
"demo": {
"logbook_title": "Demo-Logbuch Ostsee",
"badge": "Demo",
+17
View File
@@ -397,6 +397,23 @@
"close": "Close",
"button_title": "Legal notice & disclaimer"
},
"feedback": {
"button_title": "Send feedback",
"title": "Feedback",
"intro": "Share bugs, ideas or general feedback. Your message is sent to the project team via a secure notification channel.",
"category_label": "Category",
"category_general": "General",
"category_bug": "Bug report",
"category_feature": "Feature request",
"message_label": "Message",
"message_placeholder": "Describe your feedback…",
"send": "Send",
"sending": "Sending…",
"cancel": "Cancel",
"success": "Thank you! Your feedback has been sent.",
"error_send": "Could not send feedback. Please try again later.",
"error_not_configured": "Feedback is not available on this server."
},
"demo": {
"logbook_title": "Baltic Sea Demo Logbook",
"badge": "Demo",
+50
View File
@@ -0,0 +1,50 @@
export type FeedbackCategory = 'bug' | 'feature' | 'general'
export class FeedbackApiError extends Error {
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED'
constructor(message: string, code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
super(message)
this.name = 'FeedbackApiError'
this.code = code
}
}
function buildFeedbackHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
const userId = localStorage.getItem('active_userid')
if (userId) headers['X-User-Id'] = userId
return headers
}
export async function sendFeedback(payload: {
category: FeedbackCategory
message: string
logbookId?: string | null
logbookTitle?: string | null
}): Promise<void> {
const res = await fetch('/api/feedback', {
method: 'POST',
headers: buildFeedbackHeaders(),
body: JSON.stringify({
category: payload.category,
message: payload.message,
username: localStorage.getItem('active_username') || undefined,
logbookId: payload.logbookId || undefined,
logbookTitle: payload.logbookTitle || undefined,
appVersion: typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined,
pageUrl: window.location.href
})
})
if (res.status === 503) {
throw new FeedbackApiError('Feedback is not configured on this server', 'NOT_CONFIGURED')
}
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new FeedbackApiError(data.error || 'Failed to send feedback')
}
}