feat(pwa): adopt thread-ripper pattern with in-app install buttons, floating banner, and GET share_target
This commit is contained in:
1 parent
c739f74055
commit
4d4b8c860d
3 files changed
+148
-86
No files matched your search
+20
-52
@@ -6,86 +6,54 @@
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||
"background_color": "#18181b",
|
||||
"theme_color": "#00a82d",
|
||||
"orientation": "any",
|
||||
"categories": ["productivity", "utilities", "notes"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"action": "/",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [
|
||||
{
|
||||
"name": "files",
|
||||
"accept": [
|
||||
"text/plain",
|
||||
"text/html",
|
||||
"text/markdown",
|
||||
"text/csv",
|
||||
".txt",
|
||||
".md",
|
||||
".html",
|
||||
".csv",
|
||||
"application/pdf",
|
||||
".pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/*",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
"audio/*",
|
||||
".mp3",
|
||||
".m4a",
|
||||
".wav",
|
||||
".ogg",
|
||||
"video/*",
|
||||
".mp4",
|
||||
".webm"
|
||||
]
|
||||
}
|
||||
]
|
||||
"url": "url"
|
||||
}
|
||||
},
|
||||
"shortcuts": [
|
||||
|
||||
+39
-34
@@ -1,18 +1,20 @@
|
||||
// Everclone PWA Service Worker (v1.1.0)
|
||||
const CACHE_NAME = 'everclone-cache-v2';
|
||||
const PRECACHE_URLS = [
|
||||
// Everclone PWA Service Worker (v2.0.0)
|
||||
const CACHE_NAME = 'everclone-cache-v3';
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/icon.svg',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
'/apple-touch-icon.png'
|
||||
'/icon-maskable-192.png',
|
||||
'/icon-maskable-512.png',
|
||||
'/apple-touch-icon.png',
|
||||
'/icon.svg'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(PRECACHE_URLS).catch(() => {});
|
||||
return cache.addAll(STATIC_ASSETS).catch(() => {});
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
@@ -31,44 +33,47 @@ self.addEventListener('activate', (event) => {
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// CRITICAL: Never intercept POST/PUT/DELETE requests!
|
||||
// This ensures Android Web Share Target multipart POSTs stream directly to /api/hooks/note
|
||||
if (event.request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Bypass API endpoints, hooks, and share endpoint to always get fresh data
|
||||
if (url.pathname.startsWith('/api/') || url.pathname === '/share') {
|
||||
// Bypass API endpoints and share endpoint
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/share')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first strategy for navigation and static assets with cache fallback
|
||||
// For page navigations (including share target queries like /?url=... or /?noteId=...)
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => {
|
||||
return caches.match('/');
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stale-while-revalidate for static assets
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((networkResponse) => {
|
||||
if (
|
||||
networkResponse &&
|
||||
networkResponse.status === 200 &&
|
||||
(url.pathname === '/' || url.pathname.startsWith('/_next/static/'))
|
||||
) {
|
||||
const responseClone = networkResponse.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseClone);
|
||||
});
|
||||
}
|
||||
return networkResponse;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(event.request).then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
caches.match(event.request).then((cachedResponse) => {
|
||||
const fetchPromise = fetch(event.request)
|
||||
.then((networkResponse) => {
|
||||
if (
|
||||
networkResponse &&
|
||||
networkResponse.status === 200 &&
|
||||
(url.pathname === '/' || url.pathname.startsWith('/_next/static/'))
|
||||
) {
|
||||
const responseClone = networkResponse.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseClone);
|
||||
});
|
||||
}
|
||||
if (event.request.mode === 'navigate') {
|
||||
return caches.match('/');
|
||||
}
|
||||
return new Response('Network error occurred', { status: 503, statusText: 'Offline' });
|
||||
});
|
||||
})
|
||||
return networkResponse;
|
||||
})
|
||||
.catch(() => cachedResponse);
|
||||
|
||||
return cachedResponse || fetchPromise;
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -118,6 +118,41 @@ export default function EvercloneWorkspace() {
|
||||
(filterOnlyPinned ? 1 : 0) +
|
||||
(filterUpdatedDays > 0 ? 1 : 0);
|
||||
|
||||
|
||||
// PWA In-App Installation Listener
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (window.__everclone_install_prompt) {
|
||||
setCanInstallPwa(true);
|
||||
}
|
||||
const onInstallable = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
setCanInstallPwa(Boolean(detail));
|
||||
};
|
||||
window.addEventListener('everclone:installable', onInstallable);
|
||||
return () => window.removeEventListener('everclone:installable', onInstallable);
|
||||
}, []);
|
||||
|
||||
const handleInstallPwa = async () => {
|
||||
const promptEvent = window.__everclone_install_prompt;
|
||||
if (!promptEvent) {
|
||||
alert("To install Everclone on Android, tap your browser's menu (⋮) and select 'Install app' or 'Add to Home screen'.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await promptEvent.prompt();
|
||||
const choice = await promptEvent.userChoice;
|
||||
if (choice.outcome === 'accepted') {
|
||||
setCanInstallPwa(false);
|
||||
window.__everclone_install_prompt = null;
|
||||
setShareToast('Everclone installed! You can now share directly to Everclone.');
|
||||
setTimeout(() => setShareToast(null), 5000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Install prompt failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setFilterNotebookId('');
|
||||
setFilterTagIds([]);
|
||||
@@ -397,6 +432,48 @@ export default function EvercloneWorkspace() {
|
||||
window.history.replaceState({}, '', '/');
|
||||
return;
|
||||
}
|
||||
|
||||
// Android PWA Web Share Target GET Handler (/?url=... or /?text=... or /?title=...)
|
||||
const sharedUrl = sp.get('url') || '';
|
||||
const sharedText = sp.get('text') || '';
|
||||
const sharedTitle = sp.get('title') || '';
|
||||
if (sharedUrl || sharedText || sharedTitle) {
|
||||
window.history.replaceState({}, '', '/');
|
||||
setShareToast('Saving shared note to Everclone...');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/hooks/note?source=share-target', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: sharedUrl,
|
||||
text: sharedText,
|
||||
title: sharedTitle,
|
||||
tags: ['inbox', 'share'],
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setShareToast('Saved note to Everclone from Share!');
|
||||
setTimeout(() => setShareToast(null), 4000);
|
||||
if (data.noteId) {
|
||||
setSelectedNoteId(data.noteId);
|
||||
setMobilePane('editor');
|
||||
fetchNotesList(data.noteId);
|
||||
}
|
||||
} else {
|
||||
setShareToast('Failed to save shared note');
|
||||
setTimeout(() => setShareToast(null), 4000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error saving shared note:', e);
|
||||
setShareToast('Error saving shared note');
|
||||
setTimeout(() => setShareToast(null), 4000);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
}
|
||||
fetchNotesList();
|
||||
}, [fetchNotesList]);
|
||||
@@ -920,6 +997,18 @@ export default function EvercloneWorkspace() {
|
||||
<Plus className="w-4 h-4 stroke-[2.5]" />
|
||||
{!sidebarCollapsed && <span>New Note</span>}
|
||||
</button>
|
||||
{canInstallPwa && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstallPwa}
|
||||
title="Install Everclone App"
|
||||
className="w-full flex items-center justify-center gap-2 py-2 px-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs rounded-full shadow transition"
|
||||
>
|
||||
<Download className="w-4 h-4 stroke-[2.5]" />
|
||||
{!sidebarCollapsed && <span>Install App</span>}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in new issue
Block a user