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;
|
||||
|
||||
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 {
|
||||
if (typeof window === 'undefined') return 'system';
|
||||
@@ -218,6 +260,8 @@ export default function App() {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
|
||||
}, [themePreference]);
|
||||
|
||||
useEffect(() => installMagneticUiFeedback(), []);
|
||||
|
||||
function handleToggleTheme() {
|
||||
setThemePreference((prev) => {
|
||||
const current = prev === 'system' ? systemTheme : prev;
|
||||
|
||||
@@ -25,6 +25,8 @@ interface AppAuthenticatedShellProps {
|
||||
}
|
||||
|
||||
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<div className="app-shell">
|
||||
@@ -106,7 +108,9 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
</Link>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<div key={routeAnimationKey} className="route-stage">
|
||||
<AppMainRoutes {...props.mainRoutesProps} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
@@ -19,14 +20,32 @@ interface 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 (
|
||||
<div className="dialog-mask">
|
||||
<div className={`dialog-mask ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}>
|
||||
<form
|
||||
className="dialog-card"
|
||||
className={`dialog-card ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (props.confirmDisabled) return;
|
||||
if (props.confirmDisabled || closing) return;
|
||||
props.onConfirm();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -62,6 +62,10 @@ function draftFromSend(send: Send): SendDraft {
|
||||
}
|
||||
|
||||
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<SendTypeFilter>('all');
|
||||
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 [showPassword, setShowPassword] = useState(false);
|
||||
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 [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
||||
@@ -226,7 +230,15 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
|
||||
return (
|
||||
<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' : ''}`}>
|
||||
{isMobileLayout && (
|
||||
<div className="mobile-sidebar-head">
|
||||
@@ -310,12 +322,27 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-panel">
|
||||
{filteredSends.map((send) => (
|
||||
<div key={send.id} className={`list-item ${selectedId === send.id ? 'active' : ''}`}>
|
||||
{filteredSends.map((send, index) => (
|
||||
<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
|
||||
type="checkbox"
|
||||
className="row-check"
|
||||
checked={!!selectedMap[send.id]}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onInput={(e) =>
|
||||
setSelectedMap((prev) => ({
|
||||
...prev,
|
||||
@@ -377,7 +404,8 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</div>
|
||||
)}
|
||||
{isEditing && draft && (
|
||||
<div className="card">
|
||||
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
|
||||
<div className="card stagger-item" style={{ animationDelay: '0ms' }}>
|
||||
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
|
||||
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
|
||||
<div className="field-grid">
|
||||
@@ -472,16 +500,17 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && selectedSend && (
|
||||
<>
|
||||
<div className="card">
|
||||
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
|
||||
<div className="card stagger-item" style={{ animationDelay: '36ms' }}>
|
||||
<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>
|
||||
|
||||
<div className="card">
|
||||
<div className="card stagger-item" style={{ animationDelay: '72ms' }}>
|
||||
<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_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
||||
@@ -504,7 +533,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</div>
|
||||
|
||||
{!!(selectedSend.decNotes || '').trim() && (
|
||||
<div className="card">
|
||||
<div className="card stagger-item" style={{ animationDelay: '108ms' }}>
|
||||
<h4>{t('txt_notes')}</h4>
|
||||
<div className="notes">{selectedSend.decNotes || ''}</div>
|
||||
</div>
|
||||
@@ -523,7 +552,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,10 @@ interface 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 [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchComposing, setSearchComposing] = useState(false);
|
||||
@@ -97,7 +101,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [repromptOpen, setRepromptOpen] = useState(false);
|
||||
const [repromptPassword, setRepromptPassword] = useState('');
|
||||
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 [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -769,7 +773,15 @@ function folderName(id: string | null | undefined): string {
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
folders={props.folders}
|
||||
sidebarFilter={sidebarFilter}
|
||||
@@ -878,6 +890,7 @@ function folderName(id: string | null | undefined): string {
|
||||
</div>
|
||||
)}
|
||||
{isEditing && draft && (
|
||||
<div key={`editor-${draft.id || selectedCipher?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
|
||||
<VaultEditor
|
||||
draft={draft}
|
||||
isCreating={isCreating}
|
||||
@@ -910,9 +923,11 @@ function folderName(id: string | null | undefined): string {
|
||||
onCancel={cancelEdit}
|
||||
onDeleteSelected={() => selectedCipher && setPendingDelete(selectedCipher)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && selectedCipher && (
|
||||
<div key={`detail-${selectedCipher.id}`} className="detail-switch-stage">
|
||||
<VaultDetailView
|
||||
selectedCipher={selectedCipher}
|
||||
repromptApprovedCipherId={repromptApprovedCipherId}
|
||||
@@ -932,6 +947,7 @@ function folderName(id: string | null | undefined): string {
|
||||
onArchive={(cipher) => setPendingArchive(cipher)}
|
||||
onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>}
|
||||
|
||||
@@ -104,38 +104,11 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
</button>
|
||||
</div>
|
||||
<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' && (
|
||||
<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')}
|
||||
</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' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
||||
<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')}
|
||||
</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 className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||
{!!props.filteredCiphers.length && (
|
||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||
{props.visibleCiphers.map((cipher) => (
|
||||
<div key={cipher.id} className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}>
|
||||
{props.visibleCiphers.map((cipher, index) => (
|
||||
<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
|
||||
type="checkbox"
|
||||
className="row-check"
|
||||
checked={!!props.selectedMap[cipher.id]}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<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_unarchive_failed: "Bulk unarchive failed",
|
||||
txt_unarchive: "Unarchive",
|
||||
txt_delete_selected: "Delete Selected",
|
||||
txt_delete_selected: "Delete",
|
||||
txt_delete_selected_items: "Delete Selected Items",
|
||||
txt_delete_selected_items_permanently: "Delete Selected Items Permanently",
|
||||
txt_delete_send_failed: "Delete send failed",
|
||||
@@ -885,7 +885,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_copy_link: '复制链接',
|
||||
txt_select_all: '全选',
|
||||
txt_select_duplicate_items: '选择重复项',
|
||||
txt_delete_selected: '删除所选',
|
||||
txt_delete_selected: '删除',
|
||||
txt_all_items: '所有项目',
|
||||
txt_favorites: '收藏',
|
||||
txt_duplicates: '重复项',
|
||||
|
||||
+847
-185
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user