mirror of
https://github.com/dergigi/boris.git
synced 2026-02-23 07:54:59 +01:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f2ecd5fe1 | ||
|
|
d6be6f364b |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onU
|
|||||||
navigate(`/r/${encodeURIComponent(url)}`)
|
navigate(`/r/${encodeURIComponent(url)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClearCache = () => {
|
const handleClearCache = async () => {
|
||||||
if (confirm('Are you sure you want to clear all cached images?')) {
|
if (confirm('Are you sure you want to clear all cached images?')) {
|
||||||
clearImageCache()
|
await clearImageCache()
|
||||||
setCacheStats(getImageCacheStats())
|
setCacheStats(getImageCacheStats())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { cacheImage, getCachedImage } from '../services/imageCacheService'
|
import { cacheImage, getCachedImage, loadCachedImage } from '../services/imageCacheService'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to cache and retrieve images from localStorage
|
* Hook to cache and retrieve images using Cache API
|
||||||
*
|
*
|
||||||
* @param imageUrl - The URL of the image to cache
|
* @param imageUrl - The URL of the image to cache
|
||||||
* @param settings - User settings to determine if caching is enabled
|
* @param settings - User settings to determine if caching is enabled
|
||||||
* @returns The cached data URL or the original URL
|
* @returns The cached blob URL or the original URL
|
||||||
*/
|
*/
|
||||||
export function useImageCache(
|
export function useImageCache(
|
||||||
imageUrl: string | undefined,
|
imageUrl: string | undefined,
|
||||||
@@ -29,33 +29,59 @@ export function useImageCache(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already cached
|
// Store imageUrl in local variable for closure
|
||||||
const cached = getCachedImage(imageUrl)
|
const urlToCache = imageUrl
|
||||||
if (cached) {
|
|
||||||
console.log('📦 Using cached image:', imageUrl.substring(0, 50))
|
|
||||||
setCachedUrl(cached)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, show original URL while caching in background
|
// Check if image is in cache metadata (fast synchronous check)
|
||||||
setCachedUrl(imageUrl)
|
const isCached = getCachedImage(urlToCache)
|
||||||
|
|
||||||
// Cache image in background
|
if (isCached) {
|
||||||
if (!isLoading) {
|
// Load the cached image asynchronously
|
||||||
setIsLoading(true)
|
loadCachedImage(urlToCache)
|
||||||
const maxSize = settings?.imageCacheSizeMB ?? 210
|
.then(blobUrl => {
|
||||||
|
if (blobUrl) {
|
||||||
cacheImage(imageUrl, maxSize)
|
console.log('📦 Using cached image:', urlToCache.substring(0, 50))
|
||||||
.then(dataUrl => {
|
setCachedUrl(blobUrl)
|
||||||
setCachedUrl(dataUrl)
|
} else {
|
||||||
|
// Not actually cached, fall through to caching logic
|
||||||
|
setCachedUrl(urlToCache)
|
||||||
|
cacheInBackground()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Failed to cache image:', err)
|
console.error('Failed to load cached image:', err)
|
||||||
// Keep using original URL on error
|
setCachedUrl(urlToCache)
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsLoading(false)
|
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// Not cached, show original and cache in background
|
||||||
|
setCachedUrl(urlToCache)
|
||||||
|
cacheInBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheInBackground() {
|
||||||
|
if (!isLoading) {
|
||||||
|
setIsLoading(true)
|
||||||
|
const maxSize = settings?.imageCacheSizeMB ?? 210
|
||||||
|
|
||||||
|
cacheImage(urlToCache, maxSize)
|
||||||
|
.then(blobUrl => {
|
||||||
|
setCachedUrl(blobUrl)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to cache image:', err)
|
||||||
|
// Keep using original URL on error
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup: revoke blob URLs when component unmounts or URL changes
|
||||||
|
return () => {
|
||||||
|
if (cachedUrl && cachedUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(cachedUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB])
|
}, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB])
|
||||||
|
|
||||||
@@ -64,7 +90,7 @@ export function useImageCache(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Simpler hook variant that just caches on mount if enabled
|
* Simpler hook variant that just caches on mount if enabled
|
||||||
* Useful for article cover images
|
* Useful for preloading article cover images
|
||||||
*/
|
*/
|
||||||
export function useCacheImageOnLoad(
|
export function useCacheImageOnLoad(
|
||||||
imageUrl: string | undefined,
|
imageUrl: string | undefined,
|
||||||
@@ -76,9 +102,9 @@ export function useCacheImageOnLoad(
|
|||||||
const enableCache = settings?.enableImageCache ?? true
|
const enableCache = settings?.enableImageCache ?? true
|
||||||
if (!enableCache) return
|
if (!enableCache) return
|
||||||
|
|
||||||
// Check if already cached
|
// Check if already cached (fast metadata check)
|
||||||
const cached = getCachedImage(imageUrl)
|
const isCached = getCachedImage(imageUrl)
|
||||||
if (cached) return
|
if (isCached) return
|
||||||
|
|
||||||
// Cache in background
|
// Cache in background
|
||||||
const maxSize = settings?.imageCacheSizeMB ?? 210
|
const maxSize = settings?.imageCacheSizeMB ?? 210
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Image Cache Service
|
* Image Cache Service
|
||||||
*
|
*
|
||||||
* Caches images in localStorage for offline access.
|
* Caches images using the Cache API for offline access.
|
||||||
* Uses LRU (Least Recently Used) eviction when cache size limit is exceeded.
|
* Uses LRU (Least Recently Used) eviction when cache size limit is exceeded.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_PREFIX = 'img_cache_'
|
const CACHE_NAME = 'boris-image-cache-v1'
|
||||||
const CACHE_METADATA_KEY = 'img_cache_metadata'
|
const CACHE_METADATA_KEY = 'img_cache_metadata'
|
||||||
|
|
||||||
interface CacheMetadata {
|
interface CacheMetadata {
|
||||||
[url: string]: {
|
[url: string]: {
|
||||||
key: string
|
|
||||||
size: number
|
size: number
|
||||||
lastAccessed: number
|
lastAccessed: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cache metadata
|
* Get cache metadata from localStorage
|
||||||
*/
|
*/
|
||||||
function getMetadata(): CacheMetadata {
|
function getMetadata(): CacheMetadata {
|
||||||
try {
|
try {
|
||||||
@@ -29,7 +28,7 @@ function getMetadata(): CacheMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save cache metadata
|
* Save cache metadata to localStorage
|
||||||
*/
|
*/
|
||||||
function saveMetadata(metadata: CacheMetadata): void {
|
function saveMetadata(metadata: CacheMetadata): void {
|
||||||
try {
|
try {
|
||||||
@@ -61,24 +60,10 @@ function mbToBytes(mb: number): number {
|
|||||||
return mb * 1024 * 1024
|
return mb * 1024 * 1024
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate cache key for URL
|
|
||||||
*/
|
|
||||||
function getCacheKey(url: string): string {
|
|
||||||
// Use a simple hash of the URL
|
|
||||||
let hash = 0
|
|
||||||
for (let i = 0; i < url.length; i++) {
|
|
||||||
const char = url.charCodeAt(i)
|
|
||||||
hash = ((hash << 5) - hash) + char
|
|
||||||
hash = hash & hash
|
|
||||||
}
|
|
||||||
return `${CACHE_PREFIX}${Math.abs(hash)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evict least recently used images until cache is under limit
|
* Evict least recently used images until cache is under limit
|
||||||
*/
|
*/
|
||||||
function evictLRU(maxSizeBytes: number): void {
|
async function evictLRU(maxSizeBytes: number): Promise<void> {
|
||||||
const metadata = getMetadata()
|
const metadata = getMetadata()
|
||||||
const entries = Object.entries(metadata)
|
const entries = Object.entries(metadata)
|
||||||
|
|
||||||
@@ -86,12 +71,13 @@ function evictLRU(maxSizeBytes: number): void {
|
|||||||
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
|
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
|
||||||
|
|
||||||
let currentSize = getTotalCacheSize()
|
let currentSize = getTotalCacheSize()
|
||||||
|
const cache = await caches.open(CACHE_NAME)
|
||||||
|
|
||||||
for (const [url, item] of entries) {
|
for (const [url, item] of entries) {
|
||||||
if (currentSize <= maxSizeBytes) break
|
if (currentSize <= maxSizeBytes) break
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(item.key)
|
await cache.delete(url)
|
||||||
delete metadata[url]
|
delete metadata[url]
|
||||||
currentSize -= item.size
|
currentSize -= item.size
|
||||||
console.log(`🗑️ Evicted image from cache: ${url.substring(0, 50)}...`)
|
console.log(`🗑️ Evicted image from cache: ${url.substring(0, 50)}...`)
|
||||||
@@ -104,50 +90,32 @@ function evictLRU(maxSizeBytes: number): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch image and convert to data URL
|
* Cache an image using Cache API
|
||||||
*/
|
|
||||||
async function fetchImageAsDataUrl(url: string): Promise<string> {
|
|
||||||
const response = await fetch(url)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch image: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob()
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onloadend = () => {
|
|
||||||
if (typeof reader.result === 'string') {
|
|
||||||
resolve(reader.result)
|
|
||||||
} else {
|
|
||||||
reject(new Error('Failed to convert image to data URL'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.onerror = reject
|
|
||||||
reader.readAsDataURL(blob)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache an image
|
|
||||||
*/
|
*/
|
||||||
export async function cacheImage(
|
export async function cacheImage(
|
||||||
url: string,
|
url: string,
|
||||||
maxCacheSizeMB: number = 50
|
maxCacheSizeMB: number = 210
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// Check if already cached
|
// Check if already cached
|
||||||
const cached = getCachedImage(url)
|
const cached = await getCachedImageUrl(url)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
console.log('✅ Image already cached:', url.substring(0, 50))
|
console.log('✅ Image already cached:', url.substring(0, 50))
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and convert to data URL
|
// Fetch the image
|
||||||
console.log('📥 Caching image:', url.substring(0, 50))
|
console.log('📥 Caching image:', url.substring(0, 50))
|
||||||
const dataUrl = await fetchImageAsDataUrl(url)
|
const response = await fetch(url)
|
||||||
const size = dataUrl.length
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch image: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the response so we can read it twice (once for size, once for cache)
|
||||||
|
const responseClone = response.clone()
|
||||||
|
const blob = await response.blob()
|
||||||
|
const size = blob.size
|
||||||
|
|
||||||
// Check if image alone exceeds cache limit
|
// Check if image alone exceeds cache limit
|
||||||
if (bytesToMB(size) > maxCacheSizeMB) {
|
if (bytesToMB(size) > maxCacheSizeMB) {
|
||||||
@@ -160,43 +128,25 @@ export async function cacheImage(
|
|||||||
// Evict old images if necessary
|
// Evict old images if necessary
|
||||||
const currentSize = getTotalCacheSize()
|
const currentSize = getTotalCacheSize()
|
||||||
if (currentSize + size > maxSizeBytes) {
|
if (currentSize + size > maxSizeBytes) {
|
||||||
evictLRU(maxSizeBytes - size)
|
await evictLRU(maxSizeBytes - size)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store image
|
// Store in Cache API
|
||||||
const key = getCacheKey(url)
|
const cache = await caches.open(CACHE_NAME)
|
||||||
|
await cache.put(url, responseClone)
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
const metadata = getMetadata()
|
const metadata = getMetadata()
|
||||||
|
metadata[url] = {
|
||||||
try {
|
size,
|
||||||
localStorage.setItem(key, dataUrl)
|
lastAccessed: Date.now()
|
||||||
metadata[url] = {
|
|
||||||
key,
|
|
||||||
size,
|
|
||||||
lastAccessed: Date.now()
|
|
||||||
}
|
|
||||||
saveMetadata(metadata)
|
|
||||||
|
|
||||||
console.log(`💾 Cached image (${bytesToMB(size).toFixed(2)}MB). Total cache: ${bytesToMB(getTotalCacheSize()).toFixed(2)}MB`)
|
|
||||||
return dataUrl
|
|
||||||
} catch (err) {
|
|
||||||
// If storage fails, try evicting more and retry once
|
|
||||||
console.warn('Storage full, evicting more items...')
|
|
||||||
evictLRU(maxSizeBytes / 2) // Free up half the cache
|
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.setItem(key, dataUrl)
|
|
||||||
metadata[url] = {
|
|
||||||
key,
|
|
||||||
size,
|
|
||||||
lastAccessed: Date.now()
|
|
||||||
}
|
|
||||||
saveMetadata(metadata)
|
|
||||||
return dataUrl
|
|
||||||
} catch {
|
|
||||||
console.error('Failed to cache image after eviction')
|
|
||||||
return url // Return original URL on failure
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
saveMetadata(metadata)
|
||||||
|
|
||||||
|
console.log(`💾 Cached image (${bytesToMB(size).toFixed(2)}MB). Total cache: ${bytesToMB(getTotalCacheSize()).toFixed(2)}MB`)
|
||||||
|
|
||||||
|
// Return blob URL for immediate use
|
||||||
|
return URL.createObjectURL(blob)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to cache image:', err)
|
console.error('Failed to cache image:', err)
|
||||||
return url // Return original URL on error
|
return url // Return original URL on error
|
||||||
@@ -204,50 +154,53 @@ export async function cacheImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cached image
|
* Get cached image URL (creates blob URL from cached response)
|
||||||
*/
|
*/
|
||||||
export function getCachedImage(url: string): string | null {
|
async function getCachedImageUrl(url: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const metadata = getMetadata()
|
const cache = await caches.open(CACHE_NAME)
|
||||||
const item = metadata[url]
|
const response = await cache.match(url)
|
||||||
|
|
||||||
if (!item) return null
|
if (!response) {
|
||||||
|
|
||||||
const dataUrl = localStorage.getItem(item.key)
|
|
||||||
if (!dataUrl) {
|
|
||||||
// Clean up stale metadata
|
|
||||||
delete metadata[url]
|
|
||||||
saveMetadata(metadata)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last accessed time
|
// Update last accessed time in metadata
|
||||||
item.lastAccessed = Date.now()
|
const metadata = getMetadata()
|
||||||
metadata[url] = item
|
if (metadata[url]) {
|
||||||
saveMetadata(metadata)
|
metadata[url].lastAccessed = Date.now()
|
||||||
|
saveMetadata(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
return dataUrl
|
// Convert response to blob URL
|
||||||
|
const blob = await response.blob()
|
||||||
|
return URL.createObjectURL(blob)
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached image (synchronous wrapper that returns null, actual loading happens async)
|
||||||
|
* This maintains backward compatibility with the hook's synchronous check
|
||||||
|
*/
|
||||||
|
export function getCachedImage(url: string): string | null {
|
||||||
|
// Check if we have metadata for this URL
|
||||||
|
const metadata = getMetadata()
|
||||||
|
return metadata[url] ? url : null // Return URL if in metadata, let hook handle async loading
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all cached images
|
* Clear all cached images
|
||||||
*/
|
*/
|
||||||
export function clearImageCache(): void {
|
export async function clearImageCache(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const metadata = getMetadata()
|
// Clear from Cache API
|
||||||
|
await caches.delete(CACHE_NAME)
|
||||||
for (const item of Object.values(metadata)) {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(item.key)
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to remove cached image:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Clear metadata from localStorage
|
||||||
localStorage.removeItem(CACHE_METADATA_KEY)
|
localStorage.removeItem(CACHE_METADATA_KEY)
|
||||||
|
|
||||||
console.log('🗑️ Cleared all cached images')
|
console.log('🗑️ Cleared all cached images')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to clear image cache:', err)
|
console.error('Failed to clear image cache:', err)
|
||||||
@@ -276,3 +229,9 @@ export function getImageCacheStats(): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load cached image asynchronously (for use in hooks/components)
|
||||||
|
*/
|
||||||
|
export async function loadCachedImage(url: string): Promise<string | null> {
|
||||||
|
return getCachedImageUrl(url)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user