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": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
|
||||||
"background_color": "#18181b",
|
"background_color": "#18181b",
|
||||||
"theme_color": "#00a82d",
|
"theme_color": "#00a82d",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"categories": ["productivity", "utilities", "notes"],
|
"categories": ["productivity", "utilities", "notes"],
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
|
||||||
"src": "/icon.svg",
|
|
||||||
"sizes": "any",
|
|
||||||
"type": "image/svg+xml",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"src": "/icon-192.png",
|
"src": "/icon-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"src": "/icon-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"src": "/icon-maskable-192.png",
|
"src": "/icon-maskable-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"src": "/icon-maskable-512.png",
|
"src": "/icon-maskable-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/apple-touch-icon.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"share_target": {
|
"share_target": {
|
||||||
"action": "/share",
|
"action": "/",
|
||||||
"method": "POST",
|
"method": "GET",
|
||||||
"enctype": "multipart/form-data",
|
"enctype": "application/x-www-form-urlencoded",
|
||||||
"params": {
|
"params": {
|
||||||
"title": "title",
|
"title": "title",
|
||||||
"text": "text",
|
"text": "text",
|
||||||
"url": "url",
|
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"shortcuts": [
|
"shortcuts": [
|
||||||
|
|||||||
+39
-34
@@ -1,18 +1,20 @@
|
|||||||
// Everclone PWA Service Worker (v1.1.0)
|
// Everclone PWA Service Worker (v2.0.0)
|
||||||
const CACHE_NAME = 'everclone-cache-v2';
|
const CACHE_NAME = 'everclone-cache-v3';
|
||||||
const PRECACHE_URLS = [
|
const STATIC_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/manifest.json',
|
'/manifest.json',
|
||||||
'/icon.svg',
|
|
||||||
'/icon-192.png',
|
'/icon-192.png',
|
||||||
'/icon-512.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) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
return cache.addAll(PRECACHE_URLS).catch(() => {});
|
return cache.addAll(STATIC_ASSETS).catch(() => {});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
@@ -31,44 +33,47 @@ self.addEventListener('activate', (event) => {
|
|||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
// CRITICAL: Never intercept POST/PUT/DELETE requests!
|
// 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') {
|
if (event.request.method !== 'GET') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
// Bypass API endpoints, hooks, and share endpoint to always get fresh data
|
// Bypass API endpoints and share endpoint
|
||||||
if (url.pathname.startsWith('/api/') || url.pathname === '/share') {
|
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/share')) {
|
||||||
return;
|
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(
|
event.respondWith(
|
||||||
fetch(event.request)
|
caches.match(event.request).then((cachedResponse) => {
|
||||||
.then((networkResponse) => {
|
const fetchPromise = fetch(event.request)
|
||||||
if (
|
.then((networkResponse) => {
|
||||||
networkResponse &&
|
if (
|
||||||
networkResponse.status === 200 &&
|
networkResponse &&
|
||||||
(url.pathname === '/' || url.pathname.startsWith('/_next/static/'))
|
networkResponse.status === 200 &&
|
||||||
) {
|
(url.pathname === '/' || url.pathname.startsWith('/_next/static/'))
|
||||||
const responseClone = networkResponse.clone();
|
) {
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
const responseClone = networkResponse.clone();
|
||||||
cache.put(event.request, responseClone);
|
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;
|
|
||||||
}
|
}
|
||||||
if (event.request.mode === 'navigate') {
|
return networkResponse;
|
||||||
return caches.match('/');
|
})
|
||||||
}
|
.catch(() => cachedResponse);
|
||||||
return new Response('Network error occurred', { status: 503, statusText: 'Offline' });
|
|
||||||
});
|
return cachedResponse || fetchPromise;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -118,6 +118,41 @@ export default function EvercloneWorkspace() {
|
|||||||
(filterOnlyPinned ? 1 : 0) +
|
(filterOnlyPinned ? 1 : 0) +
|
||||||
(filterUpdatedDays > 0 ? 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 = () => {
|
const clearAllFilters = () => {
|
||||||
setFilterNotebookId('');
|
setFilterNotebookId('');
|
||||||
setFilterTagIds([]);
|
setFilterTagIds([]);
|
||||||
@@ -397,6 +432,48 @@ export default function EvercloneWorkspace() {
|
|||||||
window.history.replaceState({}, '', '/');
|
window.history.replaceState({}, '', '/');
|
||||||
return;
|
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();
|
||||||
}, [fetchNotesList]);
|
}, [fetchNotesList]);
|
||||||
@@ -920,6 +997,18 @@ export default function EvercloneWorkspace() {
|
|||||||
<Plus className="w-4 h-4 stroke-[2.5]" />
|
<Plus className="w-4 h-4 stroke-[2.5]" />
|
||||||
{!sidebarCollapsed && <span>New Note</span>}
|
{!sidebarCollapsed && <span>New Note</span>}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in new issue
Block a user