feat: optimize profile image sizes, silent PWA updates, storage cleanup fixes
- Resize avatars/icons to 256px and banners to 1280px (client crop + server safety net) - Add server-side resizeProfileImage() for federation/API uploads without crop modal - Fix unconstrained crop on RegisterPage and CreateSpace (was missing maxOutputDimension) - PWA: switch to autoUpdate with skipWaiting/clientsClaim for seamless deploys - Storage janitor: exclude profile images from unlinked cleanup, delete stale thumbnails - Add deleteAttachmentByFilename() to clean orphaned attachment records for profile images - Migration: one-time cleanup of stale profile image attachment records - GeneralPanel: wrap in <form> to prevent implicit submission
This commit is contained in:
@@ -323,6 +323,9 @@ export function runMigrations(db: Database.Database): void {
|
||||
// ─── Add indexes on FK columns for query performance ─────────────────────
|
||||
migrateAddIndexes(db);
|
||||
|
||||
// ─── Clean up stale attachment records for profile images ───────────────
|
||||
migrateCleanupProfileAttachmentRecords(db);
|
||||
|
||||
console.log('Migrations complete.');
|
||||
}
|
||||
|
||||
@@ -884,6 +887,79 @@ function migrateReplicatedUsernames(db: Database.Database): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale attachment records left behind by profile image uploads.
|
||||
* Profile images (avatars, banners, space icons) go through POST /api/uploads
|
||||
* but are referenced by users/spaces columns, not by attachments.message_id.
|
||||
* This leaves orphaned attachment records that inflate the "Unlinked Uploads"
|
||||
* count in the storage panel.
|
||||
*
|
||||
* Gated by a persistent flag so it runs exactly once.
|
||||
*/
|
||||
function migrateCleanupProfileAttachmentRecords(db: Database.Database): void {
|
||||
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
|
||||
if (!cols.some(c => c.name === 'profile_attachments_cleaned')) {
|
||||
db.exec('ALTER TABLE instance_settings ADD COLUMN profile_attachments_cleaned INTEGER DEFAULT 0');
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT profile_attachments_cleaned FROM instance_settings WHERE id = 1').get() as
|
||||
{ profile_attachments_cleaned: number } | undefined;
|
||||
if (row && row.profile_attachments_cleaned === 1) return;
|
||||
|
||||
// Collect all filenames currently referenced by profiles
|
||||
const profileFilenames = new Set<string>();
|
||||
|
||||
const avatarRows = db.prepare('SELECT avatar FROM users WHERE avatar IS NOT NULL').all() as { avatar: string }[];
|
||||
for (const r of avatarRows) profileFilenames.add(path.basename(r.avatar));
|
||||
|
||||
const bannerRows = db.prepare('SELECT banner FROM users WHERE banner IS NOT NULL').all() as { banner: string }[];
|
||||
for (const r of bannerRows) profileFilenames.add(path.basename(r.banner));
|
||||
|
||||
const iconRows = db.prepare('SELECT icon FROM spaces WHERE icon IS NOT NULL').all() as { icon: string }[];
|
||||
for (const r of iconRows) profileFilenames.add(path.basename(r.icon));
|
||||
|
||||
const spaceBannerRows = db.prepare('SELECT banner FROM spaces WHERE banner IS NOT NULL').all() as { banner: string }[];
|
||||
for (const r of spaceBannerRows) profileFilenames.add(path.basename(r.banner));
|
||||
|
||||
// Find unlinked attachment records (no message reference)
|
||||
const unlinkedRows = db.prepare(
|
||||
'SELECT id, filename FROM attachments WHERE message_id IS NULL AND dm_message_id IS NULL'
|
||||
).all() as { id: string; filename: string }[];
|
||||
|
||||
const deleteStmt = db.prepare('DELETE FROM attachments WHERE id = ?');
|
||||
let cleaned = 0;
|
||||
|
||||
for (const att of unlinkedRows) {
|
||||
const basename = path.basename(att.filename);
|
||||
// Delete if the file is a current profile image (record is unnecessary)
|
||||
// or if the file no longer exists on disk (stale record from a replaced profile image)
|
||||
if (profileFilenames.has(basename)) {
|
||||
deleteStmt.run(att.id);
|
||||
cleaned++;
|
||||
} else {
|
||||
// Check if the file still exists on disk — if not, this is a stale
|
||||
// record from a previously-replaced profile image whose file was
|
||||
// already deleted by the PATCH handler
|
||||
try {
|
||||
const uploadDir = process.env.UPLOAD_DIR || path.join(process.cwd(), 'data', 'uploads');
|
||||
const filePath = path.join(uploadDir, basename);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
deleteStmt.run(att.id);
|
||||
cleaned++;
|
||||
}
|
||||
} catch {
|
||||
// Skip on error — the normal cleanup can handle it later
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned > 0) {
|
||||
console.log(`Migrating: Cleaned up ${cleaned} stale profile image attachment record(s)`);
|
||||
}
|
||||
|
||||
db.prepare('UPDATE instance_settings SET profile_attachments_cleaned = 1 WHERE id = 1').run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Async backfill: generate thumbnails for all existing image attachments that
|
||||
* don't have one yet. Runs once after server startup, gated by a persistent
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from 'path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
@@ -7,7 +8,9 @@ import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, Pe
|
||||
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
||||
import crypto from 'crypto';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import { deleteAttachmentFiles, deleteUploadFile } from '../utils/fileCleanup.js';
|
||||
import { deleteAttachmentFiles, deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js';
|
||||
import { resizeProfileImage } from '../utils/thumbnail.js';
|
||||
import { config } from '../config.js';
|
||||
import type {
|
||||
CreateSpaceRequest,
|
||||
UpdateSpaceRequest,
|
||||
@@ -173,6 +176,24 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Register the creator in connectionManager so they receive WS broadcasts for this space
|
||||
connectionManager.addUserSpace(request.userId, spaceId);
|
||||
|
||||
// Clean up attachment records for icon/banner — reference is now in spaces table
|
||||
if (icon && typeof icon === 'string' && icon.includes('/api/uploads/')) {
|
||||
deleteAttachmentByFilename(icon);
|
||||
}
|
||||
if (banner && typeof banner === 'string' && banner.includes('/api/uploads/')) {
|
||||
deleteAttachmentByFilename(banner);
|
||||
}
|
||||
|
||||
// Resize profile images to optimal dimensions
|
||||
if (icon && typeof icon === 'string' && !icon.startsWith('http')) {
|
||||
const filePath = path.join(config.uploadDir, path.basename(icon));
|
||||
await resizeProfileImage(filePath, 'icon');
|
||||
}
|
||||
if (banner && typeof banner === 'string' && !banner.startsWith('http')) {
|
||||
const filePath = path.join(config.uploadDir, path.basename(banner));
|
||||
await resizeProfileImage(filePath, 'banner');
|
||||
}
|
||||
|
||||
return reply.code(201).send(rowToSpace(server));
|
||||
});
|
||||
|
||||
@@ -412,9 +433,29 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Clean up old icon/banner files that were replaced
|
||||
if (icon !== undefined && oldIcon && oldIcon !== (icon || null) && !oldIcon.startsWith('http')) {
|
||||
deleteUploadFile(oldIcon);
|
||||
deleteAttachmentByFilename(oldIcon);
|
||||
}
|
||||
if (banner !== undefined && oldBanner && oldBanner !== (banner || null) && !oldBanner.startsWith('http')) {
|
||||
deleteUploadFile(oldBanner);
|
||||
deleteAttachmentByFilename(oldBanner);
|
||||
}
|
||||
// Clean up attachment records for newly-set profile images — the reference
|
||||
// now lives in the spaces table, so the attachment record is unnecessary
|
||||
if (icon && typeof icon === 'string' && icon.includes('/api/uploads/')) {
|
||||
deleteAttachmentByFilename(icon);
|
||||
}
|
||||
if (banner && typeof banner === 'string' && banner.includes('/api/uploads/')) {
|
||||
deleteAttachmentByFilename(banner);
|
||||
}
|
||||
|
||||
// Resize profile images to optimal dimensions
|
||||
if (icon && typeof icon === 'string' && !icon.startsWith('http')) {
|
||||
const filePath = path.join(config.uploadDir, path.basename(icon));
|
||||
await resizeProfileImage(filePath, 'icon');
|
||||
}
|
||||
if (banner && typeof banner === 'string' && !banner.startsWith('http')) {
|
||||
const filePath = path.join(config.uploadDir, path.basename(banner));
|
||||
await resizeProfileImage(filePath, 'banner');
|
||||
}
|
||||
|
||||
const updated = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
||||
|
||||
@@ -6,9 +6,12 @@ import { connectionManager } from '../ws/handler.js';
|
||||
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder } from '@backspace/shared';
|
||||
import { AVATAR_COLORS } from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import { deleteUploadFile } from '../utils/fileCleanup.js';
|
||||
import { deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js';
|
||||
import { tombstoneUser } from '../utils/userDeletion.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { resizeProfileImage } from '../utils/thumbnail.js';
|
||||
import { config } from '../config.js';
|
||||
import path from 'path';
|
||||
|
||||
/** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */
|
||||
function isValidAssetUrl(url: string | null | undefined): boolean {
|
||||
@@ -332,9 +335,29 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Clean up old avatar/banner files that were replaced
|
||||
if (avatar !== undefined && oldAvatar && oldAvatar !== (avatar || null) && !oldAvatar.startsWith('http')) {
|
||||
deleteUploadFile(oldAvatar);
|
||||
deleteAttachmentByFilename(oldAvatar);
|
||||
}
|
||||
if (banner !== undefined && oldBanner && oldBanner !== (updateData.banner ?? null) && !oldBanner.startsWith('http')) {
|
||||
deleteUploadFile(oldBanner);
|
||||
deleteAttachmentByFilename(oldBanner);
|
||||
}
|
||||
// Clean up attachment records for newly-set profile images — the reference
|
||||
// now lives in the users table, so the attachment record is unnecessary
|
||||
if (avatar && typeof avatar === 'string' && avatar.includes('/api/uploads/')) {
|
||||
deleteAttachmentByFilename(avatar);
|
||||
}
|
||||
if (updateData.banner && typeof updateData.banner === 'string' && updateData.banner.includes('/api/uploads/')) {
|
||||
deleteAttachmentByFilename(updateData.banner);
|
||||
}
|
||||
|
||||
// Resize profile images to optimal dimensions (safety net for federation, API clients, etc.)
|
||||
if (avatar && typeof avatar === 'string' && !avatar.startsWith('http')) {
|
||||
const filePath = path.join(config.uploadDir, path.basename(avatar));
|
||||
await resizeProfileImage(filePath, 'avatar');
|
||||
}
|
||||
if (updateData.banner && typeof updateData.banner === 'string' && !updateData.banner.startsWith('http')) {
|
||||
const filePath = path.join(config.uploadDir, path.basename(updateData.banner));
|
||||
await resizeProfileImage(filePath, 'banner');
|
||||
}
|
||||
|
||||
const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { config } from '../config.js';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { thumbFilename } from './thumbnail.js';
|
||||
|
||||
/**
|
||||
@@ -39,3 +41,34 @@ export function deleteAttachmentFiles(rows: { filename: string }[]): void {
|
||||
deleteUploadFile(row.filename);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the attachment DB record for a given filename, plus its thumbnail.
|
||||
* Used when profile images (avatars, banners, icons) are set or replaced —
|
||||
* the file reference moves to users/spaces tables, making the attachment
|
||||
* record unnecessary. Idempotent (no-op if record doesn't exist).
|
||||
*/
|
||||
export function deleteAttachmentByFilename(filename: string): void {
|
||||
const db = getDb();
|
||||
const safeName = path.basename(filename);
|
||||
|
||||
// Read the record first to get the thumbnail filename before deleting
|
||||
const record = db.select({
|
||||
id: schema.attachments.id,
|
||||
thumbnailFilename: schema.attachments.thumbnailFilename,
|
||||
}).from(schema.attachments)
|
||||
.where(eq(schema.attachments.filename, safeName))
|
||||
.get();
|
||||
if (!record) return;
|
||||
|
||||
// Delete thumbnail from disk (profile images don't need thumbnails)
|
||||
if (record.thumbnailFilename) {
|
||||
const thumbPath = path.join(config.uploadDir, path.basename(record.thumbnailFilename));
|
||||
try { fs.unlinkSync(thumbPath); } catch { /* may not exist */ }
|
||||
}
|
||||
|
||||
// Delete the attachment record
|
||||
db.delete(schema.attachments)
|
||||
.where(eq(schema.attachments.id, record.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -124,7 +124,10 @@ function getReferencedFilenames(): Set<string> {
|
||||
function getUnlinkedAttachments(): { id: string; filename: string; thumbnailFilename: string | null; size: number }[] {
|
||||
const db = getDb();
|
||||
const cutoff = Date.now() - UNLINKED_AGE_MS;
|
||||
// Attachments with no message_id AND no dm_message_id, older than 1 hour
|
||||
const profileReferenced = getProfileReferencedFilenames();
|
||||
|
||||
// Attachments with no message_id AND no dm_message_id, older than 1 hour,
|
||||
// excluding files currently used as profile images (avatars, banners, icons)
|
||||
const rows = db.select({
|
||||
id: schema.attachments.id,
|
||||
filename: schema.attachments.filename,
|
||||
@@ -137,6 +140,7 @@ function getUnlinkedAttachments(): { id: string; filename: string; thumbnailFile
|
||||
|
||||
return rows.filter(r =>
|
||||
r.messageId === null && r.dmMessageId === null && r.createdAt < cutoff
|
||||
&& !profileReferenced.has(path.basename(r.filename))
|
||||
).map(r => ({
|
||||
id: r.id,
|
||||
filename: r.filename,
|
||||
@@ -242,12 +246,19 @@ export function cleanupStorage(dryRun: boolean): CleanupResult {
|
||||
// Phase 2: Clean up stale unlinked attachment records.
|
||||
// Files referenced by user/space profiles (avatars, banners, icons) are
|
||||
// preserved on disk — only the orphaned attachment DB record is removed.
|
||||
// Thumbnails are always deleted since profile images don't need them.
|
||||
for (const att of unlinked) {
|
||||
const fileInUseByProfile = profileReferenced.has(path.basename(att.filename));
|
||||
if (!dryRun) {
|
||||
try {
|
||||
if (!fileInUseByProfile) {
|
||||
deleteUploadFile(att.filename);
|
||||
} else if (att.thumbnailFilename) {
|
||||
// Main file is a profile image — keep it. But delete the thumbnail
|
||||
// since it's only useful for message attachments, and the attachment
|
||||
// record is about to be deleted (which would orphan the thumbnail).
|
||||
const thumbPath = path.join(config.uploadDir, path.basename(att.thumbnailFilename));
|
||||
try { fs.unlinkSync(thumbPath); } catch { /* may not exist */ }
|
||||
}
|
||||
db.delete(schema.attachments)
|
||||
.where(eq(schema.attachments.id, att.id))
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const PROFILE_LIMITS = { avatar: 256, icon: 256, banner: 1280 } as const;
|
||||
|
||||
/**
|
||||
* Resize a profile image (avatar, icon, or banner) to the target max dimension.
|
||||
* Preserves animated GIF frames. No-op if the image is already small enough.
|
||||
* Writes to a temp file then atomically renames to avoid corruption.
|
||||
*/
|
||||
export async function resizeProfileImage(
|
||||
filepath: string,
|
||||
type: 'avatar' | 'icon' | 'banner',
|
||||
): Promise<void> {
|
||||
const maxDim = PROFILE_LIMITS[type];
|
||||
try {
|
||||
const image = sharp(filepath, { animated: true });
|
||||
const metadata = await image.metadata();
|
||||
if (!metadata.width || metadata.width <= maxDim) return;
|
||||
|
||||
const tmpPath = filepath + '.tmp';
|
||||
await sharp(filepath, { animated: true })
|
||||
.resize({ width: maxDim, withoutEnlargement: true })
|
||||
.toFile(tmpPath);
|
||||
fs.renameSync(tmpPath, filepath);
|
||||
} catch (err) {
|
||||
// Non-fatal — the original file is still intact
|
||||
console.error(`Profile image resize failed (non-fatal) for ${path.basename(filepath)}:`, err);
|
||||
// Clean up temp file if it was partially written
|
||||
try { fs.unlinkSync(filepath + '.tmp'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
const RESIZABLE_MIMETYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Self-hosted chat platform" />
|
||||
<meta name="theme-color" content="#0b0b10" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Backspace" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { LoginPage } from './components/auth/LoginPage';
|
||||
import { RegisterPage } from './components/auth/RegisterPage';
|
||||
import { AppLayout } from './components/layout/AppLayout';
|
||||
import { JoinPage } from './components/JoinPage';
|
||||
import { SwUpdatePrompt } from './components/ui/SwUpdatePrompt';
|
||||
import { SwAutoUpdate } from './components/ui/SwUpdatePrompt';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
@@ -29,7 +29,7 @@ function AuthRedirect({ children }: { children: React.ReactNode }) {
|
||||
export function App() {
|
||||
return (
|
||||
<>
|
||||
<SwUpdatePrompt />
|
||||
<SwAutoUpdate />
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
|
||||
@@ -473,6 +473,7 @@ export function RegisterPage() {
|
||||
title="Crop Avatar"
|
||||
aspectRatio={1}
|
||||
cropShape="round"
|
||||
maxOutputDimension={256}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -300,6 +300,7 @@ export function CreateSpaceModal() {
|
||||
title="Crop Space Icon"
|
||||
cropShape="round"
|
||||
aspectRatio={1}
|
||||
maxOutputDimension={256}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -66,7 +66,7 @@ export function GeneralPanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<form className="space-y-5" onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
Configure your Backspace instance. These settings affect all users.
|
||||
</div>
|
||||
@@ -178,6 +178,6 @@ export function GeneralPanel() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -741,7 +741,7 @@ export function AccountPanel() {
|
||||
title="Crop Avatar"
|
||||
cropShape="round"
|
||||
aspectRatio={1}
|
||||
maxOutputDimension={512}
|
||||
maxOutputDimension={256}
|
||||
/>
|
||||
<ImageCropModal
|
||||
isOpen={bannerCropSrc !== null}
|
||||
@@ -751,7 +751,7 @@ export function AccountPanel() {
|
||||
title="Crop Banner"
|
||||
cropShape="rect"
|
||||
aspectRatio={3}
|
||||
maxOutputDimension={1920}
|
||||
maxOutputDimension={1280}
|
||||
/>
|
||||
|
||||
<DeleteAccountModal
|
||||
|
||||
@@ -601,7 +601,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
title="Crop Space Icon"
|
||||
cropShape="round"
|
||||
aspectRatio={1}
|
||||
maxOutputDimension={512}
|
||||
maxOutputDimension={256}
|
||||
/>
|
||||
|
||||
<ImageCropModal
|
||||
@@ -612,7 +612,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
title="Crop Space Banner"
|
||||
cropShape="rect"
|
||||
aspectRatio={16 / 9}
|
||||
maxOutputDimension={1920}
|
||||
maxOutputDimension={1280}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function SwUpdatePrompt() {
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW();
|
||||
export function SwAutoUpdate() {
|
||||
useRegisterSW({
|
||||
onRegisteredSW(_swUrl, registration) {
|
||||
if (!registration) return;
|
||||
setInterval(() => {
|
||||
registration.update();
|
||||
}, 60_000);
|
||||
},
|
||||
});
|
||||
|
||||
if (!needRefresh) return null;
|
||||
useEffect(() => {
|
||||
if (!navigator.serviceWorker) return;
|
||||
const onControllerChange = () => window.location.reload();
|
||||
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
|
||||
return () => navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999] glass-pill px-4 py-2.5 flex items-center gap-3 text-sm text-txt-primary shadow-lg">
|
||||
<span>A new version is available</span>
|
||||
<button
|
||||
onClick={() => updateServiceWorker(true)}
|
||||
className="px-3 py-1 rounded-md bg-accent-primary text-white text-xs font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['icons/favicon-32.png', 'icons/favicon-16.png', 'icons/apple-touch-icon.png'],
|
||||
manifest: {
|
||||
name: 'Backspace',
|
||||
@@ -27,6 +27,9 @@ export default defineConfig({
|
||||
workbox: {
|
||||
navigateFallback: '/index.html',
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/ws/, /^\/uploads/],
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
cleanupOutdatedCaches: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user