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[]; ciphers: Cipher[];
folders: Folder[]; folders: Folder[];
loading: boolean; loading: boolean;
error: string;
emailForReprompt: string; emailForReprompt: string;
onRefresh: () => Promise<void>; onRefresh: () => Promise<void>;
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>; onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
@@ -1021,6 +1022,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
<VaultListPanel <VaultListPanel
busy={busy} busy={busy}
loading={props.loading} loading={props.loading}
error={props.error}
searchInput={searchInput} searchInput={searchInput}
sortMode={sortMode} sortMode={sortMode}
sortMenuOpen={sortMenuOpen} sortMenuOpen={sortMenuOpen}
@@ -1140,7 +1142,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
</div> </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> </section>
</div> </div>
@@ -1,12 +1,13 @@
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useMemo, useState } from 'preact/hooks'; 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 { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
TOTP_PERIOD_SECONDS, TOTP_PERIOD_SECONDS,
TOTP_RING_CIRCUMFERENCE, TOTP_RING_CIRCUMFERENCE,
VaultListIcon,
copyToClipboard, copyToClipboard,
formatAttachmentSize, formatAttachmentSize,
formatHistoryTime, formatHistoryTime,
@@ -115,8 +116,18 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
{(Number(props.selectedCipher.reprompt || 0) !== 1 || props.repromptApprovedCipherId === props.selectedCipher.id) && ( {(Number(props.selectedCipher.reprompt || 0) !== 1 || props.repromptApprovedCipherId === props.selectedCipher.id) && (
<> <>
<div className="card"> <div className="card">
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3> <div className="detail-title-row">
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div> <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>} {isArchived && <div className="list-badge archive-badge">{t('txt_archived')}</div>}
</div> </div>
+10 -1
View File
@@ -24,6 +24,7 @@ interface VirtualRange {
interface VaultListPanelProps { interface VaultListPanelProps {
busy: boolean; busy: boolean;
loading: boolean; loading: boolean;
error: string;
searchInput: string; searchInput: string;
sortMode: VaultSortMode; sortMode: VaultSortMode;
sortMenuOpen: boolean; 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)}> <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.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 && ( {!!props.filteredCiphers.length && (
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}> <div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
{props.visibleCiphers.map((cipher) => ( {props.visibleCiphers.map((cipher) => (
@@ -253,7 +262,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
))} ))}
</div> </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> </div>
</section> </section>
); );
+13 -9
View File
@@ -3,9 +3,8 @@ import type { ComponentChildren } from 'preact';
import { Globe } from 'lucide-preact'; import { Globe } from 'lucide-preact';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { import {
getWebsiteIconImageUrl,
getWebsiteIconStatus, getWebsiteIconStatus,
markWebsiteIconErrored,
markWebsiteIconLoaded,
preloadWebsiteIcon, preloadWebsiteIcon,
subscribeWebsiteIconStatus, subscribeWebsiteIconStatus,
} from '@/lib/website-icon-cache'; } from '@/lib/website-icon-cache';
@@ -24,17 +23,23 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
const nodeRef = useRef<HTMLSpanElement | null>(null); const nodeRef = useRef<HTMLSpanElement | null>(null);
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true)); const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle')); const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
useEffect(() => { useEffect(() => {
if (!host) { if (!host) {
setShouldLoad(true); setShouldLoad(true);
setStatus('idle'); setStatus('idle');
setImageUrl('');
return; return;
} }
const nextStatus = getWebsiteIconStatus(host); const nextStatus = getWebsiteIconStatus(host);
setShouldLoad(nextStatus === 'loaded'); setShouldLoad(nextStatus === 'loaded');
setStatus(nextStatus); setStatus(nextStatus);
return subscribeWebsiteIconStatus(host, setStatus); setImageUrl(getWebsiteIconImageUrl(host));
return subscribeWebsiteIconStatus(host, (next) => {
setStatus(next);
setImageUrl(getWebsiteIconImageUrl(host));
});
}, [host]); }, [host]);
useEffect(() => { useEffect(() => {
@@ -70,7 +75,9 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return; if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return;
let disposed = false; let disposed = false;
void preloadWebsiteIcon(host, src).then((nextStatus) => { void preloadWebsiteIcon(host, src).then((nextStatus) => {
if (!disposed) setStatus(nextStatus); if (disposed) return;
setStatus(nextStatus);
setImageUrl(getWebsiteIconImageUrl(host));
}); });
return () => { return () => {
disposed = true; disposed = true;
@@ -84,16 +91,13 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
return ( return (
<span className="list-icon-stack" ref={nodeRef}> <span className="list-icon-stack" ref={nodeRef}>
{status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>} {status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>}
{status === 'loaded' && ( {status === 'loaded' && imageUrl && (
<img <img
className="list-icon loaded" className="list-icon loaded"
src={src} src={imageUrl}
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
referrerPolicy="no-referrer"
onLoad={() => markWebsiteIconLoaded(host)}
onError={() => markWebsiteIconErrored(host)}
/> />
)} )}
</span> </span>
+47 -15
View File
@@ -1,8 +1,11 @@
type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error'; type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error';
const ICON_LOAD_TIMEOUT_MS = 5000;
interface WebsiteIconRecord { interface WebsiteIconRecord {
status: WebsiteIconStatus; status: WebsiteIconStatus;
promise: Promise<WebsiteIconStatus> | null; promise: Promise<WebsiteIconStatus> | null;
imageUrl: string | null;
listeners: Set<(status: WebsiteIconStatus) => void>; listeners: Set<(status: WebsiteIconStatus) => void>;
} }
@@ -14,6 +17,7 @@ function ensureRecord(host: string): WebsiteIconRecord {
record = { record = {
status: 'idle', status: 'idle',
promise: null, promise: null,
imageUrl: null,
listeners: new Set(), listeners: new Set(),
}; };
iconRecords.set(host, record); iconRecords.set(host, record);
@@ -34,6 +38,11 @@ export function getWebsiteIconStatus(host: string): WebsiteIconStatus {
return ensureRecord(host).status; 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 { export function subscribeWebsiteIconStatus(host: string, listener: (status: WebsiteIconStatus) => void): () => void {
if (!host) return () => undefined; if (!host) return () => undefined;
const record = ensureRecord(host); 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; if (!host) return;
const record = ensureRecord(host); const record = ensureRecord(host);
record.promise = null; record.promise = null;
if (imageUrl) {
record.imageUrl = imageUrl;
}
notifyRecord(host, 'loaded'); notifyRecord(host, 'loaded');
} }
@@ -54,9 +66,19 @@ export function markWebsiteIconErrored(host: string): void {
if (!host) return; if (!host) return;
const record = ensureRecord(host); const record = ensureRecord(host);
record.promise = null; record.promise = null;
record.imageUrl = null;
notifyRecord(host, 'error'); 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> { export function preloadWebsiteIcon(host: string, src: string): Promise<WebsiteIconStatus> {
if (!host) return Promise.resolve('error'); if (!host) return Promise.resolve('error');
@@ -68,21 +90,31 @@ export function preloadWebsiteIcon(host: string, src: string): Promise<WebsiteIc
return record.promise; return record.promise;
} }
record.status = 'loading'; notifyRecord(host, 'loading');
record.promise = new Promise<WebsiteIconStatus>((resolve) => { record.promise = (async () => {
const img = new Image(); const controller = new AbortController();
img.decoding = 'async'; const timeout = window.setTimeout(() => controller.abort(), ICON_LOAD_TIMEOUT_MS);
img.referrerPolicy = 'no-referrer'; try {
img.onload = () => { const resp = await fetch(src, {
markWebsiteIconLoaded(host); cache: 'force-cache',
resolve('loaded'); signal: controller.signal,
}; });
img.onerror = () => { 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); markWebsiteIconErrored(host);
resolve('error'); return 'error';
}; } finally {
img.src = src; window.clearTimeout(timeout);
}); }
})();
return record.promise; return record.promise;
} }
+6
View File
@@ -36,6 +36,7 @@
:root[data-theme='dark'] .muted, :root[data-theme='dark'] .muted,
:root[data-theme='dark'] .detail-sub, :root[data-theme='dark'] .detail-sub,
:root[data-theme='dark'] .detail-folder-line,
:root[data-theme='dark'] .field-help, :root[data-theme='dark'] .field-help,
:root[data-theme='dark'] .list-sub, :root[data-theme='dark'] .list-sub,
:root[data-theme='dark'] .kv-label, :root[data-theme='dark'] .kv-label,
@@ -296,3 +297,8 @@
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
-webkit-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 { .or {
@apply my-2.5 text-center text-slate-700; @apply text-center text-slate-700;
} }
.field-help { .field-help {
+46 -3
View File
@@ -61,15 +61,15 @@
@media (max-width: 1180px) { @media (max-width: 1180px) {
.auth-page { .auth-page {
@apply items-start p-3.5; @apply items-center p-3.5;
} }
.standalone-shell { .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 { .standalone-brand-outside {
@apply justify-start; @apply justify-center;
} }
.standalone-brand-logo { .standalone-brand-logo {
@@ -663,6 +663,49 @@
} }
@media (max-width: 640px) { @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 { .backup-interval-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+49 -9
View File
@@ -664,18 +664,17 @@
} }
.dialog-mask.totp-scan-mask { .dialog-mask.totp-scan-mask {
@apply block p-0; @apply grid place-items-center p-5;
background: #0f172a; background: rgba(15, 23, 42, 0.78);
backdrop-filter: none;
-webkit-backdrop-filter: none;
} }
.dialog-card.totp-scan-dialog { .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; @apply flex w-full max-w-[560px] flex-col overflow-hidden rounded-3xl border-0 p-0 text-left;
max-height: 100dvh; height: min(720px, calc(100dvh - 48px));
max-height: calc(100dvh - 48px);
background: #0f172a; background: #0f172a;
color: #f8fafc; color: #f8fafc;
box-shadow: none; box-shadow: 0 28px 80px rgba(2, 6, 23, 0.45);
} }
.totp-scan-head { .totp-scan-head {
@@ -698,8 +697,7 @@
} }
.totp-scan-frame { .totp-scan-frame {
@apply relative min-h-0 flex-1 overflow-hidden rounded-none; @apply relative min-h-0 flex-1 overflow-hidden;
aspect-ratio: auto;
background: #0f172a; background: #0f172a;
} }
@@ -738,6 +736,39 @@
@apply flex min-h-full flex-col; @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 { .totp-codes-list {
@apply grid w-full items-start gap-2.5; @apply grid w-full items-start gap-2.5;
grid-template-columns: repeat(var(--totp-columns, 1), minmax(300px, 1fr)); grid-template-columns: repeat(var(--totp-columns, 1), minmax(300px, 1fr));
@@ -953,3 +984,12 @@
@apply grid min-h-[120px] place-items-center; @apply grid min-h-[120px] place-items-center;
color: #667085; color: #667085;
} }
.vault-error-state {
@apply gap-3 text-center;
}
.vault-error-state strong {
@apply text-sm;
color: var(--danger);
}