refactoring: TypeScript migration, security fixes,

This commit is contained in:
Maurice
2026-03-27 18:40:18 +01:00
parent 510475a46f
commit 8396a75223
150 changed files with 8116 additions and 8467 deletions
@@ -5,6 +5,25 @@ import { collabApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import type { User } from '../../types'
interface ChatReaction {
emoji: string
count: number
users: { id: number; username: string }[]
}
interface ChatMessage {
id: number
trip_id: number
user_id: number
text: string
reply_to_id: number | null
reactions: ChatReaction[]
created_at: string
user?: { username: string; avatar_url: string | null }
reply_to?: ChatMessage | null
}
// ── Twemoji helper (Apple-style emojis via CDN) ──
function emojiToCodepoint(emoji) {
@@ -75,7 +94,14 @@ function shouldShowDateSeparator(msg, prevMsg) {
}
/* ── Emoji Picker ── */
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }) {
interface EmojiPickerProps {
onSelect: (emoji: string) => void
onClose: () => void
anchorRef: React.RefObject<HTMLElement | null>
containerRef: React.RefObject<HTMLElement | null>
}
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
const ref = useRef(null)
@@ -142,7 +168,14 @@ function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }) {
/* ── Reaction Quick Menu (right-click) ── */
const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
function ReactionMenu({ x, y, onReact, onClose }) {
interface ReactionMenuProps {
x: number
y: number
onReact: (emoji: string) => void
onClose: () => void
}
function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
const ref = useRef(null)
useEffect(() => {
@@ -179,7 +212,11 @@ function ReactionMenu({ x, y, onReact, onClose }) {
}
/* ── Message Text with clickable URLs ── */
function MessageText({ text }) {
interface MessageTextProps {
text: string
}
function MessageText({ text }: MessageTextProps) {
const parts = text.split(URL_REGEX)
const urls = text.match(URL_REGEX) || []
const result = []
@@ -198,7 +235,14 @@ function MessageText({ text }) {
const URL_REGEX = /https?:\/\/[^\s<>"']+/g
const previewCache = {}
function LinkPreview({ url, tripId, own, onLoad }) {
interface LinkPreviewProps {
url: string
tripId: number
own: boolean
onLoad: (() => void) | undefined
}
function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
const [data, setData] = useState(previewCache[url] || null)
const [loading, setLoading] = useState(!previewCache[url])
@@ -252,7 +296,13 @@ function LinkPreview({ url, tripId, own, onLoad }) {
}
/* ── Reaction Badge with NOMAD tooltip ── */
function ReactionBadge({ reaction, currentUserId, onReact }) {
interface ReactionBadgeProps {
reaction: ChatReaction
currentUserId: number
onReact: () => void
}
function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef(null)
@@ -295,7 +345,12 @@ function ReactionBadge({ reaction, currentUserId, onReact }) {
}
/* ── Main Component ── */
export default function CollabChat({ tripId, currentUser }) {
interface CollabChatProps {
tripId: number
currentUser: User
}
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
@@ -1,16 +1,56 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import DOM from 'react-dom'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import type { User } from '../../types'
interface NoteFile {
id: number
filename: string
original_name: string
mime_type: string
url?: string
}
interface CollabNote {
id: number
trip_id: number
title: string
content: string
category: string
website: string | null
pinned: boolean
color: string | null
username: string
avatar_url: string | null
avatar: string | null
user_id: number
created_at: string
author?: { username: string; avatar: string | null }
user?: { username: string; avatar: string | null }
files?: NoteFile[]
}
interface NoteAuthor {
username: string
avatar?: string | null
}
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
// ── Website Thumbnail (fetches OG image) ────────────────────────────────────
const ogCache = {}
function WebsiteThumbnail({ url, tripId, color }) {
interface WebsiteThumbnailProps {
url: string
tripId: number
color: string
}
function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) {
const [data, setData] = useState(ogCache[url] || null)
const [failed, setFailed] = useState(false)
@@ -46,7 +86,12 @@ function WebsiteThumbnail({ url, tripId, color }) {
}
// ── File Preview Portal ─────────────────────────────────────────────────────
function FilePreviewPortal({ file, onClose }) {
interface FilePreviewPortalProps {
file: NoteFile | null
onClose: () => void
}
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
if (!file) return null
const url = file.url || `/uploads/${file.filename}`
const isImage = file.mime_type?.startsWith('image/')
@@ -120,7 +165,12 @@ const formatTimestamp = (ts, t, locale) => {
}
// ── Avatar ──────────────────────────────────────────────────────────────────
function UserAvatar({ user, size = 14 }) {
interface UserAvatarProps {
user: NoteAuthor | null
size?: number
}
function UserAvatar({ user, size = 14 }: UserAvatarProps) {
if (!user) return null
if (user.avatar) {
return (
@@ -161,7 +211,19 @@ function UserAvatar({ user, size = 14 }) {
}
// ── New Note Modal (portal to body) ─────────────────────────────────────────
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }) {
interface NoteFormModalProps {
onClose: () => void
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
onDeleteFile: (noteId: number, fileId: number) => Promise<void>
existingCategories: string[]
categoryColors: Record<string, string>
getCategoryColor: (category: string) => string
note: CollabNote | null
tripId: number
t: (key: string) => string
}
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
const isEdit = !!note
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
@@ -236,7 +298,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
onPaste={e => {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
for (const item of Array.from(items)) {
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
e.preventDefault()
const file = item.getAsFile()
@@ -390,7 +452,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.attachFiles')}
</div>
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = '' }} />
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Existing attachments (edit mode) */}
{existingAttachments.map(a => {
@@ -448,7 +510,12 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
)
}
function EditableCatName({ name, onRename }) {
interface EditableCatNameProps {
name: string
onRename: (newName: string) => void
}
function EditableCatName({ name, onRename }: EditableCatNameProps) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(name)
const inputRef = useRef(null)
@@ -477,7 +544,16 @@ function EditableCatName({ name, onRename }) {
}
// ── Category Settings Modal ──────────────────────────────────────────────────
function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }) {
interface CategorySettingsModalProps {
onClose: () => void
categories: string[]
categoryColors: Record<string, string>
onSave: (colors: Record<string, string>) => void
onRenameCategory: (oldName: string, newName: string) => Promise<void>
t: (key: string) => string
}
function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }: CategorySettingsModalProps) {
const [localColors, setLocalColors] = useState({ ...categoryColors })
const [renames, setRenames] = useState({}) // { oldName: newName }
const [newCatName, setNewCatName] = useState('')
@@ -608,7 +684,19 @@ function CategorySettingsModal({ onClose, categories, categoryColors, onSave, on
}
// ── Note Card ───────────────────────────────────────────────────────────────
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile, getCategoryColor, tripId, t }) {
interface NoteCardProps {
note: CollabNote
currentUser: User
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
onDelete: (noteId: number) => Promise<void>
onEdit: (note: CollabNote) => void
onPreviewFile: (file: NoteFile) => void
getCategoryColor: (category: string) => string
tripId: number
t: (key: string) => string
}
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
const [hovered, setHovered] = useState(false)
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
@@ -773,7 +861,12 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile
}
// ── Main Component ──────────────────────────────────────────────────────────
export default function CollabNotes({ tripId, currentUser }) {
interface CollabNotesProps {
tripId: number
currentUser: User
}
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
const { t } = useTranslation()
const [notes, setNotes] = useState([])
const [loading, setLoading] = useState(true)
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import { useState, useEffect } from 'react'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
@@ -23,7 +23,18 @@ const card = {
overflow: 'hidden', minHeight: 0,
}
export default function CollabPanel({ tripId, tripMembers = [] }) {
interface TripMember {
id: number
username: string
avatar_url?: string | null
}
interface CollabPanelProps {
tripId: number
tripMembers?: TripMember[]
}
export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelProps) {
const { user } = useAuthStore()
const { t } = useTranslation()
const [mobileTab, setMobileTab] = useState('chat')
@@ -4,6 +4,30 @@ import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import ReactDOM from 'react-dom'
import type { User } from '../../types'
interface PollVoter {
user_id: number
username: string
avatar_url: string | null
}
interface PollOption {
id: number
text: string
voters: PollVoter[]
}
interface Poll {
id: number
question: string
options: PollOption[]
multi_choice: boolean
is_closed: boolean
deadline: string | null
created_by: number
created_at: string
}
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
@@ -29,7 +53,13 @@ function totalVotes(poll) {
}
// ── Create Poll Modal ────────────────────────────────────────────────────────
function CreatePollModal({ onClose, onCreate, t }) {
interface CreatePollModalProps {
onClose: () => void
onCreate: (data: { question: string; options: string[]; multi_choice: boolean }) => Promise<void>
t: (key: string) => string
}
function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
const [question, setQuestion] = useState('')
const [options, setOptions] = useState(['', ''])
const [multiChoice, setMultiChoice] = useState(false)
@@ -111,7 +141,12 @@ function CreatePollModal({ onClose, onCreate, t }) {
}
// ── Voter Chip with custom tooltip ────────────────────────────────────────────
function VoterChip({ voter, offset }) {
interface VoterChipProps {
voter: PollVoter
offset: boolean
}
function VoterChip({ voter, offset }: VoterChipProps) {
const [hover, setHover] = useState(false)
const ref = React.useRef(null)
const [pos, setPos] = useState({ top: 0, left: 0 })
@@ -152,7 +187,16 @@ function VoterChip({ voter, offset }) {
}
// ── Poll Card ────────────────────────────────────────────────────────────────
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }) {
interface PollCardProps {
poll: Poll
currentUser: User
onVote: (pollId: number, optionId: number) => Promise<void>
onClose: (pollId: number) => Promise<void>
onDelete: (pollId: number) => Promise<void>
t: (key: string) => string
}
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) {
const total = totalVotes(poll)
const isClosed = poll.is_closed || isExpired(poll.deadline)
const remaining = timeRemaining(poll.deadline)
@@ -286,7 +330,12 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }) {
}
// ── Main Component ───────────────────────────────────────────────────────────
export default function CollabPolls({ tripId, currentUser }) {
interface CollabPollsProps {
tripId: number
currentUser: User
}
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
const { t } = useTranslation()
const [polls, setPolls] = useState([])
const [loading, setLoading] = useState(true)
@@ -26,7 +26,17 @@ function formatDayLabel(date, t, locale) {
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
}
export default function WhatsNextWidget({ tripMembers = [] }) {
interface TripMember {
id: number
username: string
avatar_url?: string | null
}
interface WhatsNextWidgetProps {
tripMembers?: TripMember[]
}
export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetProps) {
const { days, assignments } = useTripStore()
const { t, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'