Refactor frontend styles toward Tailwind utilities and unified design system

This commit is contained in:
shuaiplus
2026-04-25 02:23:10 +08:00
parent 514889adfc
commit e4bc1b9bbe
20 changed files with 632 additions and 1177 deletions
@@ -21,6 +21,7 @@ interface AppAuthenticatedShellProps {
onLock: () => void;
onLogout: () => void;
onToggleTheme: () => void;
onToggleMobileSidebar: () => void;
mainRoutesProps: AppMainRoutesProps;
}
@@ -51,7 +52,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
className="btn btn-secondary small mobile-sidebar-toggle"
aria-label={props.sidebarToggleTitle}
title={props.sidebarToggleTitle}
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))}
onClick={props.onToggleMobileSidebar}
>
<FolderIcon size={16} className="btn-icon" />
</button>
+1 -1
View File
@@ -76,7 +76,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
<span>{t('txt_totp_code')}</span>
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
</label>
<label className="check-line" style={{ marginBottom: 0 }}>
<label className="check-line check-line-compact">
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
<span>{t('txt_trust_this_device_for_30_days')}</span>
</label>
+3
View File
@@ -33,6 +33,7 @@ export interface AppMainRoutesProps {
profile: Profile | null;
session: SessionState | null;
mobileLayout: boolean;
mobileSidebarToggleKey: number;
importRoute: string;
settingsHomeRoute: string;
settingsAccountRoute: string;
@@ -167,6 +168,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onBulkDelete={props.onBulkDeleteSends}
uploadingSendFileName={props.uploadingSendFileName}
sendUploadPercent={props.sendUploadPercent}
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
onNotify={props.onNotify}
/>
</Suspense>
@@ -206,6 +208,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
attachmentDownloadPercent={props.attachmentDownloadPercent}
uploadingAttachmentName={props.uploadingAttachmentName}
attachmentUploadPercent={props.attachmentUploadPercent}
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
/>
</Suspense>
</Route>
+78 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Download, Eye, Lock } from 'lucide-preact';
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
import { toBufferSource } from '@/lib/crypto';
@@ -11,29 +11,95 @@ interface PublicSendPageProps {
keyPart: string | null;
}
interface PublicSendFileData {
id: string;
fileName?: string | null;
sizeName?: string | null;
}
interface PublicSendData {
id: string;
type: 0 | 1;
decName?: string | null;
decText?: string | null;
decFileName?: string | null;
expirationDate?: string | null;
file?: PublicSendFileData | null;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
}
function optionalString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function parsePublicSendData(value: unknown): PublicSendData | null {
const source = asRecord(value);
if (!source) return null;
const id = optionalString(source.id);
const rawType = Number(source.type);
if (!id || (rawType !== 0 && rawType !== 1)) return null;
const fileSource = asRecord(source.file);
const fileId = optionalString(fileSource?.id);
const file = fileSource && fileId
? {
id: fileId,
fileName: optionalString(fileSource.fileName),
sizeName: optionalString(fileSource.sizeName),
}
: null;
if (rawType === 1 && !file) return null;
return {
id,
type: rawType,
decName: optionalString(source.decName),
decText: optionalString(source.decText),
decFileName: optionalString(source.decFileName),
expirationDate: optionalString(source.expirationDate),
file,
};
}
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 [sendData, setSendData] = useState<PublicSendData | null>(null);
const [busy, setBusy] = useState(false);
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
const loadRequestRef = useRef(0);
const loadAbortRef = useRef<AbortController | null>(null);
async function loadSend(pass?: string): Promise<void> {
loadAbortRef.current?.abort();
const controller = new AbortController();
const requestId = loadRequestRef.current + 1;
loadRequestRef.current = requestId;
loadAbortRef.current = controller;
setBusy(true);
setError('');
setLoading(true);
try {
const data = await accessPublicSend(props.accessId, props.keyPart, pass);
const data = await accessPublicSend(props.accessId, props.keyPart, pass, { signal: controller.signal });
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
if (!props.keyPart) {
setError(t('txt_this_link_is_missing_decryption_key'));
setSendData(null);
return;
}
const decrypted = await decryptPublicSend(data, props.keyPart);
setSendData(decrypted);
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
const parsed = parsePublicSendData(decrypted);
if (!parsed) throw new Error(t('txt_send_unavailable'));
setSendData(parsed);
setNeedPassword(false);
} catch (e) {
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
const err = e as Error & { status?: number };
if (err.status === 401) {
setNeedPassword(true);
@@ -43,6 +109,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
}
setSendData(null);
} finally {
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
setBusy(false);
setLoading(false);
}
@@ -86,6 +153,9 @@ export default function PublicSendPage(props: PublicSendPageProps) {
useEffect(() => {
void loadSend();
return () => {
loadAbortRef.current?.abort();
};
}, [props.accessId, props.keyPart]);
return (
@@ -120,13 +190,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
{!loading && sendData && (
<>
<h2 style={{ marginTop: '8px' }}>{sendData.decName || t('txt_no_name')}</h2>
<h2 className="public-send-title">{sendData.decName || t('txt_no_name')}</h2>
{sendData.type === 0 ? (
<div className="card" style={{ marginTop: '10px' }}>
<div className="card public-send-card">
<div className="notes">{sendData.decText || ''}</div>
</div>
) : (
<div className="card" style={{ marginTop: '10px' }}>
<div className="card public-send-card">
<div className="kv-line">
<span>{t('txt_file')}</span>
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
@@ -142,7 +212,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
{!loading && !sendData && !needPassword && !error && (
<p className="muted">
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> {t('txt_send_unavailable')}
<Eye size={14} className="inline-status-icon" /> {t('txt_send_unavailable')}
</p>
)}
{!!error && <p className="local-error">{error}</p>}
@@ -66,8 +66,8 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<section className="card">
<div className="section-head">
<div>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
<div className="muted-inline" style={{ marginTop: 4 }}>
<h3 className="flush-title">{t('txt_device_management')}</h3>
<div className="muted-inline section-note">
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
</div>
</div>
@@ -89,7 +89,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</section>
<section className="card">
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
<table className="table">
<thead>
<tr>
@@ -169,7 +169,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
{!props.loading && props.devices.length === 0 && (
<tr>
<td colSpan={7}>
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div>
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
</td>
</tr>
)}
+9 -12
View File
@@ -14,6 +14,7 @@ interface SendsPageProps {
onBulkDelete: (ids: string[]) => Promise<void>;
uploadingSendFileName: string;
sendUploadPercent: number | null;
mobileSidebarToggleKey: number;
onNotify: (type: 'success' | 'error', text: string) => void;
}
@@ -107,12 +108,9 @@ export default function SendsPage(props: SendsPageProps) {
}, []);
useEffect(() => {
const onToggleSidebar = () => {
setMobileSidebarOpen((open) => !open);
};
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
}, []);
if (!props.mobileSidebarToggleKey) return;
setMobileSidebarOpen((open) => !open);
}, [props.mobileSidebarToggleKey]);
useEffect(() => {
try {
@@ -325,8 +323,7 @@ export default function SendsPage(props: SendsPageProps) {
{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` }}
className={`list-item stagger-item stagger-delay-${Math.min(index, 10)} ${selectedId === send.id ? 'active' : ''}`}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check')) return;
@@ -405,7 +402,7 @@ export default function SendsPage(props: SendsPageProps) {
)}
{isEditing && draft && (
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
<div className="card stagger-item" style={{ animationDelay: '0ms' }}>
<div className="card stagger-item stagger-delay-0">
<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">
@@ -505,12 +502,12 @@ export default function SendsPage(props: SendsPageProps) {
{!isEditing && selectedSend && (
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
<div className="card stagger-item" style={{ animationDelay: '36ms' }}>
<div className="card stagger-item stagger-delay-1">
<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 stagger-item" style={{ animationDelay: '72ms' }}>
<div className="card stagger-item stagger-delay-2">
<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>
@@ -533,7 +530,7 @@ export default function SendsPage(props: SendsPageProps) {
</div>
{!!(selectedSend.decNotes || '').trim() && (
<div className="card stagger-item" style={{ animationDelay: '108ms' }}>
<div className="card stagger-item stagger-delay-3">
<h4>{t('txt_notes')}</h4>
<div className="notes">{selectedSend.decNotes || ''}</div>
</div>
+9 -26
View File
@@ -268,7 +268,7 @@ export default function SettingsPage(props: SettingsPageProps) {
<div className="settings-subcard">
<h3>{t('txt_recovery_code')}</h3>
<p className="muted-inline" style={{ marginBottom: 8 }}>
<p className="muted-inline settings-field-note">
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
</p>
<label className="field">
@@ -298,8 +298,8 @@ export default function SettingsPage(props: SettingsPageProps) {
</button>
</div>
{recoveryCode && (
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}>
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div>
<div className="card recovery-code-card">
<div className="recovery-code-value">{recoveryCode}</div>
</div>
)}
</div>
@@ -341,30 +341,13 @@ export default function SettingsPage(props: SettingsPageProps) {
onConfirm={() => setApiKeyDialogOpen(false)}
onCancel={() => setApiKeyDialogOpen(false)}
>
<div
style={{
border: '1px solid color-mix(in srgb, var(--danger) 24%, transparent)',
background: 'color-mix(in srgb, var(--danger) 7%, var(--surface))',
borderRadius: 8,
padding: 14,
marginTop: 12,
marginBottom: 14,
}}
>
<div style={{ fontWeight: 800, color: 'var(--danger)', marginBottom: 8 }}>{t('txt_warning')}</div>
<div style={{ color: 'var(--text)', lineHeight: 1.55 }}>{t('txt_api_key_warning_body')}</div>
<div className="api-key-warning-panel">
<div className="api-key-warning-title">{t('txt_warning')}</div>
<div className="api-key-warning-body">{t('txt_api_key_warning_body')}</div>
</div>
<div
style={{
border: '1px solid color-mix(in srgb, var(--primary) 25%, transparent)',
background: 'color-mix(in srgb, var(--primary) 7%, var(--surface))',
borderRadius: 8,
padding: 14,
marginBottom: 10,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 800, color: 'var(--primary)', marginBottom: 10 }}>
<div className="api-key-credentials-panel">
<div className="api-key-credentials-title">
<KeyRound size={15} />
<span>{t('txt_oauth_client_credentials')}</span>
</div>
@@ -376,7 +359,7 @@ export default function SettingsPage(props: SettingsPageProps) {
] as [string, string][]).map(([label, value]) => (
<label key={label} className="field">
<span>{label}</span>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', gap: 8 }}>
<div className="api-key-credential-row">
<input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} />
<button
type="button"
+4 -6
View File
@@ -58,6 +58,7 @@ interface VaultPageProps {
attachmentDownloadPercent: number | null;
uploadingAttachmentName: string;
attachmentUploadPercent: number | null;
mobileSidebarToggleKey: number;
}
@@ -131,12 +132,9 @@ export default function VaultPage(props: VaultPageProps) {
}, []);
useEffect(() => {
const onToggleSidebar = () => {
setMobileSidebarOpen((open) => !open);
};
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
}, []);
if (!props.mobileSidebarToggleKey) return;
setMobileSidebarOpen((open) => !open);
}, [props.mobileSidebarToggleKey]);
useEffect(() => {
const onQuickAdd = () => {
@@ -105,7 +105,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="card">
<h4>{t('txt_master_password_reprompt_2')}</h4>
<div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div>
<div className="actions" style={{ marginTop: '10px' }}>
<div className="actions detail-unlock-actions">
<button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}>
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
</button>
@@ -117,7 +117,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="card">
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
{isArchived && <div className="list-badge" style={{ marginTop: '8px', width: 'fit-content' }}>{t('txt_archived')}</div>}
{isArchived && <div className="list-badge archive-badge">{t('txt_archived')}</div>}
</div>
{props.selectedCipher.login && (
+1 -1
View File
@@ -299,7 +299,7 @@ export default function VaultEditor(props: VaultEditorProps) {
</DndContext>
{props.draft.loginFido2Credentials.length > 0 && (
<>
<div className="section-head" style={{ marginTop: '18px' }}>
<div className="section-head passkeys-section-head">
<h4>{t('txt_passkeys')}</h4>
</div>
<div className="attachment-list">