mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Polish vault icons and mobile layout
This commit is contained in:
@@ -36,6 +36,7 @@ interface VaultPageProps {
|
||||
ciphers: Cipher[];
|
||||
folders: Folder[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
emailForReprompt: string;
|
||||
onRefresh: () => Promise<void>;
|
||||
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
||||
@@ -1021,6 +1022,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
<VaultListPanel
|
||||
busy={busy}
|
||||
loading={props.loading}
|
||||
error={props.error}
|
||||
searchInput={searchInput}
|
||||
sortMode={sortMode}
|
||||
sortMenuOpen={sortMenuOpen}
|
||||
@@ -1140,7 +1142,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && !selectedCipher && (props.loading ? <LoadingState card lines={5} /> : <div className="empty card">{t('txt_select_an_item')}</div>)}
|
||||
{!isEditing && !selectedCipher && (
|
||||
props.loading
|
||||
? <LoadingState card lines={5} />
|
||||
: props.error
|
||||
? (
|
||||
<div className="empty card vault-error-state">
|
||||
<strong>{props.error}</strong>
|
||||
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={handleSyncVault}>
|
||||
{t('txt_retry_sync')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: <div className="empty card">{t('txt_select_an_item')}</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useMemo, useState } from 'preact/hooks';
|
||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
TOTP_PERIOD_SECONDS,
|
||||
TOTP_RING_CIRCUMFERENCE,
|
||||
VaultListIcon,
|
||||
copyToClipboard,
|
||||
formatAttachmentSize,
|
||||
formatHistoryTime,
|
||||
@@ -115,8 +116,18 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
{(Number(props.selectedCipher.reprompt || 0) !== 1 || props.repromptApprovedCipherId === props.selectedCipher.id) && (
|
||||
<>
|
||||
<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>
|
||||
<div className="detail-title-row">
|
||||
<span className="detail-title-icon" aria-hidden="true">
|
||||
<VaultListIcon cipher={props.selectedCipher} />
|
||||
</span>
|
||||
<div className="detail-title-main">
|
||||
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
|
||||
<div className="detail-folder-line">
|
||||
<Folder size={13} aria-hidden="true" />
|
||||
<span>{props.folderName(props.selectedCipher.folderId)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isArchived && <div className="list-badge archive-badge">{t('txt_archived')}</div>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ interface VirtualRange {
|
||||
interface VaultListPanelProps {
|
||||
busy: boolean;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
searchInput: string;
|
||||
sortMode: VaultSortMode;
|
||||
sortMenuOpen: boolean;
|
||||
@@ -238,6 +239,14 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
|
||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
|
||||
{!props.loading && !!props.error && !props.filteredCiphers.length && (
|
||||
<div className="empty vault-error-state">
|
||||
<strong>{props.error}</strong>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onSyncVault}>
|
||||
{t('txt_retry_sync')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!!props.filteredCiphers.length && (
|
||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||
{props.visibleCiphers.map((cipher) => (
|
||||
@@ -253,7 +262,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!props.loading && !props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
||||
{!props.loading && !props.error && !props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -3,9 +3,8 @@ import type { ComponentChildren } from 'preact';
|
||||
import { Globe } from 'lucide-preact';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import {
|
||||
getWebsiteIconImageUrl,
|
||||
getWebsiteIconStatus,
|
||||
markWebsiteIconErrored,
|
||||
markWebsiteIconLoaded,
|
||||
preloadWebsiteIcon,
|
||||
subscribeWebsiteIconStatus,
|
||||
} from '@/lib/website-icon-cache';
|
||||
@@ -24,17 +23,23 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
const nodeRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
||||
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
|
||||
|
||||
useEffect(() => {
|
||||
if (!host) {
|
||||
setShouldLoad(true);
|
||||
setStatus('idle');
|
||||
setImageUrl('');
|
||||
return;
|
||||
}
|
||||
const nextStatus = getWebsiteIconStatus(host);
|
||||
setShouldLoad(nextStatus === 'loaded');
|
||||
setStatus(nextStatus);
|
||||
return subscribeWebsiteIconStatus(host, setStatus);
|
||||
setImageUrl(getWebsiteIconImageUrl(host));
|
||||
return subscribeWebsiteIconStatus(host, (next) => {
|
||||
setStatus(next);
|
||||
setImageUrl(getWebsiteIconImageUrl(host));
|
||||
});
|
||||
}, [host]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,7 +75,9 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return;
|
||||
let disposed = false;
|
||||
void preloadWebsiteIcon(host, src).then((nextStatus) => {
|
||||
if (!disposed) setStatus(nextStatus);
|
||||
if (disposed) return;
|
||||
setStatus(nextStatus);
|
||||
setImageUrl(getWebsiteIconImageUrl(host));
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
@@ -84,16 +91,13 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
return (
|
||||
<span className="list-icon-stack" ref={nodeRef}>
|
||||
{status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>}
|
||||
{status === 'loaded' && (
|
||||
{status === 'loaded' && imageUrl && (
|
||||
<img
|
||||
className="list-icon loaded"
|
||||
src={src}
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={() => markWebsiteIconLoaded(host)}
|
||||
onError={() => markWebsiteIconErrored(host)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
const ICON_LOAD_TIMEOUT_MS = 5000;
|
||||
|
||||
interface WebsiteIconRecord {
|
||||
status: WebsiteIconStatus;
|
||||
promise: Promise<WebsiteIconStatus> | null;
|
||||
imageUrl: string | null;
|
||||
listeners: Set<(status: WebsiteIconStatus) => void>;
|
||||
}
|
||||
|
||||
@@ -14,6 +17,7 @@ function ensureRecord(host: string): WebsiteIconRecord {
|
||||
record = {
|
||||
status: 'idle',
|
||||
promise: null,
|
||||
imageUrl: null,
|
||||
listeners: new Set(),
|
||||
};
|
||||
iconRecords.set(host, record);
|
||||
@@ -34,6 +38,11 @@ export function getWebsiteIconStatus(host: string): WebsiteIconStatus {
|
||||
return ensureRecord(host).status;
|
||||
}
|
||||
|
||||
export function getWebsiteIconImageUrl(host: string): string {
|
||||
if (!host) return '';
|
||||
return ensureRecord(host).imageUrl || '';
|
||||
}
|
||||
|
||||
export function subscribeWebsiteIconStatus(host: string, listener: (status: WebsiteIconStatus) => void): () => void {
|
||||
if (!host) return () => undefined;
|
||||
const record = ensureRecord(host);
|
||||
@@ -43,10 +52,13 @@ export function subscribeWebsiteIconStatus(host: string, listener: (status: Webs
|
||||
};
|
||||
}
|
||||
|
||||
export function markWebsiteIconLoaded(host: string): void {
|
||||
export function markWebsiteIconLoaded(host: string, imageUrl?: string): void {
|
||||
if (!host) return;
|
||||
const record = ensureRecord(host);
|
||||
record.promise = null;
|
||||
if (imageUrl) {
|
||||
record.imageUrl = imageUrl;
|
||||
}
|
||||
notifyRecord(host, 'loaded');
|
||||
}
|
||||
|
||||
@@ -54,9 +66,19 @@ export function markWebsiteIconErrored(host: string): void {
|
||||
if (!host) return;
|
||||
const record = ensureRecord(host);
|
||||
record.promise = null;
|
||||
record.imageUrl = null;
|
||||
notifyRecord(host, 'error');
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read icon'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export function preloadWebsiteIcon(host: string, src: string): Promise<WebsiteIconStatus> {
|
||||
if (!host) return Promise.resolve('error');
|
||||
|
||||
@@ -68,21 +90,31 @@ export function preloadWebsiteIcon(host: string, src: string): Promise<WebsiteIc
|
||||
return record.promise;
|
||||
}
|
||||
|
||||
record.status = 'loading';
|
||||
record.promise = new Promise<WebsiteIconStatus>((resolve) => {
|
||||
const img = new Image();
|
||||
img.decoding = 'async';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.onload = () => {
|
||||
markWebsiteIconLoaded(host);
|
||||
resolve('loaded');
|
||||
};
|
||||
img.onerror = () => {
|
||||
notifyRecord(host, 'loading');
|
||||
record.promise = (async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), ICON_LOAD_TIMEOUT_MS);
|
||||
try {
|
||||
const resp = await fetch(src, {
|
||||
cache: 'force-cache',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.ok) throw new Error('Icon unavailable');
|
||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||
if (!contentType.startsWith('image/')) throw new Error('Icon response is not an image');
|
||||
const blob = await resp.blob();
|
||||
if (!blob.size) throw new Error('Icon response is empty');
|
||||
const imageUrl = await blobToDataUrl(blob);
|
||||
if (!imageUrl) throw new Error('Icon response is empty');
|
||||
markWebsiteIconLoaded(host, imageUrl);
|
||||
return 'loaded';
|
||||
} catch {
|
||||
markWebsiteIconErrored(host);
|
||||
resolve('error');
|
||||
};
|
||||
img.src = src;
|
||||
});
|
||||
return 'error';
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
})();
|
||||
|
||||
return record.promise;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
:root[data-theme='dark'] .muted,
|
||||
:root[data-theme='dark'] .detail-sub,
|
||||
:root[data-theme='dark'] .detail-folder-line,
|
||||
:root[data-theme='dark'] .field-help,
|
||||
:root[data-theme='dark'] .list-sub,
|
||||
:root[data-theme='dark'] .kv-label,
|
||||
@@ -296,3 +297,8 @@
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .not-found-code {
|
||||
background: color-mix(in srgb, var(--primary) 18%, var(--panel));
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ input[type='file'].input::file-selector-button:hover {
|
||||
}
|
||||
|
||||
.or {
|
||||
@apply my-2.5 text-center text-slate-700;
|
||||
@apply text-center text-slate-700;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
|
||||
@@ -61,15 +61,15 @@
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.auth-page {
|
||||
@apply items-start p-3.5;
|
||||
@apply items-center p-3.5;
|
||||
}
|
||||
|
||||
.standalone-shell {
|
||||
@apply w-full max-w-[460px] gap-2.5 pt-3;
|
||||
@apply w-full max-w-[460px] gap-2.5;
|
||||
}
|
||||
|
||||
.standalone-brand-outside {
|
||||
@apply justify-start;
|
||||
@apply justify-center;
|
||||
}
|
||||
|
||||
.standalone-brand-logo {
|
||||
@@ -663,6 +663,49 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-module h3 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settings-module .field,
|
||||
.auth-card .field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settings-module .field > span,
|
||||
.auth-card .field > span {
|
||||
margin-top: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.settings-module .field-grid,
|
||||
.auth-card .field-grid,
|
||||
.session-timeout-fields {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-module .btn,
|
||||
.auth-card .btn:not(.full) {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.dialog-mask.totp-scan-mask {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: #0f172a;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.dialog-card.totp-scan-dialog {
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.backup-interval-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -664,18 +664,17 @@
|
||||
}
|
||||
|
||||
.dialog-mask.totp-scan-mask {
|
||||
@apply block p-0;
|
||||
background: #0f172a;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
@apply grid place-items-center p-5;
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
}
|
||||
|
||||
.dialog-card.totp-scan-dialog {
|
||||
@apply flex h-dvh w-screen max-w-none flex-col overflow-hidden rounded-none border-0 p-0 text-left;
|
||||
max-height: 100dvh;
|
||||
@apply flex w-full max-w-[560px] flex-col overflow-hidden rounded-3xl border-0 p-0 text-left;
|
||||
height: min(720px, calc(100dvh - 48px));
|
||||
max-height: calc(100dvh - 48px);
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
box-shadow: none;
|
||||
box-shadow: 0 28px 80px rgba(2, 6, 23, 0.45);
|
||||
}
|
||||
|
||||
.totp-scan-head {
|
||||
@@ -698,8 +697,7 @@
|
||||
}
|
||||
|
||||
.totp-scan-frame {
|
||||
@apply relative min-h-0 flex-1 overflow-hidden rounded-none;
|
||||
aspect-ratio: auto;
|
||||
@apply relative min-h-0 flex-1 overflow-hidden;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
@@ -738,6 +736,39 @@
|
||||
@apply flex min-h-full flex-col;
|
||||
}
|
||||
|
||||
.detail-title-row {
|
||||
@apply flex min-w-0 items-center gap-3;
|
||||
}
|
||||
|
||||
.detail-title-icon {
|
||||
@apply flex h-11 w-11 shrink-0 items-center justify-center;
|
||||
}
|
||||
|
||||
.detail-title-icon .list-icon-wrap,
|
||||
.detail-title-icon .list-icon-stack,
|
||||
.detail-title-icon .list-icon,
|
||||
.detail-title-icon .list-icon-fallback {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.detail-title-main {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.detail-folder-line {
|
||||
@apply mt-1 flex min-w-0 items-center gap-1.5 text-xs font-semibold;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.detail-folder-line span {
|
||||
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.detail-folder-line svg {
|
||||
@apply shrink-0;
|
||||
}
|
||||
|
||||
.totp-codes-list {
|
||||
@apply grid w-full items-start gap-2.5;
|
||||
grid-template-columns: repeat(var(--totp-columns, 1), minmax(300px, 1fr));
|
||||
@@ -953,3 +984,12 @@
|
||||
@apply grid min-h-[120px] place-items-center;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.vault-error-state {
|
||||
@apply gap-3 text-center;
|
||||
}
|
||||
|
||||
.vault-error-state strong {
|
||||
@apply text-sm;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user