Polish vault icons and mobile layout

This commit is contained in:
shuaiplus
2026-05-04 04:20:23 +08:00
parent 97a3aa691d
commit 1b4d263d6e
9 changed files with 202 additions and 42 deletions
+16 -1
View File
@@ -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>
+10 -1
View File
@@ -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>
);
+13 -9
View File
@@ -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>
+47 -15
View File
@@ -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;
}
+6
View File
@@ -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);
}
+1 -1
View File
@@ -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 {
+46 -3
View File
@@ -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;
}
+49 -9
View File
@@ -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);
}