Add universal reader features: gestures, keyboard, navigator, offline
- gestures.ts: Touch gesture controls (swipe, pinch, tap, double-tap) - keyboard-shortcuts.ts: Keyboard navigation (arrows, space, page up/down, etc) - navigator-panel.ts: Affinity-style minimap for page navigation - offline-manager.ts: Service worker registration and online status - reading-speed-tracker.ts: Track and sync reading speed to database - Add web manifest.json for PWA support
This commit is contained in:
@@ -0,0 +1,115 @@
|
|||||||
|
// Touch gesture controls for mobile/tablet
|
||||||
|
// Procedural implementation (no OOP)
|
||||||
|
|
||||||
|
interface GestureHandlers {
|
||||||
|
onSwipeLeft: () => void;
|
||||||
|
onSwipeRight: () => void;
|
||||||
|
onSwipeUp: () => void;
|
||||||
|
onSwipeDown: () => void;
|
||||||
|
onPinch: (scale: number) => void;
|
||||||
|
onTap: () => void;
|
||||||
|
onDoubleTap: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GestureState {
|
||||||
|
touchStartX: number;
|
||||||
|
touchStartY: number;
|
||||||
|
touchStartTime: number;
|
||||||
|
lastTapTime: number;
|
||||||
|
initialPinchDistance: number;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGestureControls(
|
||||||
|
container: HTMLElement,
|
||||||
|
handlers: GestureHandlers,
|
||||||
|
): void {
|
||||||
|
let state: GestureState = {
|
||||||
|
touchStartX: 0,
|
||||||
|
touchStartY: 0,
|
||||||
|
touchStartTime: 0,
|
||||||
|
lastTapTime: 0,
|
||||||
|
initialPinchDistance: 0,
|
||||||
|
scale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener(
|
||||||
|
"touchstart",
|
||||||
|
(e) => {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
state.touchStartX = e.touches[0].clientX;
|
||||||
|
state.touchStartY = e.touches[0].clientY;
|
||||||
|
state.touchStartTime = Date.now();
|
||||||
|
} else if (e.touches.length === 2) {
|
||||||
|
state.initialPinchDistance = getPinchDistance(e.touches);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
container.addEventListener(
|
||||||
|
"touchend",
|
||||||
|
(e) => {
|
||||||
|
const deltaX = e.changedTouches[0].clientX - state.touchStartX;
|
||||||
|
const deltaY = e.changedTouches[0].clientY - state.touchStartY;
|
||||||
|
const deltaTime = Date.now() - state.touchStartTime;
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && deltaTime < 300) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - state.lastTapTime < 300) {
|
||||||
|
handlers.onDoubleTap();
|
||||||
|
state.lastTapTime = 0;
|
||||||
|
} else {
|
||||||
|
state.lastTapTime = now;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (state.lastTapTime !== 0) {
|
||||||
|
handlers.onTap();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minSwipeDistance = 50;
|
||||||
|
const maxSwipeTime = 500;
|
||||||
|
|
||||||
|
if (deltaTime > maxSwipeTime) return;
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||||
|
if (deltaX > minSwipeDistance) {
|
||||||
|
handlers.onSwipeRight();
|
||||||
|
} else if (deltaX < -minSwipeDistance) {
|
||||||
|
handlers.onSwipeLeft();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (deltaY > minSwipeDistance) {
|
||||||
|
handlers.onSwipeDown();
|
||||||
|
} else if (deltaY < -minSwipeDistance) {
|
||||||
|
handlers.onSwipeUp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
container.addEventListener(
|
||||||
|
"touchmove",
|
||||||
|
(e) => {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
const currentDistance = getPinchDistance(e.touches);
|
||||||
|
if (state.initialPinchDistance > 0) {
|
||||||
|
const scale = currentDistance / state.initialPinchDistance;
|
||||||
|
state.scale = scale;
|
||||||
|
handlers.onPinch(scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPinchDistance(touches: TouchList): number {
|
||||||
|
const dx = touches[0].clientX - touches[1].clientX;
|
||||||
|
const dy = touches[0].clientY - touches[1].clientY;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Extended keyboard shortcuts for all readers
|
||||||
|
// Procedural implementation (no OOP)
|
||||||
|
|
||||||
|
interface KeyboardShortcutHandler {
|
||||||
|
onNextPage: () => void;
|
||||||
|
onPreviousPage: () => void;
|
||||||
|
onNextChapter: () => void;
|
||||||
|
onPreviousChapter: () => void;
|
||||||
|
onGoToPage: (page: number) => void;
|
||||||
|
onToggleBookmark: () => void;
|
||||||
|
onZoomIn: () => void;
|
||||||
|
onZoomOut: () => void;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupKeyboardShortcuts(
|
||||||
|
container: HTMLElement,
|
||||||
|
handlers: KeyboardShortcutHandler,
|
||||||
|
maxPage: number,
|
||||||
|
): void {
|
||||||
|
container.addEventListener("keydown", (e) => {
|
||||||
|
if (
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowRight":
|
||||||
|
case "PageDown":
|
||||||
|
case "l":
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onNextPage();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ArrowLeft":
|
||||||
|
case "PageUp":
|
||||||
|
case "h":
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onPreviousPage();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ArrowUp":
|
||||||
|
case "k":
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onPreviousPage();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ArrowDown":
|
||||||
|
case "j":
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onNextPage();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case " ":
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onNextPage();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Home":
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onGoToPage(1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "End":
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onGoToPage(maxPage);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "b":
|
||||||
|
if (!e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onToggleBookmark();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "+":
|
||||||
|
case "=":
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onZoomIn();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "-":
|
||||||
|
case "_":
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onZoomOut();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "0":
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onZoomIn();
|
||||||
|
handlers.onZoomIn();
|
||||||
|
handlers.onZoomIn();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "f":
|
||||||
|
if (!e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onToggleFullscreen();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onClose();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (e.key >= "1" && e.key <= "9") {
|
||||||
|
const targetPage = Math.floor((parseInt(e.key) / 10) * maxPage);
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onGoToPage(targetPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showShortcutHelp(): void {
|
||||||
|
const help = document.createElement("div");
|
||||||
|
help.className =
|
||||||
|
"keyboard-shortcut-help fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50";
|
||||||
|
help.innerHTML = `
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 max-w-md">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Keyboard Shortcuts</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">→</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">Space</kbd> Next page</div>
|
||||||
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">←</kbd> Previous page</div>
|
||||||
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">Home</kbd> First page</div>
|
||||||
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">End</kbd> Last page</div>
|
||||||
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">+</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">-</kbd> Zoom</div>
|
||||||
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">B</kbd> Toggle bookmark</div>
|
||||||
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">F</kbd> Fullscreen</div>
|
||||||
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">1-9</kbd> Jump to %</div>
|
||||||
|
</div>
|
||||||
|
<button class="mt-4 px-4 py-2 bg-blue-600 rounded" onclick="this.closest('.keyboard-shortcut-help').remove()">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(help);
|
||||||
|
help.addEventListener("click", (e) => {
|
||||||
|
if (e.target === help) help.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// Navigator panel - shows full page with draggable viewport box
|
||||||
|
// Affinity/Photoshop-style mini-map for page navigation
|
||||||
|
// Procedural implementation (no OOP)
|
||||||
|
|
||||||
|
import { loadSettings } from "./settings-manager";
|
||||||
|
|
||||||
|
interface NavigatorState {
|
||||||
|
panelId: string;
|
||||||
|
container: HTMLElement;
|
||||||
|
viewport: HTMLElement;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
scale: number;
|
||||||
|
contentImage: HTMLImageElement | null;
|
||||||
|
isDragging: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeNavigator(containerSelector: string): NavigatorState {
|
||||||
|
const container = document.querySelector(containerSelector) as HTMLElement;
|
||||||
|
if (!container) throw new Error("Navigator container not found");
|
||||||
|
|
||||||
|
const viewport = document.createElement("div");
|
||||||
|
viewport.className = "navigator-viewport-box";
|
||||||
|
viewport.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid var(--accent-color, #3b82f6);
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
cursor: move;
|
||||||
|
z-index: 10;
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(viewport);
|
||||||
|
|
||||||
|
const state: NavigatorState = {
|
||||||
|
panelId: "navigator",
|
||||||
|
container,
|
||||||
|
viewport,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
scale: 0.1,
|
||||||
|
contentImage: null,
|
||||||
|
isDragging: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
setupNavigatorDragHandler(state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup draggable viewport box within navigator
|
||||||
|
function setupNavigatorDragHandler(state: NavigatorState): void {
|
||||||
|
state.viewport.addEventListener("mousedown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
state.isDragging = true;
|
||||||
|
state.viewport.style.cursor = "grabbing";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", (e) => {
|
||||||
|
if (!state.isDragging || !state.contentImage) return;
|
||||||
|
|
||||||
|
const containerRect = state.container.getBoundingClientRect();
|
||||||
|
const imgRect = state.contentImage.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Calculate position relative to scaled image
|
||||||
|
const relX = (e.clientX - imgRect.left) / imgRect.width;
|
||||||
|
const relY = (e.clientY - imgRect.top) / imgRect.height;
|
||||||
|
|
||||||
|
// Update main viewer's position (call external handler)
|
||||||
|
const mainViewer = document.getElementById("reader-content");
|
||||||
|
if (mainViewer) {
|
||||||
|
mainViewer.dataset.panX = relX.toString();
|
||||||
|
mainViewer.dataset.panY = relY.toString();
|
||||||
|
// Dispatch event for main viewer to handle
|
||||||
|
mainViewer.dispatchEvent(
|
||||||
|
new CustomEvent("navigator-pan", {
|
||||||
|
detail: { x: relX, y: relY },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mouseup", () => {
|
||||||
|
state.isDragging = false;
|
||||||
|
state.viewport.style.cursor = "move";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update navigator with current page image
|
||||||
|
async function updateNavigatorContent(
|
||||||
|
state: NavigatorState,
|
||||||
|
pageNumber: number,
|
||||||
|
): Promise<void> {
|
||||||
|
state.currentPage = pageNumber;
|
||||||
|
|
||||||
|
// Get current page image (from PDF viewer, comic reader, or manga reader)
|
||||||
|
const contentArea = document.getElementById("reader-content");
|
||||||
|
const img = contentArea?.querySelector("img, canvas") as
|
||||||
|
| HTMLImageElement
|
||||||
|
| HTMLCanvasElement
|
||||||
|
| null;
|
||||||
|
|
||||||
|
if (!img) return;
|
||||||
|
|
||||||
|
// Create thumbnail version for navigator
|
||||||
|
const thumb = document.createElement("img");
|
||||||
|
thumb.src = img.src || (img as HTMLCanvasElement).toDataURL();
|
||||||
|
thumb.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Clear and populate container
|
||||||
|
state.container.innerHTML = "";
|
||||||
|
state.container.appendChild(thumb);
|
||||||
|
state.contentImage = thumb;
|
||||||
|
|
||||||
|
// Recreate viewport box
|
||||||
|
const viewport = document.createElement("div");
|
||||||
|
viewport.className = "navigator-viewport-box";
|
||||||
|
viewport.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid var(--accent-color, #3b82f6);
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
cursor: move;
|
||||||
|
z-index: 10;
|
||||||
|
width: ${100 / state.scale}%;
|
||||||
|
height: ${100 / state.scale}%;
|
||||||
|
`;
|
||||||
|
state.container.appendChild(viewport);
|
||||||
|
state.viewport = viewport;
|
||||||
|
|
||||||
|
// Re-attach drag handler
|
||||||
|
setupNavigatorDragHandler(state);
|
||||||
|
|
||||||
|
// Calculate viewport size relative to container
|
||||||
|
const containerRect = state.container.getBoundingClientRect();
|
||||||
|
const viewportWidth = (containerRect.width / img.width) * 100;
|
||||||
|
const viewportHeight = (containerRect.height / img.height) * 100;
|
||||||
|
|
||||||
|
viewport.style.width = `${viewportWidth}%`;
|
||||||
|
viewport.style.height = `${viewportHeight}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle window resize
|
||||||
|
function handleNavigatorResize(state: NavigatorState): void {
|
||||||
|
if (state.contentImage) {
|
||||||
|
updateNavigatorContent(state, state.currentPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { initializeNavigator, updateNavigatorContent, handleNavigatorResize };
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Offline manager for PWA functionality
|
||||||
|
|
||||||
|
export function registerServiceWorker(): void {
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register("/static/service-worker.js")
|
||||||
|
.then((registration) => {
|
||||||
|
console.log("Service worker registered:", registration);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Service worker registration failed:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkOnlineStatus(): boolean {
|
||||||
|
if (typeof navigator !== "undefined" && navigator.onLine) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for online/offline events
|
||||||
|
window.addEventListener("online", () => {
|
||||||
|
showToast("Back online", "success");
|
||||||
|
// Sync any pending changes
|
||||||
|
syncPendingChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("offline", () => {
|
||||||
|
showToast("You are offline. Some features may be limited.", "warning");
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Track reading speed and update database
|
||||||
|
|
||||||
|
// Reading speed tracker
|
||||||
|
// Procedural implementation (no OOP)
|
||||||
|
|
||||||
|
interface ReadingSpeedTrackerState {
|
||||||
|
startTime: number | null;
|
||||||
|
pagesRead: number;
|
||||||
|
wordsRead: number;
|
||||||
|
lastSync: number;
|
||||||
|
mediaItemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReadingSpeedTracker(
|
||||||
|
mediaItemId: string,
|
||||||
|
): ReadingSpeedTrackerState {
|
||||||
|
return {
|
||||||
|
startTime: null,
|
||||||
|
pagesRead: 0,
|
||||||
|
wordsRead: 0,
|
||||||
|
lastSync: Date.now(),
|
||||||
|
mediaItemId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReadingSession(
|
||||||
|
state: ReadingSpeedTrackerState,
|
||||||
|
): ReadingSpeedTrackerState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
startTime: Date.now(),
|
||||||
|
pagesRead: 0,
|
||||||
|
wordsRead: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordPageTurn(
|
||||||
|
state: ReadingSpeedTrackerState,
|
||||||
|
): ReadingSpeedTrackerState {
|
||||||
|
if (!state.startTime) return state;
|
||||||
|
|
||||||
|
const newPagesRead = state.pagesRead + 1;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (newPagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) {
|
||||||
|
syncReadingSpeed({ ...state, pagesRead: newPagesRead });
|
||||||
|
return { ...state, pagesRead: newPagesRead, lastSync: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...state, pagesRead: newPagesRead };
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordWordsRead(
|
||||||
|
state: ReadingSpeedTrackerState,
|
||||||
|
wordCount: number,
|
||||||
|
): ReadingSpeedTrackerState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
wordsRead: state.wordsRead + wordCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncReadingSpeed(
|
||||||
|
state: ReadingSpeedTrackerState,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!state.startTime) return;
|
||||||
|
|
||||||
|
const minutesElapsed = (Date.now() - state.startTime) / (1000 * 60);
|
||||||
|
const pagesPerMinute = state.pagesRead / minutesElapsed;
|
||||||
|
const wordsPerMinute = state.wordsRead / minutesElapsed;
|
||||||
|
|
||||||
|
await apiPut(`/readers/${state.mediaItemId}/reading-speed`, {
|
||||||
|
pages_per_minute: pagesPerMinute,
|
||||||
|
words_per_minute: wordsPerMinute,
|
||||||
|
pages_read: state.pagesRead,
|
||||||
|
total_reading_minutes: minutesElapsed,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "Bookhoard Reader",
|
||||||
|
"short_name": "Reader",
|
||||||
|
"description": "Offline-capable ebook and comic reader",
|
||||||
|
"start_url": "/dashboard",
|
||||||
|
"display": "fullscreen",
|
||||||
|
"background_color": "#1a1b26",
|
||||||
|
"theme_color": "#1a1b26",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"offline_enabled": true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user