mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
@@ -75,9 +75,29 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
||||
if (v !== value) onSave(v)
|
||||
}
|
||||
|
||||
const handlePaste = (e) => {
|
||||
if (type !== 'number') return
|
||||
e.preventDefault()
|
||||
let text = e.clipboardData.getData('text').trim()
|
||||
// Strip everything except digits, dots, commas, minus
|
||||
text = text.replace(/[^\d.,-]/g, '')
|
||||
// Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal
|
||||
const lastComma = text.lastIndexOf(',')
|
||||
const lastDot = text.lastIndexOf('.')
|
||||
const decimalPos = Math.max(lastComma, lastDot)
|
||||
if (decimalPos > -1) {
|
||||
const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '')
|
||||
const decPart = text.substring(decimalPos + 1)
|
||||
text = intPart + '.' + decPart
|
||||
} else {
|
||||
text = text.replace(/[.,]/g, '')
|
||||
}
|
||||
setEditValue(text)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)} onBlur={save}
|
||||
onChange={e => setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste}
|
||||
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
|
||||
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
|
||||
placeholder={placeholder} />
|
||||
@@ -131,6 +151,7 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }}
|
||||
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
|
||||
@@ -313,7 +313,6 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
padding: 16,
|
||||
fontFamily: FONT,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<form
|
||||
style={{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { filesApi } from '../../api/client'
|
||||
@@ -30,6 +30,18 @@ function formatSize(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
async function triggerDownload(url: string, filename: string) {
|
||||
const authUrl = await getAuthUrl(url, 'download')
|
||||
const res = await fetch(authUrl)
|
||||
const blob = await res.blob()
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove() }, 100)
|
||||
}
|
||||
|
||||
function formatDateWithLocale(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
@@ -113,6 +125,12 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||
title={t('files.openTab')}>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerDownload(file.url, file.original_name)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||
title={t('files.download') || 'Download'}>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
@@ -514,6 +532,10 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button onClick={() => triggerDownload(file.url, file.original_name)} title={t('files.download') || 'Download'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
{can('file_delete', trip) && <button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={14} />
|
||||
@@ -734,6 +756,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
<Download size={13} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
<button onClick={() => setPreviewFile(null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
|
||||
@@ -678,7 +678,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } set('price', t) }}
|
||||
placeholder="0.00"
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ interface AdminUser {
|
||||
last_login?: string | null
|
||||
online?: boolean
|
||||
oidc_issuer?: string | null
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface AdminStats {
|
||||
@@ -605,9 +606,13 @@ export default function AdminPage(): React.ReactElement {
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-700">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{u.avatar_url ? (
|
||||
<img src={u.avatar_url} alt={u.username} className="w-8 h-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-700">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2" style={{ borderColor: 'var(--bg-card)', background: u.online ? '#22c55e' : '#94a3b8' }} />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -40,8 +40,8 @@ export const isDocker = (() => {
|
||||
|
||||
export function listUsers() {
|
||||
const users = db.prepare(
|
||||
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
||||
).all() as Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'>[];
|
||||
'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
||||
).all() as (Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'> & { avatar?: string | null })[];
|
||||
let onlineUserIds = new Set<number>();
|
||||
try {
|
||||
const { getOnlineUserIds } = require('../websocket');
|
||||
@@ -49,6 +49,7 @@ export function listUsers() {
|
||||
} catch { /* */ }
|
||||
return users.map(u => ({
|
||||
...u,
|
||||
avatar_url: u.avatar ? `/uploads/avatars/${u.avatar}` : null,
|
||||
created_at: utcSuffix(u.created_at),
|
||||
updated_at: utcSuffix(u.updated_at as string),
|
||||
last_login: utcSuffix(u.last_login),
|
||||
|
||||
@@ -159,7 +159,7 @@ function createNotification(input: NotificationInput): number[] {
|
||||
notification: {
|
||||
...row,
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: sender?.avatar ?? null,
|
||||
sender_avatar: sender?.avatar ? `/uploads/avatars/${sender.avatar}` : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -219,7 +219,7 @@ export function createNotificationForRecipient(
|
||||
notification: {
|
||||
...row,
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: sender?.avatar ?? null,
|
||||
sender_avatar: sender?.avatar ? `/uploads/avatars/${sender.avatar}` : null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -249,7 +249,12 @@ function getNotifications(
|
||||
const { total } = db.prepare(`SELECT COUNT(*) as total FROM notifications ${wherePlain}`).get(userId) as { total: number };
|
||||
const { unread_count } = db.prepare('SELECT COUNT(*) as unread_count FROM notifications WHERE recipient_id = ? AND is_read = 0').get(userId) as { unread_count: number };
|
||||
|
||||
return { notifications: rows, total, unread_count };
|
||||
const mapped = rows.map(r => ({
|
||||
...r,
|
||||
sender_avatar: r.sender_avatar ? `/uploads/avatars/${r.sender_avatar}` : null,
|
||||
}));
|
||||
|
||||
return { notifications: mapped, total, unread_count };
|
||||
}
|
||||
|
||||
function getUnreadCount(userId: number): number {
|
||||
@@ -326,9 +331,14 @@ async function respondToBoolean(
|
||||
WHERE n.id = ?
|
||||
`).get(notificationId) as NotificationRow;
|
||||
|
||||
broadcastToUser(userId, { type: 'notification:updated', notification: updated });
|
||||
const mappedUpdated = {
|
||||
...updated,
|
||||
sender_avatar: updated.sender_avatar ? `/uploads/avatars/${updated.sender_avatar}` : null,
|
||||
};
|
||||
|
||||
return { success: true, notification: updated };
|
||||
broadcastToUser(userId, { type: 'notification:updated', notification: mappedUpdated });
|
||||
|
||||
return { success: true, notification: mappedUpdated };
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user