import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Lock, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact'; import { copyTextToClipboard } from '@/lib/clipboard'; import LoadingState from '@/components/LoadingState'; import type { Send, SendDraft } from '@/lib/types'; import { t } from '@/lib/i18n'; interface SendsPageProps { sends: Send[]; loading: boolean; onRefresh: () => Promise; onCreate: (draft: SendDraft, autoCopyLink: boolean) => Promise; onUpdate: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise; onDelete: (send: Send) => Promise; onBulkDelete: (ids: string[]) => Promise; uploadingSendFileName: string; sendUploadPercent: number | null; mobileSidebarToggleKey: number; onNotify: (type: 'success' | 'error', text: string) => void; } type SendTypeFilter = 'all' | 'text' | 'file'; const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1'; const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)'; function daysFromNow(iso: string | null | undefined, fallback: number): string { if (!iso) return String(fallback); const d = new Date(iso).getTime(); if (!Number.isFinite(d)) return String(fallback); const diff = d - Date.now(); const days = Math.ceil(diff / (24 * 60 * 60 * 1000)); return String(Math.max(days, 0)); } function buildDefaultDraft(): SendDraft { return { type: 'text', name: '', notes: '', text: '', file: null, deletionDays: '7', expirationDays: '0', maxAccessCount: '', password: '', hasPassword: false, disabled: false, }; } function draftFromSend(send: Send): SendDraft { return { id: send.id, type: Number(send.type) === 1 ? 'file' : 'text', name: send.decName || '', notes: send.decNotes || '', text: send.decText || '', file: null, deletionDays: daysFromNow(send.deletionDate, 7), expirationDays: daysFromNow(send.expirationDate, 0), maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '', password: '', hasPassword: !!send.password, disabled: !!send.disabled, }; } export default function SendsPage(props: SendsPageProps) { const getInitialIsMobileLayout = () => typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(MOBILE_LAYOUT_QUERY).matches : false; const [search, setSearch] = useState(''); const [typeFilter, setTypeFilter] = useState('all'); const [selectedId, setSelectedId] = useState(null); const [isEditing, setIsEditing] = useState(false); const [isCreating, setIsCreating] = useState(false); const [busy, setBusy] = useState(false); const [draft, setDraft] = useState(null); const [showPassword, setShowPassword] = useState(false); const [selectedMap, setSelectedMap] = useState>({}); const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout); const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list'); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const mobileSidebarToggleKeyRef = useRef(props.mobileSidebarToggleKey); const [autoCopyLink, setAutoCopyLink] = useState(() => { try { return localStorage.getItem(AUTO_COPY_KEY) === '1'; } catch { return false; } }); const sendUploadLabel = props.sendUploadPercent == null ? t('txt_uploading_file_named', { name: props.uploadingSendFileName || t('txt_file') }) : t('txt_uploading_file_named_percent', { name: props.uploadingSendFileName || t('txt_file'), percent: props.sendUploadPercent, }); useEffect(() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; const media = window.matchMedia(MOBILE_LAYOUT_QUERY); const sync = () => setIsMobileLayout(media.matches); sync(); if (typeof media.addEventListener === 'function') { media.addEventListener('change', sync); return () => media.removeEventListener('change', sync); } media.addListener(sync); return () => media.removeListener(sync); }, []); useEffect(() => { if (props.mobileSidebarToggleKey === mobileSidebarToggleKeyRef.current) return; mobileSidebarToggleKeyRef.current = props.mobileSidebarToggleKey; setMobileSidebarOpen((open) => !open); }, [props.mobileSidebarToggleKey]); useEffect(() => { try { localStorage.setItem(AUTO_COPY_KEY, autoCopyLink ? '1' : '0'); } catch { // ignore storage errors } }, [autoCopyLink]); useEffect(() => { if (!isMobileLayout) { setMobilePanel('list'); setMobileSidebarOpen(false); return; } if (isEditing) { setMobilePanel('edit'); } else if (!selectedId) { setMobilePanel('list'); } }, [isMobileLayout, isEditing, selectedId]); const filteredSends = useMemo(() => { const q = search.trim().toLowerCase(); return props.sends.filter((send) => { if (typeFilter === 'text' && Number(send.type) !== 0) return false; if (typeFilter === 'file' && Number(send.type) !== 1) return false; if (!q) return true; const name = (send.decName || '').toLowerCase(); const text = (send.decText || '').toLowerCase(); return name.includes(q) || text.includes(q); }); }, [props.sends, search, typeFilter]); useEffect(() => { if (!filteredSends.length) { setSelectedId(null); return; } if (!selectedId || !filteredSends.some((x) => x.id === selectedId)) { setSelectedId(filteredSends[0].id); setIsEditing(false); setIsCreating(false); setDraft(null); } }, [filteredSends, selectedId]); const selectedSend = useMemo( () => props.sends.find((x) => x.id === selectedId) || null, [props.sends, selectedId] ); const selectedIds = useMemo(() => Object.keys(selectedMap).filter((id) => selectedMap[id]), [selectedMap]); const selectedCount = selectedIds.length; async function saveDraft(): Promise { if (!draft) return; if (!draft.name.trim()) { props.onNotify('error', t('txt_name_is_required')); return; } if (draft.type === 'text' && !draft.text.trim()) { props.onNotify('error', t('txt_text_is_required')); return; } if (draft.type === 'file' && isCreating && !draft.file) { props.onNotify('error', t('txt_please_select_a_file')); return; } setBusy(true); try { if (isCreating) { await props.onCreate(draft, autoCopyLink); setSelectedId(null); } else if (selectedSend) { await props.onUpdate(selectedSend, draft, autoCopyLink); } setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); if (isMobileLayout) setMobilePanel('detail'); } finally { setBusy(false); } } async function removeSend(send: Send): Promise { setBusy(true); try { await props.onDelete(send); if (selectedId === send.id) setSelectedId(null); setIsEditing(false); setDraft(null); if (isMobileLayout) setMobilePanel('list'); } finally { setBusy(false); } } async function removeSelected(): Promise { if (!selectedCount) return; setBusy(true); try { await props.onBulkDelete(selectedIds); setSelectedMap({}); } finally { setBusy(false); } } function getAccessUrl(send: Send): string { const rawUrl = send.shareUrl || `/send/${send.accessId}`; if (/^https?:\/\//i.test(rawUrl)) return rawUrl; if (rawUrl.startsWith('/#/')) return `${window.location.origin}${rawUrl}`; if (rawUrl.startsWith('#/')) return `${window.location.origin}/${rawUrl}`; if (rawUrl.startsWith('/')) return `${window.location.origin}/#${rawUrl}`; return `${window.location.origin}/#/${rawUrl.replace(/^\/+/, '')}`; } function copyAccessUrl(send: Send): void { const url = getAccessUrl(send); void copyTextToClipboard(url, { successMessage: t('txt_link_copied') }); } return (
{isMobileLayout && (
{ if (!mobileSidebarOpen) return; setMobileSidebarOpen(false); }} /> )}
setSearch((e.currentTarget as HTMLInputElement).value)} />
{!!selectedCount && ( )}
{props.loading && !filteredSends.length && } {filteredSends.map((send, index) => (
{ const target = event.target as HTMLElement; if (target.closest('.row-check')) return; setSelectedId(send.id); setIsEditing(false); setIsCreating(false); setDraft(null); if (isMobileLayout) setMobilePanel('detail'); setMobileSidebarOpen(false); }} > event.stopPropagation()} onInput={(e) => setSelectedMap((prev) => ({ ...prev, [send.id]: (e.currentTarget as HTMLInputElement).checked, })) } />
))} {!props.loading && !filteredSends.length &&
{t('txt_no_sends')}
}
{isMobileLayout && mobilePanel !== 'list' && (
)} {isEditing && draft && (

{isCreating ? t('txt_new_send') : t('txt_edit_send')}

{!!props.uploadingSendFileName &&
{sendUploadLabel}
}
{draft.type === 'file' ? ( ) : (