mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Added mobile PWA support
This commit is contained in:
@@ -1075,6 +1075,24 @@ async def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@app.get("/manifest.json")
|
||||
async def serve_manifest():
|
||||
"""Serve PWA manifest."""
|
||||
manifest_file = app_settings.static_dir / "manifest.json"
|
||||
if manifest_file.exists():
|
||||
return FileResponse(manifest_file, media_type="application/manifest+json")
|
||||
return {"error": "Manifest not found"}
|
||||
|
||||
|
||||
@app.get("/sw.js")
|
||||
async def serve_service_worker():
|
||||
"""Serve service worker."""
|
||||
sw_file = app_settings.static_dir / "sw.js"
|
||||
if sw_file.exists():
|
||||
return FileResponse(sw_file, media_type="application/javascript")
|
||||
return {"error": "Service worker not found"}
|
||||
|
||||
|
||||
# Catch-all route for React Router (must be last)
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
|
||||
+32
-1
@@ -2,14 +2,45 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Bambuddy</title>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="description" content="Monitor and manage your Bambu Lab 3D printers" />
|
||||
<meta name="theme-color" content="#00ae42" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-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="Bambuddy" />
|
||||
|
||||
<!-- Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
<!-- Service Worker Registration -->
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('SW registered:', registration.scope);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('SW registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "Bambuddy",
|
||||
"short_name": "Bambuddy",
|
||||
"description": "Monitor and manage your Bambu Lab 3D printers",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#00ae42",
|
||||
"orientation": "any",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/img/favicon-16x16.png",
|
||||
"sizes": "16x16",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/img/favicon-32x32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/img/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/img/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/img/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["utilities", "productivity"],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Printers",
|
||||
"short_name": "Printers",
|
||||
"description": "View your printers",
|
||||
"url": "/",
|
||||
"icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Archives",
|
||||
"short_name": "Archives",
|
||||
"description": "View print archives",
|
||||
"url": "/archives",
|
||||
"icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Queue",
|
||||
"short_name": "Queue",
|
||||
"description": "View print queue",
|
||||
"url": "/queue",
|
||||
"icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Bambuddy Service Worker
|
||||
const CACHE_NAME = 'bambuddy-v1';
|
||||
const STATIC_CACHE = 'bambuddy-static-v1';
|
||||
|
||||
// Static assets to cache on install
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/img/favicon.png',
|
||||
'/img/favicon-16x16.png',
|
||||
'/img/favicon-32x32.png',
|
||||
'/img/android-chrome-192x192.png',
|
||||
'/img/android-chrome-512x512.png',
|
||||
'/img/apple-touch-icon.png',
|
||||
'/img/bambuddy_logo_dark.png',
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing service worker...');
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE).then((cache) => {
|
||||
console.log('[SW] Caching static assets');
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
);
|
||||
// Activate immediately
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating service worker...');
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE)
|
||||
.map((name) => {
|
||||
console.log('[SW] Deleting old cache:', name);
|
||||
return caches.delete(name);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
// Take control immediately
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch event - network-first for API, cache-first for static
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip WebSocket connections
|
||||
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
||||
return;
|
||||
}
|
||||
|
||||
// API requests - network first, no cache (real-time data is critical)
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => {
|
||||
// Return offline response for API failures
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'offline', message: 'You are currently offline' }),
|
||||
{
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets - cache first, then network
|
||||
if (
|
||||
url.pathname.startsWith('/img/') ||
|
||||
url.pathname.startsWith('/icons/') ||
|
||||
url.pathname.endsWith('.png') ||
|
||||
url.pathname.endsWith('.jpg') ||
|
||||
url.pathname.endsWith('.svg') ||
|
||||
url.pathname.endsWith('.ico')
|
||||
) {
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
return fetch(request).then((response) => {
|
||||
// Cache successful responses
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(STATIC_CACHE).then((cache) => {
|
||||
cache.put(request, clone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// JS/CSS assets - stale-while-revalidate
|
||||
if (
|
||||
url.pathname.startsWith('/assets/') ||
|
||||
url.pathname.endsWith('.js') ||
|
||||
url.pathname.endsWith('.css')
|
||||
) {
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
const fetchPromise = fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(request, clone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
});
|
||||
return cached || fetchPromise;
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML pages - network first, fall back to cache
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(request, clone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(request).then((cached) => {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
// Return cached index for SPA navigation
|
||||
return caches.match('/');
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Handle push notifications (for future use)
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
const data = event.data.json();
|
||||
const options = {
|
||||
body: data.body || 'New notification from Bambuddy',
|
||||
icon: '/img/android-chrome-192x192.png',
|
||||
badge: '/img/favicon-32x32.png',
|
||||
vibrate: [100, 50, 100],
|
||||
data: {
|
||||
url: data.url || '/',
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Bambuddy', options)
|
||||
);
|
||||
});
|
||||
|
||||
// Handle notification clicks
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const url = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||
// Check if there's already a window open
|
||||
for (const client of windowClients) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
client.navigate(url);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Open a new window if none exists
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(url);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+34
-3
@@ -2,15 +2,46 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Bambuddy</title>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="description" content="Monitor and manage your Bambu Lab 3D printers" />
|
||||
<meta name="theme-color" content="#00ae42" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-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="Bambuddy" />
|
||||
|
||||
<!-- Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
|
||||
<script type="module" crossorigin src="/assets/index-DAltyj1x.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BYZOEJWU.css">
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BN5iZvNL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dm9m4fYz.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- Service Worker Registration -->
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('SW registered:', registration.scope);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('SW registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "Bambuddy",
|
||||
"short_name": "Bambuddy",
|
||||
"description": "Monitor and manage your Bambu Lab 3D printers",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#00ae42",
|
||||
"orientation": "any",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/img/favicon-16x16.png",
|
||||
"sizes": "16x16",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/img/favicon-32x32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/img/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/img/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/img/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["utilities", "productivity"],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Printers",
|
||||
"short_name": "Printers",
|
||||
"description": "View your printers",
|
||||
"url": "/",
|
||||
"icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Archives",
|
||||
"short_name": "Archives",
|
||||
"description": "View print archives",
|
||||
"url": "/archives",
|
||||
"icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Queue",
|
||||
"short_name": "Queue",
|
||||
"description": "View print queue",
|
||||
"url": "/queue",
|
||||
"icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
// Bambuddy Service Worker
|
||||
const CACHE_NAME = 'bambuddy-v1';
|
||||
const STATIC_CACHE = 'bambuddy-static-v1';
|
||||
|
||||
// Static assets to cache on install
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/img/favicon.png',
|
||||
'/img/favicon-16x16.png',
|
||||
'/img/favicon-32x32.png',
|
||||
'/img/android-chrome-192x192.png',
|
||||
'/img/android-chrome-512x512.png',
|
||||
'/img/apple-touch-icon.png',
|
||||
'/img/bambuddy_logo_dark.png',
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing service worker...');
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE).then((cache) => {
|
||||
console.log('[SW] Caching static assets');
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
);
|
||||
// Activate immediately
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating service worker...');
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE)
|
||||
.map((name) => {
|
||||
console.log('[SW] Deleting old cache:', name);
|
||||
return caches.delete(name);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
// Take control immediately
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch event - network-first for API, cache-first for static
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip WebSocket connections
|
||||
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
||||
return;
|
||||
}
|
||||
|
||||
// API requests - network first, no cache (real-time data is critical)
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => {
|
||||
// Return offline response for API failures
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'offline', message: 'You are currently offline' }),
|
||||
{
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets - cache first, then network
|
||||
if (
|
||||
url.pathname.startsWith('/img/') ||
|
||||
url.pathname.startsWith('/icons/') ||
|
||||
url.pathname.endsWith('.png') ||
|
||||
url.pathname.endsWith('.jpg') ||
|
||||
url.pathname.endsWith('.svg') ||
|
||||
url.pathname.endsWith('.ico')
|
||||
) {
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
return fetch(request).then((response) => {
|
||||
// Cache successful responses
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(STATIC_CACHE).then((cache) => {
|
||||
cache.put(request, clone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// JS/CSS assets - stale-while-revalidate
|
||||
if (
|
||||
url.pathname.startsWith('/assets/') ||
|
||||
url.pathname.endsWith('.js') ||
|
||||
url.pathname.endsWith('.css')
|
||||
) {
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
const fetchPromise = fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(request, clone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
});
|
||||
return cached || fetchPromise;
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML pages - network first, fall back to cache
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(request, clone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(request).then((cached) => {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
// Return cached index for SPA navigation
|
||||
return caches.match('/');
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Handle push notifications (for future use)
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
const data = event.data.json();
|
||||
const options = {
|
||||
body: data.body || 'New notification from Bambuddy',
|
||||
icon: '/img/android-chrome-192x192.png',
|
||||
badge: '/img/favicon-32x32.png',
|
||||
vibrate: [100, 50, 100],
|
||||
data: {
|
||||
url: data.url || '/',
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Bambuddy', options)
|
||||
);
|
||||
});
|
||||
|
||||
// Handle notification clicks
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const url = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||
// Check if there's already a window open
|
||||
for (const client of windowClients) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
client.navigate(url);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Open a new window if none exists
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(url);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user