Implement AC Nautik PDF Export, E2E Encrypted Photos, and Background GPS Route Tracking
This commit is contained in:
@@ -5,6 +5,7 @@ import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { decryptJson, encryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
||||
|
||||
@@ -110,6 +111,20 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadPdf = async (entryId: string, date: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await downloadLogbookPagePdf(logbookId, entryId, date)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download PDF:', err)
|
||||
setError(err.message || 'Failed to generate PDF export.')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
@@ -269,6 +284,10 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
|
||||
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
|
||||
@@ -4,7 +4,18 @@ import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock } from 'lucide-react'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Play, Square, Navigation } from 'lucide-react'
|
||||
import PhotoCapture from './PhotoCapture.tsx'
|
||||
import {
|
||||
startGpsTracking,
|
||||
stopGpsTracking,
|
||||
isGpsTrackingActive,
|
||||
getDecryptedGpsTrack,
|
||||
downloadGpxFile,
|
||||
getDistanceMeters,
|
||||
type GpsWaypoint
|
||||
} from '../services/gpsTracker.js'
|
||||
|
||||
interface LogEntryEditorProps {
|
||||
entryId: string
|
||||
@@ -79,10 +90,16 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [weatherLoading, setWeatherLoading] = useState(false)
|
||||
|
||||
// GPS Tracking States
|
||||
const [waypoints, setWaypoints] = useState<GpsWaypoint[]>([])
|
||||
const [trackingActive, setTrackingActive] = useState(false)
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
// Auto-calculate Freshwater Consumption
|
||||
useEffect(() => {
|
||||
const morning = parseFloat(fwMorning) || 0
|
||||
@@ -146,6 +163,84 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
loadEntry()
|
||||
}, [entryId])
|
||||
|
||||
// GPS Tracking logic
|
||||
const loadGpsTrack = async () => {
|
||||
try {
|
||||
const track = await getDecryptedGpsTrack(entryId)
|
||||
setWaypoints(track)
|
||||
} catch (e) {
|
||||
console.warn('Failed to load GPS track:', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadGpsTrack()
|
||||
setTrackingActive(isGpsTrackingActive(entryId))
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTrackingActive(isGpsTrackingActive(entryId))
|
||||
if (isGpsTrackingActive(entryId)) {
|
||||
loadGpsTrack()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [entryId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trackingActive) return
|
||||
const timer = setInterval(() => {
|
||||
setTick((t) => t + 1)
|
||||
}, 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [trackingActive])
|
||||
|
||||
const handleStartTracking = async () => {
|
||||
try {
|
||||
await startGpsTracking(logbookId, entryId, (newWp) => {
|
||||
setWaypoints((prev) => [...prev, newWp])
|
||||
})
|
||||
setTrackingActive(true)
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to start GPS tracking')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopTracking = () => {
|
||||
stopGpsTracking()
|
||||
setTrackingActive(false)
|
||||
loadGpsTrack()
|
||||
}
|
||||
|
||||
const calculateTotalDistanceSailed = () => {
|
||||
if (waypoints.length < 2) return 0
|
||||
let totalMeters = 0
|
||||
for (let i = 1; i < waypoints.length; i++) {
|
||||
totalMeters += getDistanceMeters(
|
||||
waypoints[i - 1].lat,
|
||||
waypoints[i - 1].lng,
|
||||
waypoints[i].lat,
|
||||
waypoints[i].lng
|
||||
)
|
||||
}
|
||||
return Number((totalMeters / 1852).toFixed(2))
|
||||
}
|
||||
|
||||
const calculateDurationStr = () => {
|
||||
if (tick < 0 || waypoints.length < 2) return '00:00:00'
|
||||
const first = waypoints[0].timestamp
|
||||
const last = trackingActive ? Date.now() : waypoints[waypoints.length - 1].timestamp
|
||||
const diffMs = last - first
|
||||
if (diffMs <= 0) return '00:00:00'
|
||||
|
||||
const secs = Math.floor(diffMs / 1000) % 60
|
||||
const mins = Math.floor(diffMs / (1000 * 60)) % 60
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(hours)}:${pad(mins)}:${pad(secs)}`
|
||||
}
|
||||
|
||||
const handleGetGps = () => {
|
||||
if (!navigator.geolocation) {
|
||||
alert('Geolocation is not supported by your browser')
|
||||
@@ -275,6 +370,19 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
setEvents((prev) => prev.filter((_, idx) => idx !== index))
|
||||
}
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await downloadLogbookPagePdf(logbookId, entryId, date)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download PDF:', err)
|
||||
setError(err.message || 'Failed to generate PDF export.')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
@@ -360,16 +468,29 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
{/* Top Header Controls */}
|
||||
<div className="form-card" style={{ paddingBottom: '20px' }}>
|
||||
<div className="section-title-bar">
|
||||
<button className="btn-back" onClick={onBack} style={{ padding: '6px 12px' }}>
|
||||
<ChevronLeft size={16} />
|
||||
{t('logs.back_to_list')}
|
||||
</button>
|
||||
<div className="form-header" style={{ margin: 0 }}>
|
||||
<FileText size={24} className="form-icon" />
|
||||
<h2>
|
||||
{t('logs.route')}: {departure || '...'} → {destination || '...'} (Tag {dayOfTravel})
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<button className="btn-back" onClick={onBack} style={{ padding: '6px 12px' }}>
|
||||
<ChevronLeft size={16} />
|
||||
{t('logs.back_to_list')}
|
||||
</button>
|
||||
<div className="form-header" style={{ margin: 0 }}>
|
||||
<FileText size={24} className="form-icon" />
|
||||
<h2>
|
||||
{t('logs.route')}: {departure || '...'} → {destination || '...'} (Tag {dayOfTravel})
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={saving || exporting}
|
||||
style={{ width: 'auto', padding: '8px 16px' }}
|
||||
>
|
||||
<Download size={16} />
|
||||
<span>{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -829,6 +950,77 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GPS Tracking Dashboard */}
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Navigation size={20} className={`form-icon ${trackingActive ? 'spin' : ''}`} style={{ color: trackingActive ? '#10b981' : '#f59e0b', animationDuration: '3s' }} />
|
||||
<h3>{t('logs.gps_tracking_title')}</h3>
|
||||
<span className={`sync-badge ${trackingActive ? 'synced' : 'local'}`} style={{ marginLeft: 'auto', background: trackingActive ? 'rgba(16, 185, 129, 0.15)' : 'rgba(148, 163, 184, 0.15)', color: trackingActive ? '#10b981' : '#94a3b8' }}>
|
||||
{trackingActive ? t('logs.gps_tracking_status_active') : t('logs.gps_tracking_status_inactive')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '16px', margin: '16px 0' }}>
|
||||
<div className="glass" style={{ padding: '12px', borderRadius: '8px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{t('logs.gps_tracking_stat_duration')}</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', fontFamily: 'monospace', color: '#f8fafc' }}>
|
||||
{calculateDurationStr()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass" style={{ padding: '12px', borderRadius: '8px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{t('logs.gps_tracking_stat_distance')}</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', color: '#f8fafc' }}>
|
||||
{calculateTotalDistanceSailed()} sm
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass" style={{ padding: '12px', borderRadius: '8px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{t('logs.gps_tracking_stat_waypoints')}</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', color: '#f8fafc' }}>
|
||||
{waypoints.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{!trackingActive ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={handleStartTracking}
|
||||
style={{ width: 'auto', padding: '10px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
<Play size={16} />
|
||||
{t('logs.gps_tracking_btn_start')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={handleStopTracking}
|
||||
style={{ width: 'auto', padding: '10px 20px', display: 'flex', gap: '8px', alignItems: 'center', background: '#ef4444' }}
|
||||
>
|
||||
<Square size={16} />
|
||||
{t('logs.gps_tracking_btn_stop')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => downloadGpxFile(waypoints, date)}
|
||||
disabled={waypoints.length === 0}
|
||||
style={{ width: 'auto', padding: '10px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
<Download size={16} />
|
||||
{t('logs.gps_tracking_btn_gpx')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PhotoCapture entryId={entryId} logbookId={logbookId} />
|
||||
|
||||
{/* Section 4: Sign-Off Signatures */}
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { Camera, Trash2 } from 'lucide-react'
|
||||
|
||||
interface PhotoCaptureProps {
|
||||
entryId: string
|
||||
logbookId: string
|
||||
}
|
||||
|
||||
interface DecryptedPhoto {
|
||||
payloadId: string
|
||||
image: string
|
||||
caption: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps) {
|
||||
const { t } = useTranslation()
|
||||
const [caption, setCaption] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Reactively query local photos database
|
||||
const localPhotos = useLiveQuery(
|
||||
() => db.photos.where({ entryId }).toArray(),
|
||||
[entryId]
|
||||
)
|
||||
|
||||
// Decrypt photos on query updates
|
||||
useEffect(() => {
|
||||
async function decryptPhotosList() {
|
||||
if (!localPhotos) return
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) return
|
||||
|
||||
const list: DecryptedPhoto[] = []
|
||||
for (const p of localPhotos) {
|
||||
try {
|
||||
const decrypted = await decryptJson(p.encryptedData, p.iv, p.tag, masterKey)
|
||||
if (decrypted) {
|
||||
list.push({
|
||||
payloadId: p.payloadId,
|
||||
image: decrypted.image,
|
||||
caption: decrypted.caption || '',
|
||||
updatedAt: p.updatedAt
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt photo attachment:', e)
|
||||
}
|
||||
}
|
||||
setDecryptedPhotos(list)
|
||||
}
|
||||
|
||||
decryptPhotosList()
|
||||
}, [localPhotos])
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const img = new Image()
|
||||
img.onload = async () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Could not get canvas context')
|
||||
|
||||
let width = img.width
|
||||
let height = img.height
|
||||
const MAX_WIDTH = 1280
|
||||
const MAX_HEIGHT = 720
|
||||
|
||||
// Calculate resizing conserving aspect ratio
|
||||
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
|
||||
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
|
||||
width = Math.round(width * ratio)
|
||||
height = Math.round(height * ratio)
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Compress to JPEG, 70% quality
|
||||
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
|
||||
|
||||
// Encrypt
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
|
||||
const photoId = window.crypto.randomUUID()
|
||||
const photoPayload = {
|
||||
image: compressedBase64,
|
||||
caption: caption.trim()
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(photoPayload, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Store locally
|
||||
await db.photos.put({
|
||||
payloadId: photoId,
|
||||
entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
caption: '', // stored encrypted inside payload
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Queue for background sync
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
type: 'photo',
|
||||
payloadId: photoId,
|
||||
logbookId,
|
||||
data: JSON.stringify({
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
entryId
|
||||
}),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
setCaption('')
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to process image:', err)
|
||||
setError(err.message || 'Failed to process image')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
img.src = event.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleDelete = async (photoId: string) => {
|
||||
if (window.confirm(t('logs.photo_delete_confirm'))) {
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.photos.delete(photoId)
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'photo',
|
||||
payloadId: photoId,
|
||||
logbookId,
|
||||
data: '',
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete photo:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggerSelect = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-card mt-6">
|
||||
<div className="form-header mb-4">
|
||||
<Camera size={20} className="form-icon" />
|
||||
<h3>{t('logs.photos_title')}</h3>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
{/* Upload area */}
|
||||
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
||||
<label>{t('logs.photo_caption_label')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.photo_caption_placeholder')}
|
||||
className="input-text"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={triggerSelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Camera size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||
</button>
|
||||
</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">
|
||||
<div className="photo-container">
|
||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||
<button
|
||||
type="button"
|
||||
className="photo-btn-delete"
|
||||
onClick={() => 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user