feat: Add installable Android PWA and native Android Web Share Target support

This commit is contained in:
chrisf committed 2026-10-22 17:46:24 -06:00
1 parent 44d1dec42b
commit 94ae568385
12 files changed
+241 -19

No files matched your search

+19 -10
View File
@@ -103,15 +103,24 @@ export async function POST(req: NextRequest) {
uploadedFiles.push(...files);
}
// Check if Android shared a URL inside the `text` field
if (!url && content && /^https?:\/\/\S+$/i.test(content.trim())) {
url = content.trim();
content = '';
// Check if Android shared a URL inside the `text` or `content` (or `title`)
if (!url && content) {
const match = /(https?:\/\/[^\s]+)/i.exec(content);
if (match) {
url = match[1];
if (content.trim() === url) {
content = '';
}
}
}
if (!url && title && /^https?:\/\/\S+$/i.test(title.trim())) {
url = title.trim();
title = '';
}
const notebookId = notebookName.trim()
? await getOrCreateNotebookByName(notebookName.trim())
: (await ensureDefaultNotebook()).id;
? await getOrCreateNotebookByName(notebookName.trim(), userId)
: (await ensureDefaultNotebook(userId)).id;
// 1. If Brave Extension sent live rendered DOM (`pageHtml`) in `article` mode:
if (pageHtml && (clipMode === 'article' || !clipMode)) {
@@ -185,7 +194,7 @@ export async function POST(req: NextRequest) {
});
if (isShareTarget) {
return NextResponse.redirect(new URL(`/?noteId=${clippedId}`, req.url));
return NextResponse.redirect(new URL(`/?noteId=${clippedId}&shared=1`, req.url), 303);
}
return NextResponse.json(
@@ -290,7 +299,7 @@ export async function POST(req: NextRequest) {
await applyAutoSortToNote(note.id, userId).catch(() => {});
if (isShareTarget) {
return NextResponse.redirect(new URL(`/?noteId=${note.id}`, req.url));
return NextResponse.redirect(new URL(`/?noteId=${note.id}&shared=1`, req.url), 303);
}
return NextResponse.json(
@@ -370,8 +379,8 @@ export async function GET(req: NextRequest) {
await applyAutoSortToNote(noteId, userId).catch(() => {});
if (sp.get('redirect') === '1') {
return NextResponse.redirect(new URL(`/?noteId=${noteId}`, req.url));
if (sp.get('redirect') === '1' || sp.get('source') === 'share-target') {
return NextResponse.redirect(new URL(`/?noteId=${noteId}&shared=1`, req.url), 303);
}
return NextResponse.json(
+32 -3
View File
@@ -1,13 +1,34 @@
import type { Metadata } from 'next';
import type { Metadata, Viewport } from 'next';
import './globals.css';
import PwaRegister from '@/components/PwaRegister';
export const metadata: Metadata = {
title: 'Everclone — Notes, Web Clips & Knowledge Base',
description: 'Self-hosted modern Evernote clone with rich media, ENEX import, and Web/Android hooks',
manifest: '/manifest.json',
icons: {
icon: '/icon.svg',
icon: [
{ url: '/icon.svg', type: 'image/svg+xml' },
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
],
apple: [
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
],
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'Everclone',
},
};
export const viewport: Viewport = {
themeColor: '#00a82d',
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export default function RootLayout({
@@ -17,7 +38,15 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<body>{children}</body>
<head>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
</head>
<body>
<PwaRegister />
{children}
</body>
</html>
);
}
+2
View File
@@ -99,6 +99,8 @@ export default function EvercloneWorkspace() {
const [newUserName, setNewUserName] = useState('');
const [newUserEmail, setNewUserEmail] = useState('');
const [triageModalOpen, setTriageModalOpen] = useState(false);
const [canInstallPwa, setCanInstallPwa] = useState(false);
const [shareToast, setShareToast] = useState<string | null>(null);
const activeFilterCount =
(filterNotebookId ? 1 : 0) +
+61
View File
@@ -0,0 +1,61 @@
'use client';
import { useEffect } from 'react';
declare global {
interface Window {
__everclone_install_prompt?: BeforeInstallPromptEvent | null;
}
}
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{
outcome: 'accepted' | 'dismissed';
platform: string;
}>;
prompt(): Promise<void>;
}
export default function PwaRegister() {
useEffect(() => {
// 1. Register Service Worker for PWA installability and Share Target
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('[Everclone PWA] Service Worker registered (scope):', registration.scope);
})
.catch((error) => {
console.error('[Everclone PWA] Service Worker registration failed:', error);
});
});
}
// 2. Capture the install prompt for Android / Desktop PWA installation
const handleBeforeInstall = (e: Event) => {
e.preventDefault();
const promptEvent = e as BeforeInstallPromptEvent;
window.__everclone_install_prompt = promptEvent;
window.dispatchEvent(new CustomEvent('everclone:installable', { detail: true }));
console.log('[Everclone PWA] captured beforeinstallprompt event');
};
const handleAppInstalled = () => {
window.__everclone_install_prompt = null;
window.dispatchEvent(new CustomEvent('everclone:installable', { detail: false }));
console.log('[Everclone PWA] app was successfully installed');
};
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
window.addEventListener('appinstalled', handleAppInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, []);
return null;
}
+2
View File
@@ -124,6 +124,8 @@ export default function SettingsView({
const [newKeyName, setNewKeyName] = useState('Brave Clipper & Android');
const [generatedKey, setGeneratedKey] = useState<string>('');
const [copied, setCopied] = useState<string>('');
const [canInstall, setCanInstall] = useState<boolean>(false);
const [isStandalone, setIsStandalone] = useState<boolean>(false);
// --- EMAIL-TO-NOTE STATE ---
const [emailCfg, setEmailCfg] = useState({