mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Refactor frontend styles toward Tailwind utilities and unified design system
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user