fix(logs): Ereignis-Bearbeitung sichern und Warnung bei ungespeicherten Änderungen
Normalisiert partielle Logbuch-Events beim Speichern (z. B. Besegelung) und warnt beim Verlassen von Editor, Tabs und Browser. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+25
-13
@@ -13,6 +13,7 @@ import SettingsForm from './components/SettingsForm.tsx'
|
||||
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
||||
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
||||
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
|
||||
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
|
||||
import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
||||
|
||||
function App() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { confirmLeave } = useUnsavedChangesContext()
|
||||
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
||||
@@ -347,7 +349,14 @@ function App() {
|
||||
consumePendingPushLogbook()
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
const handleTabChange = async (tab: AppTab) => {
|
||||
if (tab === activeTab) return
|
||||
if (!(await confirmLeave())) return
|
||||
setActiveTab(tab)
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (!(await confirmLeave())) return
|
||||
void logoutUser()
|
||||
setIsAuthenticated(false)
|
||||
setActiveLogbookId(null)
|
||||
@@ -358,7 +367,8 @@ function App() {
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
|
||||
const handleBackToDashboard = () => {
|
||||
const handleBackToDashboard = async () => {
|
||||
if (!(await confirmLeave())) return
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setTourSelectedEntryId(null)
|
||||
@@ -505,7 +515,7 @@ function App() {
|
||||
<aside className="app-sidebar">
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
onClick={() => void handleTabChange('logs')}
|
||||
data-tour="nav-logs"
|
||||
>
|
||||
<FileText size={18} />
|
||||
@@ -514,7 +524,7 @@ function App() {
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('vessel')}
|
||||
onClick={() => void handleTabChange('vessel')}
|
||||
data-tour="nav-vessel"
|
||||
>
|
||||
<Ship size={18} />
|
||||
@@ -523,7 +533,7 @@ function App() {
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('crew')}
|
||||
onClick={() => void handleTabChange('crew')}
|
||||
data-tour="nav-crew"
|
||||
>
|
||||
<Users size={18} />
|
||||
@@ -542,7 +552,7 @@ function App() {
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
onClick={() => void handleTabChange('stats')}
|
||||
data-tour="nav-stats"
|
||||
>
|
||||
<BarChart2 size={18} />
|
||||
@@ -551,7 +561,7 @@ function App() {
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('settings')}
|
||||
onClick={() => void handleTabChange('settings')}
|
||||
>
|
||||
<Settings size={18} />
|
||||
{t('nav.settings')}
|
||||
@@ -604,12 +614,14 @@ function App() {
|
||||
export default function AppWrapper() {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<AppTourProvider>
|
||||
<PwaUpdatePrompt />
|
||||
<App />
|
||||
<AppTourOverlay />
|
||||
</AppTourProvider>
|
||||
<AppFooter />
|
||||
<UnsavedChangesProvider>
|
||||
<AppTourProvider>
|
||||
<PwaUpdatePrompt />
|
||||
<App />
|
||||
<AppTourOverlay />
|
||||
</AppTourProvider>
|
||||
<AppFooter />
|
||||
</UnsavedChangesProvider>
|
||||
</DialogProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
hasAnySignature
|
||||
} from '../utils/signatures.js'
|
||||
import type { SignatureValue } from '../types/signatures.js'
|
||||
import { buildLogEntryPayload, sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||
import { signLogEntry } from '../services/entrySigning.js'
|
||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type SavedTrack
|
||||
} from '../services/trackUpload.js'
|
||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||
|
||||
function emptyTankLevels() {
|
||||
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
||||
@@ -248,7 +249,60 @@ export default function LogEntryEditor({
|
||||
})
|
||||
}, [buildPayloadForSigning, signSkipper, signCrew])
|
||||
|
||||
const isDirty = savedFingerprint !== null && currentFingerprint !== savedFingerprint
|
||||
const buildEventFromForm = (): LogEvent =>
|
||||
normalizeLogEvent({
|
||||
time: evTime,
|
||||
mgk: evMgk,
|
||||
rwk: evRwk,
|
||||
windPressure: evWindPressure,
|
||||
windDirection: evWindDirection,
|
||||
windStrength: evWindStrength,
|
||||
seaState: evSeaState,
|
||||
weatherIcon: evWeatherIcon,
|
||||
current: evCurrent,
|
||||
heel: evHeel,
|
||||
sailsOrMotor: evSailsOrMotor,
|
||||
logReading: evLogReading,
|
||||
distance: evDistance,
|
||||
gpsLat: evGpsLat,
|
||||
gpsLng: evGpsLng,
|
||||
remarks: evRemarks
|
||||
})
|
||||
|
||||
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
|
||||
if (editingEventIndex !== null) {
|
||||
return sortLogEventsByTime(events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev)))
|
||||
}
|
||||
return sortLogEventsByTime([...events, eventData])
|
||||
}
|
||||
|
||||
const hasPendingEventForm = useMemo(() => {
|
||||
if (!evTime.trim()) return false
|
||||
const draft = buildEventFromForm()
|
||||
if (editingEventIndex !== null) {
|
||||
const original = events[editingEventIndex]
|
||||
return original ? !logEventsEqual(draft, original) : false
|
||||
}
|
||||
return true
|
||||
}, [
|
||||
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
|
||||
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
||||
evGpsLat, evGpsLng, evRemarks, editingEventIndex, events
|
||||
])
|
||||
|
||||
const isDirty = savedFingerprint !== null && (
|
||||
currentFingerprint !== savedFingerprint || hasPendingEventForm
|
||||
)
|
||||
|
||||
const { confirmLeave } = useRegisterUnsavedChanges(
|
||||
`log-entry-${entryId}`,
|
||||
!readOnly && !loading && isDirty
|
||||
)
|
||||
|
||||
const handleBack = async () => {
|
||||
if (!(await confirmLeave())) return
|
||||
onBack()
|
||||
}
|
||||
|
||||
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
|
||||
if (readOnly) return
|
||||
@@ -483,7 +537,7 @@ export default function LogEntryEditor({
|
||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||
loadTrackStatsFromEntry(preloadedEntry)
|
||||
setEvents(sortLogEventsByTime(preloadedEntry.events || []))
|
||||
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
||||
return
|
||||
}
|
||||
@@ -516,7 +570,7 @@ export default function LogEntryEditor({
|
||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||
loadTrackStatsFromEntry(decrypted)
|
||||
setEvents(sortLogEventsByTime(decrypted.events || []))
|
||||
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
||||
}
|
||||
}
|
||||
@@ -783,25 +837,6 @@ export default function LogEntryEditor({
|
||||
return currentItems.includes(item.toLowerCase())
|
||||
}
|
||||
|
||||
const buildEventFromForm = (): LogEvent => ({
|
||||
time: evTime,
|
||||
mgk: evMgk.trim(),
|
||||
rwk: evRwk.trim(),
|
||||
windPressure: evWindPressure.trim(),
|
||||
windDirection: evWindDirection.trim(),
|
||||
windStrength: evWindStrength.trim(),
|
||||
seaState: evSeaState.trim(),
|
||||
weatherIcon: evWeatherIcon.trim(),
|
||||
current: evCurrent.trim(),
|
||||
heel: evHeel.trim(),
|
||||
sailsOrMotor: evSailsOrMotor.trim(),
|
||||
logReading: evLogReading.trim(),
|
||||
distance: evDistance.trim(),
|
||||
gpsLat: evGpsLat.trim(),
|
||||
gpsLng: evGpsLng.trim(),
|
||||
remarks: evRemarks.trim()
|
||||
})
|
||||
|
||||
const clearEventForm = () => {
|
||||
setEvTime('')
|
||||
setEvMgk('')
|
||||
@@ -824,22 +859,23 @@ export default function LogEntryEditor({
|
||||
}
|
||||
|
||||
const fillEventForm = (ev: LogEvent) => {
|
||||
setEvTime(ev.time)
|
||||
setEvMgk(ev.mgk)
|
||||
setEvRwk(ev.rwk)
|
||||
setEvWindPressure(ev.windPressure)
|
||||
setEvWindDirection(ev.windDirection)
|
||||
setEvWindStrength(ev.windStrength)
|
||||
setEvSeaState(ev.seaState)
|
||||
setEvWeatherIcon(ev.weatherIcon)
|
||||
setEvCurrent(ev.current)
|
||||
setEvHeel(ev.heel)
|
||||
setEvSailsOrMotor(ev.sailsOrMotor)
|
||||
setEvLogReading(ev.logReading)
|
||||
setEvDistance(ev.distance)
|
||||
setEvGpsLat(ev.gpsLat)
|
||||
setEvGpsLng(ev.gpsLng)
|
||||
setEvRemarks(ev.remarks)
|
||||
const normalized = normalizeLogEvent(ev)
|
||||
setEvTime(normalized.time)
|
||||
setEvMgk(normalized.mgk)
|
||||
setEvRwk(normalized.rwk)
|
||||
setEvWindPressure(normalized.windPressure)
|
||||
setEvWindDirection(normalized.windDirection)
|
||||
setEvWindStrength(normalized.windStrength)
|
||||
setEvSeaState(normalized.seaState)
|
||||
setEvWeatherIcon(normalized.weatherIcon)
|
||||
setEvCurrent(normalized.current)
|
||||
setEvHeel(normalized.heel)
|
||||
setEvSailsOrMotor(normalized.sailsOrMotor)
|
||||
setEvLogReading(normalized.logReading)
|
||||
setEvDistance(normalized.distance)
|
||||
setEvGpsLat(normalized.gpsLat)
|
||||
setEvGpsLng(normalized.gpsLng)
|
||||
setEvRemarks(normalized.remarks)
|
||||
setEvLocationName('')
|
||||
}
|
||||
|
||||
@@ -866,27 +902,25 @@ export default function LogEntryEditor({
|
||||
if (readOnly || !evTime) return
|
||||
|
||||
const eventData = buildEventFromForm()
|
||||
let nextEvents: LogEvent[]
|
||||
const isEdit = editingEventIndex !== null
|
||||
const hadSkipperSignature = isEdit && !!signSkipper
|
||||
|
||||
if (editingEventIndex !== null) {
|
||||
const hadSkipperSignature = !!signSkipper
|
||||
if (hadSkipperSignature) {
|
||||
markSkipperSignatureClearedForEventChange()
|
||||
nextEvents = sortLogEventsByTime(events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev)))
|
||||
}
|
||||
|
||||
const nextEvents = applyEventFormToEvents(eventData)
|
||||
|
||||
try {
|
||||
await persistEntryToDb(nextEvents)
|
||||
setEvents(nextEvents)
|
||||
clearEventForm()
|
||||
if (hadSkipperSignature) {
|
||||
void showAlertRef.current(
|
||||
t('logs.sign_cleared_skipper_re_sign'),
|
||||
t('logs.sign_cleared_skipper_re_sign_title')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
nextEvents = sortLogEventsByTime([...events, eventData])
|
||||
}
|
||||
|
||||
setEvents(nextEvents)
|
||||
clearEventForm()
|
||||
|
||||
try {
|
||||
await persistEntryToDb(nextEvents)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to auto-save event:', err)
|
||||
setError(err.message || 'Failed to save event.')
|
||||
@@ -935,13 +969,28 @@ export default function LogEntryEditor({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (readOnly || !isDirty) return
|
||||
if (readOnly) return
|
||||
|
||||
let eventsToSave = events
|
||||
|
||||
if (hasPendingEventForm) {
|
||||
const isEdit = editingEventIndex !== null
|
||||
if (isEdit && signSkipper) {
|
||||
markSkipperSignatureClearedForEventChange()
|
||||
}
|
||||
eventsToSave = applyEventFormToEvents(buildEventFromForm())
|
||||
setEvents(eventsToSave)
|
||||
clearEventForm()
|
||||
} else if (!isDirty) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
await persistEntryToDb()
|
||||
await persistEntryToDb(eventsToSave)
|
||||
|
||||
setSuccess(true)
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
@@ -972,7 +1021,7 @@ export default function LogEntryEditor({
|
||||
<div className="form-card" style={{ paddingBottom: '20px' }}>
|
||||
<div className="section-title-bar">
|
||||
<div className="section-title-left">
|
||||
<button className="btn-back" onClick={onBack} style={{ padding: '6px 12px' }}>
|
||||
<button className="btn-back" onClick={() => void handleBack()} style={{ padding: '6px 12px' }}>
|
||||
<ChevronLeft size={16} />
|
||||
{t('logs.back_to_list')}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDialog } from '../components/ModalDialog.tsx'
|
||||
|
||||
interface UnsavedChangesContextValue {
|
||||
setDirty: (source: string, dirty: boolean) => void
|
||||
confirmLeave: () => Promise<boolean>
|
||||
}
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null)
|
||||
|
||||
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const dirtySources = useRef(new Set<string>())
|
||||
|
||||
const setDirty = useCallback((source: string, dirty: boolean) => {
|
||||
if (dirty) dirtySources.current.add(source)
|
||||
else dirtySources.current.delete(source)
|
||||
}, [])
|
||||
|
||||
const confirmLeave = useCallback(async (): Promise<boolean> => {
|
||||
if (dirtySources.current.size === 0) return true
|
||||
return showConfirm(
|
||||
t('common.unsaved_changes_message'),
|
||||
t('common.unsaved_changes_title'),
|
||||
t('common.unsaved_changes_leave'),
|
||||
t('common.unsaved_changes_stay')
|
||||
)
|
||||
}, [showConfirm, t])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (dirtySources.current.size === 0) return
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
{children}
|
||||
</UnsavedChangesContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useUnsavedChangesContext(): UnsavedChangesContextValue {
|
||||
const ctx = useContext(UnsavedChangesContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useUnsavedChangesContext must be used within UnsavedChangesProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
|
||||
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
|
||||
const { setDirty, confirmLeave } = useUnsavedChangesContext()
|
||||
|
||||
useEffect(() => {
|
||||
setDirty(source, isDirty)
|
||||
return () => setDirty(source, false)
|
||||
}, [source, isDirty, setDirty])
|
||||
|
||||
return { confirmLeave }
|
||||
}
|
||||
@@ -6,6 +6,12 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
||||
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
||||
"unsaved_changes_leave": "Verlassen",
|
||||
"unsaved_changes_stay": "Bleiben"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Schiffsdaten",
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Beta release — features may still change"
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes_title": "Unsaved changes",
|
||||
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
||||
"unsaved_changes_leave": "Leave",
|
||||
"unsaved_changes_stay": "Stay"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Vessel Profile",
|
||||
|
||||
@@ -17,8 +17,47 @@ export interface LogEventPayload {
|
||||
remarks: string
|
||||
}
|
||||
|
||||
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
|
||||
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
||||
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
||||
'gpsLat', 'gpsLng', 'remarks'
|
||||
]
|
||||
|
||||
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
|
||||
export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<string, unknown>): LogEventPayload {
|
||||
const e = event as Record<string, unknown>
|
||||
const timeRaw = String(e.time ?? '').trim()
|
||||
const normalized: LogEventPayload = {
|
||||
time: timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw,
|
||||
mgk: '',
|
||||
rwk: '',
|
||||
windPressure: '',
|
||||
windDirection: '',
|
||||
windStrength: '',
|
||||
seaState: '',
|
||||
weatherIcon: '',
|
||||
current: '',
|
||||
heel: '',
|
||||
sailsOrMotor: '',
|
||||
logReading: '',
|
||||
distance: '',
|
||||
gpsLat: '',
|
||||
gpsLng: '',
|
||||
remarks: ''
|
||||
}
|
||||
for (const key of LOG_EVENT_FIELDS) {
|
||||
if (key === 'time') continue
|
||||
normalized[key] = String(e[key] ?? '').trim()
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean {
|
||||
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
||||
}
|
||||
|
||||
/** Chronological order: earliest time first (HH:MM). */
|
||||
export function sortLogEventsByTime<T extends Pick<LogEventPayload, 'time'>>(events: T[]): T[] {
|
||||
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
|
||||
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
||||
}
|
||||
|
||||
@@ -43,7 +82,7 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
|
||||
destination: input.destination.trim(),
|
||||
freshwater: { ...input.freshwater },
|
||||
fuel: { ...input.fuel },
|
||||
events: sortLogEventsByTime(input.events.map((e) => ({ ...e })))
|
||||
events: sortLogEventsByTime(input.events.map((e) => normalizeLogEvent(e)))
|
||||
}
|
||||
|
||||
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
|
||||
|
||||
Reference in New Issue
Block a user