diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index c7b7894..a15b015 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -36,6 +36,7 @@ interface VaultPageProps { ciphers: Cipher[]; folders: Folder[]; loading: boolean; + error: string; emailForReprompt: string; onRefresh: () => Promise; onCreate: (draft: VaultDraft, attachments?: File[]) => Promise; @@ -1021,6 +1022,7 @@ const folderName = useCallback((id: string | null | undefined): string => { { )} - {!isEditing && !selectedCipher && (props.loading ? :
{t('txt_select_an_item')}
)} + {!isEditing && !selectedCipher && ( + props.loading + ? + : props.error + ? ( +
+ {props.error} + +
+ ) + :
{t('txt_select_an_item')}
+ )} diff --git a/webapp/src/components/vault/VaultDetailView.tsx b/webapp/src/components/vault/VaultDetailView.tsx index 6bcd2d4..63e8c8c 100644 --- a/webapp/src/components/vault/VaultDetailView.tsx +++ b/webapp/src/components/vault/VaultDetailView.tsx @@ -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) && ( <>
-

{props.selectedCipher.decName || t('txt_no_name')}

-
{props.folderName(props.selectedCipher.folderId)}
+
+ +
+

{props.selectedCipher.decName || t('txt_no_name')}

+
+
+
+
{isArchived &&
{t('txt_archived')}
}
diff --git a/webapp/src/components/vault/VaultListPanel.tsx b/webapp/src/components/vault/VaultListPanel.tsx index ffc9b14..27b345d 100644 --- a/webapp/src/components/vault/VaultListPanel.tsx +++ b/webapp/src/components/vault/VaultListPanel.tsx @@ -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) {
props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}> {props.loading && !props.filteredCiphers.length && } + {!props.loading && !!props.error && !props.filteredCiphers.length && ( +
+ {props.error} + +
+ )} {!!props.filteredCiphers.length && (
{props.visibleCiphers.map((cipher) => ( @@ -253,7 +262,7 @@ export default function VaultListPanel(props: VaultListPanelProps) { ))}
)} - {!props.loading && !props.filteredCiphers.length &&
{t('txt_no_items')}
} + {!props.loading && !props.error && !props.filteredCiphers.length &&
{t('txt_no_items')}
}
); diff --git a/webapp/src/components/vault/WebsiteIcon.tsx b/webapp/src/components/vault/WebsiteIcon.tsx index 2eaecba..8f4c8e4 100644 --- a/webapp/src/components/vault/WebsiteIcon.tsx +++ b/webapp/src/components/vault/WebsiteIcon.tsx @@ -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(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 ( {status !== 'loaded' && {props.fallback ?? }} - {status === 'loaded' && ( + {status === 'loaded' && imageUrl && ( markWebsiteIconLoaded(host)} - onError={() => markWebsiteIconErrored(host)} /> )} diff --git a/webapp/src/lib/website-icon-cache.ts b/webapp/src/lib/website-icon-cache.ts index 9be5bc7..04bbcba 100644 --- a/webapp/src/lib/website-icon-cache.ts +++ b/webapp/src/lib/website-icon-cache.ts @@ -1,8 +1,11 @@ type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error'; +const ICON_LOAD_TIMEOUT_MS = 5000; + interface WebsiteIconRecord { status: WebsiteIconStatus; promise: Promise | 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 { + 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 { if (!host) return Promise.resolve('error'); @@ -68,21 +90,31 @@ export function preloadWebsiteIcon(host: string, src: string): Promise((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; } diff --git a/webapp/src/styles/dark.css b/webapp/src/styles/dark.css index 9538d50..b08fdc8 100644 --- a/webapp/src/styles/dark.css +++ b/webapp/src/styles/dark.css @@ -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); +} diff --git a/webapp/src/styles/forms.css b/webapp/src/styles/forms.css index ea2e7a8..2d4124e 100644 --- a/webapp/src/styles/forms.css +++ b/webapp/src/styles/forms.css @@ -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 { diff --git a/webapp/src/styles/responsive.css b/webapp/src/styles/responsive.css index e1b1126..04ed68c 100644 --- a/webapp/src/styles/responsive.css +++ b/webapp/src/styles/responsive.css @@ -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; } diff --git a/webapp/src/styles/vault.css b/webapp/src/styles/vault.css index 6aaafdd..70ebe63 100644 --- a/webapp/src/styles/vault.css +++ b/webapp/src/styles/vault.css @@ -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); +}