From f1f90da069dd5062703e6dce399a520ece3a3c2a Mon Sep 17 00:00:00 2001 From: elpatron Date: Sat, 30 May 2026 12:58:25 +0200 Subject: [PATCH] feat(feedback): Feedback-Formular mit Ntfy-Versand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nutzer können Feedback aus dem Header senden; der Server leitet Nachrichten über Ntfy weiter (NTFY_* in .env). Co-authored-by: Cursor --- .env.example | 8 +- client/src/App.css | 53 +++++++ client/src/App.tsx | 6 + .../src/components/FeedbackHeaderButton.tsx | 37 +++++ client/src/components/FeedbackModal.tsx | 130 ++++++++++++++++++ client/src/components/LogbookDashboard.tsx | 3 + client/src/i18n/locales/de.json | 17 +++ client/src/i18n/locales/en.json | 17 +++ client/src/services/feedback.ts | 50 +++++++ server/src/index.ts | 2 + server/src/routes/feedback.ts | 61 ++++++++ server/src/services/ntfyNotify.ts | 74 ++++++++++ 12 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 client/src/components/FeedbackHeaderButton.tsx create mode 100644 client/src/components/FeedbackModal.tsx create mode 100644 client/src/services/feedback.ts create mode 100644 server/src/routes/feedback.ts create mode 100644 server/src/services/ntfyNotify.ts diff --git a/.env.example b/.env.example index f1ca415..d81209c 100755 --- a/.env.example +++ b/.env.example @@ -10,4 +10,10 @@ ORIGIN=http://localhost # Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= -VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu \ No newline at end of file +VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu + +# Feedback via Ntfy (https://ntfy.sh or self-hosted) +# NTFY_TOPIC: topic name only (not the full URL) +NTFY_SERVER=https://ntfy.sh +NTFY_TOPIC=kapteins-daagbok-feedback +NTFY_TOKEN=tk_example_ntfy_access_token diff --git a/client/src/App.css b/client/src/App.css index c85b7af..6bbdcbf 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -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; diff --git a/client/src/App.tsx b/client/src/App.tsx index 2ba66a5..ca98663 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { + + diff --git a/client/src/components/FeedbackHeaderButton.tsx b/client/src/components/FeedbackHeaderButton.tsx new file mode 100644 index 0000000..7edbc3c --- /dev/null +++ b/client/src/components/FeedbackHeaderButton.tsx @@ -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 ( + <> + + setOpen(false)} + logbookId={logbookId} + logbookTitle={logbookTitle} + /> + + ) +} diff --git a/client/src/components/FeedbackModal.tsx b/client/src/components/FeedbackModal.tsx new file mode 100644 index 0000000..cd897cf --- /dev/null +++ b/client/src/components/FeedbackModal.tsx @@ -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('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 ( +
+
event.stopPropagation()}> +
+
+ +

{t('feedback.title')}

+ +
+ +

{t('feedback.intro')}

+ +
+ + +