mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add PublicSendPage and SendsPage components for managing sends
This commit is contained in:
@@ -77,18 +77,20 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="invite-toolbar">
|
||||
<div className="actions">
|
||||
<input
|
||||
className="input small"
|
||||
type="number"
|
||||
value={inviteHours}
|
||||
min={1}
|
||||
max={720}
|
||||
onInput={(e) => setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))}
|
||||
/>
|
||||
<span className="muted-inline">hours</span>
|
||||
<div className="actions invite-create-group">
|
||||
<label className="field invite-hours-field">
|
||||
<span>邀请码有效时长(小时)</span>
|
||||
<input
|
||||
className="input small"
|
||||
type="number"
|
||||
value={inviteHours}
|
||||
min={1}
|
||||
max={720}
|
||||
onInput={(e) => setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void props.onCreateInvite(inviteHours)}>
|
||||
Create Invite
|
||||
创建时效邀请码
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteAllInvites()}>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { Download, Eye, Lock } from 'lucide-preact';
|
||||
import { accessPublicSend, accessPublicSendFile, decryptPublicSend } from '@/lib/api';
|
||||
|
||||
interface PublicSendPageProps {
|
||||
accessId: string;
|
||||
keyPart: string | null;
|
||||
}
|
||||
|
||||
export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [password, setPassword] = useState('');
|
||||
const [needPassword, setNeedPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [sendData, setSendData] = useState<any>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function loadSend(pass?: string): Promise<void> {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await accessPublicSend(props.accessId, pass);
|
||||
if (!props.keyPart) {
|
||||
setError('This link is missing decryption key.');
|
||||
setSendData(null);
|
||||
return;
|
||||
}
|
||||
const decrypted = await decryptPublicSend(data, props.keyPart);
|
||||
setSendData(decrypted);
|
||||
setNeedPassword(false);
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
if (err.status === 401) {
|
||||
setNeedPassword(true);
|
||||
setError('This send is password protected.');
|
||||
} else {
|
||||
setError(err.message || 'Failed to open send');
|
||||
}
|
||||
setSendData(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(): Promise<void> {
|
||||
if (!sendData?.id || !sendData?.file?.id) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const url = await accessPublicSendFile(sendData.id, sendData.file.id, password || undefined);
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('Download failed');
|
||||
const blob = await resp.blob();
|
||||
const obj = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = obj;
|
||||
a.download = sendData.decFileName || sendData.file?.fileName || 'send-file';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(obj);
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
setError(err.message || 'Download failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadSend();
|
||||
}, [props.accessId, props.keyPart]);
|
||||
|
||||
return (
|
||||
<div className="auth-page public-send-page">
|
||||
<div className="auth-card">
|
||||
<h1>NodeWarden Send</h1>
|
||||
{loading && <p className="muted">Loading...</p>}
|
||||
|
||||
{!loading && needPassword && (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>Password</span>
|
||||
<div className="password-wrap">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void loadSend(password)}>
|
||||
<Lock size={14} className="btn-icon" /> Unlock Send
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && sendData && (
|
||||
<>
|
||||
<h2 style={{ marginTop: '8px' }}>{sendData.decName || '(No Name)'}</h2>
|
||||
{sendData.type === 0 ? (
|
||||
<div className="card" style={{ marginTop: '10px' }}>
|
||||
<div className="notes">{sendData.decText || ''}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card" style={{ marginTop: '10px' }}>
|
||||
<div className="kv-line">
|
||||
<span>File</span>
|
||||
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || 'Encrypted File'}</strong>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void downloadFile()}>
|
||||
<Download size={14} className="btn-icon" /> Download
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!!sendData.expirationDate && <p className="muted">Expires at: {sendData.expirationDate}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !sendData && !needPassword && !error && (
|
||||
<p className="muted">
|
||||
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> Send unavailable.
|
||||
</p>
|
||||
)}
|
||||
{!!error && <p className="local-error">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Send as SendIcon, Trash2 } from 'lucide-preact';
|
||||
import type { Send, SendDraft } from '@/lib/types';
|
||||
|
||||
interface SendsPageProps {
|
||||
sends: Send[];
|
||||
loading: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
onCreate: (draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||
onUpdate: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||
onDelete: (send: Send) => Promise<void>;
|
||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||
}
|
||||
|
||||
type SendTypeFilter = 'all' | 'text' | 'file';
|
||||
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
|
||||
|
||||
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: '',
|
||||
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: '',
|
||||
disabled: !!send.disabled,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SendsPage(props: SendsPageProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [draft, setDraft] = useState<SendDraft | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(AUTO_COPY_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(AUTO_COPY_KEY, autoCopyLink ? '1' : '0');
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}, [autoCopyLink]);
|
||||
|
||||
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<void> {
|
||||
if (!draft) return;
|
||||
if (!draft.name.trim()) {
|
||||
props.onNotify('error', 'Name is required');
|
||||
return;
|
||||
}
|
||||
if (draft.type === 'text' && !draft.text.trim()) {
|
||||
props.onNotify('error', 'Text is required');
|
||||
return;
|
||||
}
|
||||
if (draft.type === 'file' && isCreating && !draft.file) {
|
||||
props.onNotify('error', '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);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSend(send: Send): Promise<void> {
|
||||
setBusy(true);
|
||||
try {
|
||||
await props.onDelete(send);
|
||||
if (selectedId === send.id) setSelectedId(null);
|
||||
setIsEditing(false);
|
||||
setDraft(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSelected(): Promise<void> {
|
||||
if (!selectedCount) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await props.onBulkDelete(selectedIds);
|
||||
setSelectedMap({});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function copyAccessUrl(send: Send): void {
|
||||
const url = send.shareUrl || `${window.location.origin}/send/${send.accessId}`;
|
||||
void navigator.clipboard.writeText(url);
|
||||
props.onNotify('success', 'Link copied');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vault-grid">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-block">
|
||||
<div className="sidebar-title">All Sends</div>
|
||||
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
|
||||
<LayoutGrid size={14} className="tree-icon" />
|
||||
<span className="tree-label">All Sends</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-block">
|
||||
<div className="sidebar-title">Type</div>
|
||||
<button type="button" className={`tree-btn ${typeFilter === 'text' ? 'active' : ''}`} onClick={() => setTypeFilter('text')}>
|
||||
<FileText size={14} className="tree-icon" />
|
||||
<span className="tree-label">Text</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${typeFilter === 'file' ? 'active' : ''}`} onClick={() => setTypeFilter('file')}>
|
||||
<File size={14} className="tree-icon" />
|
||||
<span className="tree-label">File</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="list-col">
|
||||
<div className="list-head">
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder="Search sends..."
|
||||
value={search}
|
||||
onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void props.onRefresh()}>
|
||||
<RefreshCw size={14} className="btn-icon" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar actions">
|
||||
<button type="button" className="btn btn-danger small" disabled={!selectedCount || busy} onClick={() => void removeSelected()}>
|
||||
<Trash2 size={14} className="btn-icon" /> Delete Selected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={!filteredSends.length}
|
||||
onClick={() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const send of filteredSends) map[send.id] = true;
|
||||
setSelectedMap(map);
|
||||
}}
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
{!!selectedCount && (
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary small"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setIsCreating(true);
|
||||
setIsEditing(true);
|
||||
setDraft(buildDefaultDraft());
|
||||
setShowPassword(false);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} className="btn-icon" /> Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-panel">
|
||||
{filteredSends.map((send) => (
|
||||
<div key={send.id} className={`list-item ${selectedId === send.id ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="row-check"
|
||||
checked={!!selectedMap[send.id]}
|
||||
onInput={(e) =>
|
||||
setSelectedMap((prev) => ({
|
||||
...prev,
|
||||
[send.id]: (e.currentTarget as HTMLInputElement).checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="row-main"
|
||||
onClick={() => {
|
||||
setSelectedId(send.id);
|
||||
setIsEditing(false);
|
||||
setIsCreating(false);
|
||||
setDraft(null);
|
||||
}}
|
||||
>
|
||||
<div className="list-icon-wrap">
|
||||
<span className="list-icon-fallback">
|
||||
<SendIcon />
|
||||
</span>
|
||||
</div>
|
||||
<div className="list-text">
|
||||
<span className="list-title" title={send.decName || '(No Name)'}>{send.decName || '(No Name)'}</span>
|
||||
<span className="list-sub">
|
||||
{Number(send.type) === 1 ? 'File' : 'Text'} - Accessed {send.accessCount || 0} times
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!filteredSends.length && <div className="empty">No sends</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-col">
|
||||
{isEditing && draft && (
|
||||
<div className="card">
|
||||
<h3 className="detail-title">{isCreating ? 'New Send' : 'Edit Send'}</h3>
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<span>Name</span>
|
||||
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</label>
|
||||
<label className="field field-span-2">
|
||||
<span>Type</span>
|
||||
<div className="send-options">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
checked={draft.type === 'file'}
|
||||
disabled={!isCreating}
|
||||
onInput={() => setDraft({ ...draft, type: 'file' })}
|
||||
/>
|
||||
File
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
checked={draft.type === 'text'}
|
||||
disabled={!isCreating}
|
||||
onInput={() => setDraft({ ...draft, type: 'text' })}
|
||||
/>
|
||||
Text
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
{draft.type === 'file' ? (
|
||||
<label className="field field-span-2">
|
||||
<span>File</span>
|
||||
<input className="input" type="file" onInput={(e) => setDraft({ ...draft, file: (e.currentTarget as HTMLInputElement).files?.[0] || null })} />
|
||||
</label>
|
||||
) : (
|
||||
<label className="field field-span-2">
|
||||
<span>Text</span>
|
||||
<textarea className="input textarea" rows={8} value={draft.text} onInput={(e) => setDraft({ ...draft, text: (e.currentTarget as HTMLTextAreaElement).value })} />
|
||||
</label>
|
||||
)}
|
||||
<label className="field">
|
||||
<span>Deletion Days</span>
|
||||
<input className="input" type="number" min="1" max="31" value={draft.deletionDays} onInput={(e) => setDraft({ ...draft, deletionDays: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Expiration Days (0 = never)</span>
|
||||
<input className="input" type="number" min="0" max="3650" value={draft.expirationDays} onInput={(e) => setDraft({ ...draft, expirationDays: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Max Access Count</span>
|
||||
<input className="input" value={draft.maxAccessCount} onInput={(e) => setDraft({ ...draft, maxAccessCount: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Password</span>
|
||||
<div className="password-wrap">
|
||||
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
|
||||
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="field field-span-2">
|
||||
<span>Notes</span>
|
||||
<textarea className="input textarea" rows={5} value={draft.notes} onInput={(e) => setDraft({ ...draft, notes: (e.currentTarget as HTMLTextAreaElement).value })} />
|
||||
</label>
|
||||
<label className="field field-span-2">
|
||||
<span>Options</span>
|
||||
<div className="send-options">
|
||||
<label><input type="checkbox" checked={draft.disabled} onInput={(e) => setDraft({ ...draft, disabled: (e.currentTarget as HTMLInputElement).checked })} /> Disable this send</label>
|
||||
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> Auto copy link after save</label>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="detail-actions">
|
||||
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>Save</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => { setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && selectedSend && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 className="detail-title">{selectedSend.decName || '(No Name)'}</h3>
|
||||
<div className="detail-sub">{Number(selectedSend.type) === 1 ? 'File Send' : 'Text Send'}</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h4>Send Details</h4>
|
||||
<div className="kv-line"><span>Access Count</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
||||
<div className="kv-line"><span>Deletion Date</span><strong>{selectedSend.deletionDate || '-'}</strong></div>
|
||||
<div className="kv-line"><span>Expiration Date</span><strong>{selectedSend.expirationDate || '-'}</strong></div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{Number(selectedSend.type) === 1 ? (
|
||||
<>
|
||||
<h4>File</h4>
|
||||
<div className="kv-line"><span>File Name</span><strong>{selectedSend.file?.fileName || 'Encrypted file'}</strong></div>
|
||||
<div className="kv-line"><span>File Size</span><strong>{selectedSend.file?.sizeName || '-'}</strong></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h4>Text</h4>
|
||||
<div className="notes">{selectedSend.decText || ''}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-actions">
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => copyAccessUrl(selectedSend)}>
|
||||
<Copy size={14} className="btn-icon" /> Copy Link
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => { setDraft(draftFromSend(selectedSend)); setIsCreating(false); setIsEditing(true); }}>
|
||||
<Pencil size={14} className="btn-icon" /> Edit
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="btn btn-danger small detail-delete-btn" disabled={busy} onClick={() => void removeSend(selectedSend)}>
|
||||
<Trash2 size={14} className="btn-icon" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user