mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -64,6 +64,48 @@ const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
|||||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
||||||
|
|
||||||
type ThemePreference = 'system' | 'light' | 'dark';
|
type ThemePreference = 'system' | 'light' | 'dark';
|
||||||
|
const MAGNETIC_SELECTOR = '.topbar .btn, .topbar .user-chip, .side-link, .mobile-tab';
|
||||||
|
|
||||||
|
function installMagneticUiFeedback() {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {};
|
||||||
|
if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {};
|
||||||
|
if (typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches) return () => {};
|
||||||
|
|
||||||
|
const resetNode = (node: HTMLElement) => {
|
||||||
|
node.style.setProperty('--mag-x', '0px');
|
||||||
|
node.style.setProperty('--mag-y', '0px');
|
||||||
|
node.style.removeProperty('--mx');
|
||||||
|
node.style.removeProperty('--my');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
|
const node = event.target instanceof Element ? event.target.closest<HTMLElement>(MAGNETIC_SELECTOR) : null;
|
||||||
|
if (!node) return;
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
const localX = event.clientX - rect.left;
|
||||||
|
const localY = event.clientY - rect.top;
|
||||||
|
const dx = (localX - rect.width / 2) / Math.max(rect.width / 2, 1);
|
||||||
|
const dy = (localY - rect.height / 2) / Math.max(rect.height / 2, 1);
|
||||||
|
node.style.setProperty('--mx', `${localX}px`);
|
||||||
|
node.style.setProperty('--my', `${localY}px`);
|
||||||
|
node.style.setProperty('--mag-x', `${dx * 6}px`);
|
||||||
|
node.style.setProperty('--mag-y', `${dy * 4}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerLeave = (event: Event) => {
|
||||||
|
const node = event.target instanceof Element ? event.target.closest<HTMLElement>(MAGNETIC_SELECTOR) : null;
|
||||||
|
if (!node) return;
|
||||||
|
resetNode(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||||
|
document.addEventListener('pointerleave', onPointerLeave, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointermove', onPointerMove);
|
||||||
|
document.removeEventListener('pointerleave', onPointerLeave, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readThemePreference(): ThemePreference {
|
function readThemePreference(): ThemePreference {
|
||||||
if (typeof window === 'undefined') return 'system';
|
if (typeof window === 'undefined') return 'system';
|
||||||
@@ -218,6 +260,8 @@ export default function App() {
|
|||||||
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
|
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
|
||||||
}, [themePreference]);
|
}, [themePreference]);
|
||||||
|
|
||||||
|
useEffect(() => installMagneticUiFeedback(), []);
|
||||||
|
|
||||||
function handleToggleTheme() {
|
function handleToggleTheme() {
|
||||||
setThemePreference((prev) => {
|
setThemePreference((prev) => {
|
||||||
const current = prev === 'system' ? systemTheme : prev;
|
const current = prev === 'system' ? systemTheme : prev;
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface AppAuthenticatedShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||||
|
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
@@ -106,7 +108,9 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
</Link>
|
</Link>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="content">
|
<main className="content">
|
||||||
<AppMainRoutes {...props.mainRoutesProps} />
|
<div key={routeAnimationKey} className="route-stage">
|
||||||
|
<AppMainRoutes {...props.mainRoutesProps} />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -19,14 +20,32 @@ interface ConfirmDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||||
if (!props.open) return null;
|
const [present, setPresent] = useState(props.open);
|
||||||
|
const [closing, setClosing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.open) {
|
||||||
|
setPresent(true);
|
||||||
|
setClosing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!present) return;
|
||||||
|
setClosing(true);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setPresent(false);
|
||||||
|
setClosing(false);
|
||||||
|
}, 240);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [props.open, present]);
|
||||||
|
|
||||||
|
if (!present) return null;
|
||||||
return (
|
return (
|
||||||
<div className="dialog-mask">
|
<div className={`dialog-mask ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}>
|
||||||
<form
|
<form
|
||||||
className="dialog-card"
|
className={`dialog-card ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (props.confirmDisabled) return;
|
if (props.confirmDisabled || closing) return;
|
||||||
props.onConfirm();
|
props.onConfirm();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ function draftFromSend(send: Send): SendDraft {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SendsPage(props: SendsPageProps) {
|
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 [search, setSearch] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
|
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
@@ -71,7 +75,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
const [draft, setDraft] = useState<SendDraft | null>(null);
|
const [draft, setDraft] = useState<SendDraft | null>(null);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
const [isMobileLayout, setIsMobileLayout] = useState(false);
|
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
|
||||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
||||||
@@ -226,7 +230,15 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
||||||
{isMobileLayout && mobileSidebarOpen && <div className="mobile-sidebar-mask" onClick={() => setMobileSidebarOpen(false)} />}
|
{isMobileLayout && (
|
||||||
|
<div
|
||||||
|
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!mobileSidebarOpen) return;
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<aside className={`sidebar ${isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${isMobileLayout && mobileSidebarOpen ? 'open' : ''}`}>
|
<aside className={`sidebar ${isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${isMobileLayout && mobileSidebarOpen ? 'open' : ''}`}>
|
||||||
{isMobileLayout && (
|
{isMobileLayout && (
|
||||||
<div className="mobile-sidebar-head">
|
<div className="mobile-sidebar-head">
|
||||||
@@ -310,12 +322,27 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="list-panel">
|
<div className="list-panel">
|
||||||
{filteredSends.map((send) => (
|
{filteredSends.map((send, index) => (
|
||||||
<div key={send.id} className={`list-item ${selectedId === send.id ? 'active' : ''}`}>
|
<div
|
||||||
|
key={send.id}
|
||||||
|
className={`list-item stagger-item ${selectedId === send.id ? 'active' : ''}`}
|
||||||
|
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
|
||||||
|
onClick={(event) => {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="row-check"
|
className="row-check"
|
||||||
checked={!!selectedMap[send.id]}
|
checked={!!selectedMap[send.id]}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
onInput={(e) =>
|
onInput={(e) =>
|
||||||
setSelectedMap((prev) => ({
|
setSelectedMap((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -377,10 +404,11 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isEditing && draft && (
|
{isEditing && draft && (
|
||||||
<div className="card">
|
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
|
||||||
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
|
<div className="card stagger-item" style={{ animationDelay: '0ms' }}>
|
||||||
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
|
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
|
||||||
<div className="field-grid">
|
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
|
||||||
|
<div className="field-grid">
|
||||||
<label className="field field-span-2">
|
<label className="field field-span-2">
|
||||||
<span>{t('txt_name')}</span>
|
<span>{t('txt_name')}</span>
|
||||||
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
|
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
@@ -451,8 +479,8 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> {t('txt_auto_copy_link_after_save')}</label>
|
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> {t('txt_auto_copy_link_after_save')}</label>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
|
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
|
||||||
<Save size={14} className="btn-icon" /> {t('txt_save')}
|
<Save size={14} className="btn-icon" /> {t('txt_save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -470,18 +498,19 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
>
|
>
|
||||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing && selectedSend && (
|
{!isEditing && selectedSend && (
|
||||||
<>
|
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
|
||||||
<div className="card">
|
<div className="card stagger-item" style={{ animationDelay: '36ms' }}>
|
||||||
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
|
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
|
||||||
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
|
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card stagger-item" style={{ animationDelay: '72ms' }}>
|
||||||
<h4>{t('txt_send_details')}</h4>
|
<h4>{t('txt_send_details')}</h4>
|
||||||
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
||||||
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
||||||
@@ -504,7 +533,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!!(selectedSend.decNotes || '').trim() && (
|
{!!(selectedSend.decNotes || '').trim() && (
|
||||||
<div className="card">
|
<div className="card stagger-item" style={{ animationDelay: '108ms' }}>
|
||||||
<h4>{t('txt_notes')}</h4>
|
<h4>{t('txt_notes')}</h4>
|
||||||
<div className="notes">{selectedSend.decNotes || ''}</div>
|
<div className="notes">{selectedSend.decNotes || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,7 +552,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ interface VaultPageProps {
|
|||||||
|
|
||||||
|
|
||||||
export default function VaultPage(props: VaultPageProps) {
|
export default function VaultPage(props: VaultPageProps) {
|
||||||
|
const getInitialIsMobileLayout = () =>
|
||||||
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia(MOBILE_LAYOUT_QUERY).matches
|
||||||
|
: false;
|
||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchComposing, setSearchComposing] = useState(false);
|
const [searchComposing, setSearchComposing] = useState(false);
|
||||||
@@ -97,7 +101,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [repromptOpen, setRepromptOpen] = useState(false);
|
const [repromptOpen, setRepromptOpen] = useState(false);
|
||||||
const [repromptPassword, setRepromptPassword] = useState('');
|
const [repromptPassword, setRepromptPassword] = useState('');
|
||||||
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
||||||
const [isMobileLayout, setIsMobileLayout] = useState(false);
|
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
|
||||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -769,7 +773,15 @@ function folderName(id: string | null | undefined): string {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
||||||
{isMobileLayout && mobileSidebarOpen && <div className="mobile-sidebar-mask" onClick={() => setMobileSidebarOpen(false)} />}
|
{isMobileLayout && (
|
||||||
|
<div
|
||||||
|
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!mobileSidebarOpen) return;
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<VaultSidebar
|
<VaultSidebar
|
||||||
folders={props.folders}
|
folders={props.folders}
|
||||||
sidebarFilter={sidebarFilter}
|
sidebarFilter={sidebarFilter}
|
||||||
@@ -878,60 +890,64 @@ function folderName(id: string | null | undefined): string {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isEditing && draft && (
|
{isEditing && draft && (
|
||||||
<VaultEditor
|
<div key={`editor-${draft.id || selectedCipher?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
|
||||||
draft={draft}
|
<VaultEditor
|
||||||
isCreating={isCreating}
|
draft={draft}
|
||||||
busy={busy}
|
isCreating={isCreating}
|
||||||
folders={props.folders}
|
busy={busy}
|
||||||
selectedCipher={selectedCipher}
|
folders={props.folders}
|
||||||
editExistingAttachments={editExistingAttachments}
|
selectedCipher={selectedCipher}
|
||||||
removedAttachmentIds={removedAttachmentIds}
|
editExistingAttachments={editExistingAttachments}
|
||||||
removedAttachmentCount={removedAttachmentCount}
|
removedAttachmentIds={removedAttachmentIds}
|
||||||
attachmentQueue={attachmentQueue}
|
removedAttachmentCount={removedAttachmentCount}
|
||||||
attachmentInputRef={attachmentInputRef}
|
attachmentQueue={attachmentQueue}
|
||||||
localError={localError}
|
attachmentInputRef={attachmentInputRef}
|
||||||
onUpdateDraft={updateDraft}
|
localError={localError}
|
||||||
onSeedSshDefaults={(force) => void seedSshDefaults(force)}
|
onUpdateDraft={updateDraft}
|
||||||
onUpdateSshPublicKey={updateSshPublicKey}
|
onSeedSshDefaults={(force) => void seedSshDefaults(force)}
|
||||||
onUpdateDraftLoginUri={updateDraftLoginUri}
|
onUpdateSshPublicKey={updateSshPublicKey}
|
||||||
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
|
onUpdateDraftLoginUri={updateDraftLoginUri}
|
||||||
onQueueAttachmentFiles={queueAttachmentFiles}
|
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
|
||||||
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
onQueueAttachmentFiles={queueAttachmentFiles}
|
||||||
onRemoveQueuedAttachment={removeQueuedAttachment}
|
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
||||||
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
onRemoveQueuedAttachment={removeQueuedAttachment}
|
||||||
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
||||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||||
uploadingAttachmentName={props.uploadingAttachmentName}
|
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||||
attachmentUploadPercent={props.attachmentUploadPercent}
|
uploadingAttachmentName={props.uploadingAttachmentName}
|
||||||
onPatchDraftCustomField={patchDraftCustomField}
|
attachmentUploadPercent={props.attachmentUploadPercent}
|
||||||
onUpdateDraftCustomFields={updateDraftCustomFields}
|
onPatchDraftCustomField={patchDraftCustomField}
|
||||||
onOpenFieldModal={() => setFieldModalOpen(true)}
|
onUpdateDraftCustomFields={updateDraftCustomFields}
|
||||||
onSave={() => void saveDraft()}
|
onOpenFieldModal={() => setFieldModalOpen(true)}
|
||||||
onCancel={cancelEdit}
|
onSave={() => void saveDraft()}
|
||||||
onDeleteSelected={() => selectedCipher && setPendingDelete(selectedCipher)}
|
onCancel={cancelEdit}
|
||||||
/>
|
onDeleteSelected={() => selectedCipher && setPendingDelete(selectedCipher)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing && selectedCipher && (
|
{!isEditing && selectedCipher && (
|
||||||
<VaultDetailView
|
<div key={`detail-${selectedCipher.id}`} className="detail-switch-stage">
|
||||||
selectedCipher={selectedCipher}
|
<VaultDetailView
|
||||||
repromptApprovedCipherId={repromptApprovedCipherId}
|
selectedCipher={selectedCipher}
|
||||||
showPassword={showPassword}
|
repromptApprovedCipherId={repromptApprovedCipherId}
|
||||||
totpLive={totpLive}
|
showPassword={showPassword}
|
||||||
passkeyCreatedAt={passkeyCreatedAt}
|
totpLive={totpLive}
|
||||||
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
passkeyCreatedAt={passkeyCreatedAt}
|
||||||
folderName={folderName}
|
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
||||||
onOpenReprompt={() => setRepromptOpen(true)}
|
folderName={folderName}
|
||||||
onToggleShowPassword={() => setShowPassword((value) => !value)}
|
onOpenReprompt={() => setRepromptOpen(true)}
|
||||||
onToggleHiddenField={(index) => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
|
onToggleShowPassword={() => setShowPassword((value) => !value)}
|
||||||
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
onToggleHiddenField={(index) => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
|
||||||
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
||||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||||
onStartEdit={startEdit}
|
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||||
onDelete={setPendingDelete}
|
onStartEdit={startEdit}
|
||||||
onArchive={(cipher) => setPendingArchive(cipher)}
|
onDelete={setPendingDelete}
|
||||||
onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)}
|
onArchive={(cipher) => setPendingArchive(cipher)}
|
||||||
/>
|
onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>}
|
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>}
|
||||||
|
|||||||
@@ -104,38 +104,11 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar actions">
|
<div className="toolbar actions">
|
||||||
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
|
||||||
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
|
||||||
</button>
|
|
||||||
{props.sidebarFilter.kind === 'duplicates' && (
|
{props.sidebarFilter.kind === 'duplicates' && (
|
||||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
|
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
|
||||||
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
|
||||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
|
||||||
</button>
|
|
||||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary small mobile-fab-trigger"
|
|
||||||
aria-label={t('txt_add')}
|
|
||||||
title={t('txt_add')}
|
|
||||||
onClick={props.onToggleCreateMenu}
|
|
||||||
>
|
|
||||||
<Plus size={14} className="btn-icon" />
|
|
||||||
</button>
|
|
||||||
{props.createMenuOpen && (
|
|
||||||
<div className="create-menu">
|
|
||||||
{CREATE_TYPE_OPTIONS.map((option) => (
|
|
||||||
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
|
|
||||||
<CreateTypeIcon type={option.type} />
|
|
||||||
<span>{option.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
|
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||||
@@ -161,17 +134,54 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||||
|
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||||
|
</button>
|
||||||
|
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary small mobile-fab-trigger"
|
||||||
|
aria-label={t('txt_add')}
|
||||||
|
title={t('txt_add')}
|
||||||
|
onClick={props.onToggleCreateMenu}
|
||||||
|
>
|
||||||
|
<Plus size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
{props.createMenuOpen && (
|
||||||
|
<div className="create-menu">
|
||||||
|
{CREATE_TYPE_OPTIONS.map((option) => (
|
||||||
|
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
|
||||||
|
<CreateTypeIcon type={option.type} />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||||
{!!props.filteredCiphers.length && (
|
{!!props.filteredCiphers.length && (
|
||||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||||
{props.visibleCiphers.map((cipher) => (
|
{props.visibleCiphers.map((cipher, index) => (
|
||||||
<div key={cipher.id} className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}>
|
<div
|
||||||
|
key={cipher.id}
|
||||||
|
className={`list-item stagger-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
|
||||||
|
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
|
||||||
|
onClick={(event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest('.row-check')) return;
|
||||||
|
props.onSelectCipher(cipher.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="row-check"
|
className="row-check"
|
||||||
checked={!!props.selectedMap[cipher.id]}
|
checked={!!props.selectedMap[cipher.id]}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
|
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_bulk_archive_failed: "Bulk archive failed",
|
txt_bulk_archive_failed: "Bulk archive failed",
|
||||||
txt_bulk_unarchive_failed: "Bulk unarchive failed",
|
txt_bulk_unarchive_failed: "Bulk unarchive failed",
|
||||||
txt_unarchive: "Unarchive",
|
txt_unarchive: "Unarchive",
|
||||||
txt_delete_selected: "Delete Selected",
|
txt_delete_selected: "Delete",
|
||||||
txt_delete_selected_items: "Delete Selected Items",
|
txt_delete_selected_items: "Delete Selected Items",
|
||||||
txt_delete_selected_items_permanently: "Delete Selected Items Permanently",
|
txt_delete_selected_items_permanently: "Delete Selected Items Permanently",
|
||||||
txt_delete_send_failed: "Delete send failed",
|
txt_delete_send_failed: "Delete send failed",
|
||||||
@@ -885,7 +885,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_copy_link: '复制链接',
|
txt_copy_link: '复制链接',
|
||||||
txt_select_all: '全选',
|
txt_select_all: '全选',
|
||||||
txt_select_duplicate_items: '选择重复项',
|
txt_select_duplicate_items: '选择重复项',
|
||||||
txt_delete_selected: '删除所选',
|
txt_delete_selected: '删除',
|
||||||
txt_all_items: '所有项目',
|
txt_all_items: '所有项目',
|
||||||
txt_favorites: '收藏',
|
txt_favorites: '收藏',
|
||||||
txt_duplicates: '重复项',
|
txt_duplicates: '重复项',
|
||||||
|
|||||||
+847
-185
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user