mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-23 23:31:47 +00:00
refactoring: TypeScript migration, security fixes,
This commit is contained in:
+61
-6
@@ -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'
|
||||
|
||||
+104
-11
@@ -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)
|
||||
+13
-2
@@ -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')
|
||||
+53
-4
@@ -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)
|
||||
+11
-1
@@ -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'
|
||||
Reference in New Issue
Block a user