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(true)} + title={t('feedback.button_title')} + aria-label={t('feedback.button_title')} + > + + + 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')} + + + + {t('feedback.category_label')} + setCategory(event.target.value as FeedbackCategory)} + disabled={sending} + > + {t('feedback.category_general')} + {t('feedback.category_bug')} + {t('feedback.category_feature')} + + + + + {t('feedback.message_label')} + setMessage(event.target.value)} + placeholder={t('feedback.message_placeholder')} + rows={6} + maxLength={2000} + required + disabled={sending} + /> + + + + + {t('feedback.cancel')} + + + {sending ? t('feedback.sending') : t('feedback.send')} + + + + + + + ) +} diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx index 30b345a..b81b644 100644 --- a/client/src/components/LogbookDashboard.tsx +++ b/client/src/components/LogbookDashboard.tsx @@ -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 + + {/* Logout */} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 7c9f96a..d95c7b2 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -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", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index a707a6e..495c9df 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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", diff --git a/client/src/services/feedback.ts b/client/src/services/feedback.ts new file mode 100644 index 0000000..cb438ef --- /dev/null +++ b/client/src/services/feedback.ts @@ -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 { + const headers: Record = { + '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 { + 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') + } +} diff --git a/server/src/index.ts b/server/src/index.ts index 458a3bb..80cf966 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,6 +10,7 @@ import collaborationRouter from './routes/collaboration.js' import signRouter from './routes/sign.js' import pushRouter from './routes/push.js' import weatherRouter from './routes/weather.js' +import feedbackRouter from './routes/feedback.js' import { prisma } from './db.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -31,6 +32,7 @@ app.use('/api/collaboration', collaborationRouter) app.use('/api/sign', signRouter) app.use('/api/push', pushRouter) app.use('/api/weather', weatherRouter) +app.use('/api/feedback', feedbackRouter) // Health check endpoint app.get('/api/health', async (req, res) => { diff --git a/server/src/routes/feedback.ts b/server/src/routes/feedback.ts new file mode 100644 index 0000000..afa1d96 --- /dev/null +++ b/server/src/routes/feedback.ts @@ -0,0 +1,61 @@ +import { Router } from 'express' +import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js' + +const router = Router() + +const VALID_CATEGORIES = new Set(['bug', 'feature', 'general']) +const MAX_MESSAGE_LENGTH = 2000 + +const requireUser = (req: any, res: any, next: any) => { + const userId = req.headers['x-user-id'] + if (!userId) { + return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' }) + } + req.userId = userId + next() +} + +router.get('/status', requireUser, (_req, res) => { + res.json({ enabled: isNtfyConfigured() }) +}) + +router.post('/', requireUser, async (req: any, res) => { + try { + if (!isNtfyConfigured()) { + return res.status(503).json({ error: 'Feedback is not configured on this server' }) + } + + const { category, message, username, logbookId, logbookTitle, appVersion, pageUrl } = req.body ?? {} + + if (typeof category !== 'string' || !VALID_CATEGORIES.has(category)) { + return res.status(400).json({ error: 'Invalid category' }) + } + + if (typeof message !== 'string' || !message.trim()) { + return res.status(400).json({ error: 'Message is required' }) + } + + const trimmedMessage = message.trim() + if (trimmedMessage.length > MAX_MESSAGE_LENGTH) { + return res.status(400).json({ error: `Message must be at most ${MAX_MESSAGE_LENGTH} characters` }) + } + + await sendFeedbackViaNtfy({ + category, + message: trimmedMessage, + username: typeof username === 'string' ? username.trim() : undefined, + userId: req.userId, + logbookId: typeof logbookId === 'string' ? logbookId.trim() : undefined, + logbookTitle: typeof logbookTitle === 'string' ? logbookTitle.trim() : undefined, + appVersion: typeof appVersion === 'string' ? appVersion.trim() : undefined, + pageUrl: typeof pageUrl === 'string' ? pageUrl.trim() : undefined + }) + + return res.json({ ok: true }) + } catch (error: any) { + console.error('Error sending feedback via Ntfy:', error) + return res.status(502).json({ error: error.message || 'Failed to send feedback' }) + } +}) + +export default router diff --git a/server/src/services/ntfyNotify.ts b/server/src/services/ntfyNotify.ts new file mode 100644 index 0000000..97632bc --- /dev/null +++ b/server/src/services/ntfyNotify.ts @@ -0,0 +1,74 @@ +export interface FeedbackPayload { + category: string + message: string + username?: string + userId: string + logbookId?: string + logbookTitle?: string + appVersion?: string + pageUrl?: string +} + +function resolveNtfyConfig(): { server: string; topic: string; token?: string } | null { + const server = (process.env.NTFY_SERVER || 'https://ntfy.sh').replace(/\/+$/, '') + const topic = process.env.NTFY_TOPIC?.trim() + const token = process.env.NTFY_TOKEN?.trim() + + if (!topic) return null + + return { server, topic, token: token || undefined } +} + +export function isNtfyConfigured(): boolean { + return resolveNtfyConfig() !== null +} + +export async function sendFeedbackViaNtfy(payload: FeedbackPayload): Promise { + const config = resolveNtfyConfig() + if (!config) { + throw new Error('NTFY_TOPIC is not configured') + } + + const categoryLabel = payload.category.charAt(0).toUpperCase() + payload.category.slice(1) + const title = `Kapteins Daagbok – ${categoryLabel}` + + const lines = [ + payload.message, + '', + '---', + `User: ${payload.username || '(unknown)'}`, + `User ID: ${payload.userId}` + ] + + if (payload.logbookTitle || payload.logbookId) { + lines.push(`Logbook: ${payload.logbookTitle || payload.logbookId}`) + } + if (payload.appVersion) { + lines.push(`App version: ${payload.appVersion}`) + } + if (payload.pageUrl) { + lines.push(`Page: ${payload.pageUrl}`) + } + + const headers: Record = { + Title: title, + Tags: 'speech_balloon,ship', + 'Content-Type': 'text/plain; charset=utf-8' + } + + if (config.token) { + headers.Authorization = `Bearer ${config.token}` + } + + const url = `${config.server}/${encodeURIComponent(config.topic)}` + const res = await fetch(url, { + method: 'POST', + headers, + body: lines.join('\n') + }) + + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`Ntfy request failed (${res.status})${body ? `: ${body}` : ''}`) + } +}
{t('feedback.intro')}