feat: installable PWA with mobile share-target capture
- Corrected manifest (Jobbjakt branding, matching green theme, maskable icons, description/categories/scope/id). - share_target (GET) maps a shared url/link into the same /?add= capture flow the bookmarklet uses, so mobile 'Share -> Jobbjakt' pre-fills Add Job. - resolveCaptureUrl helper (tested) extracts the link from add or from a link embedded in shared text; App uses it and strips the params. - Deliberately no offline service worker: the app deploys frequently and an aggressive cache would risk stale builds (documented in README). - 4 unit tests; build compiles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,8 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re
|
||||
- History/event trail per application (created, status changes, follow-up set, delete/restore)
|
||||
- Export jobs to JSON/CSV + daily scheduled JSON export
|
||||
- Optional “job import” preview from supported job sites (plugins) + optional translation to English
|
||||
- Quick-capture bookmarklet (Settings): opens `/?add=<page url>` to pre-fill Add Job from any posting
|
||||
- Quick-capture bookmarklet (Settings) + installable PWA with a mobile share-target: both open `/?add=<page url>` to pre-fill Add Job from any posting
|
||||
- Note: no offline service-worker cache is bundled by design (the app is deployed frequently; an aggressive cache would risk serving stale builds). The manifest provides installability and share-to-capture without it.
|
||||
- Optional local AI service for short/full descriptions
|
||||
- Optional Google sign-in (Google ID tokens) to protect the API
|
||||
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
{
|
||||
"short_name": "JobTrack",
|
||||
"name": "JobTrack — Job Application Tracker",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#0b1224",
|
||||
"background_color": "#0b1224"
|
||||
"short_name": "Jobbjakt",
|
||||
"name": "Jobbjakt — Job Application Tracker",
|
||||
"description": "Track and manage your job applications, tailor CVs, and stay on top of follow-ups.",
|
||||
"id": "/",
|
||||
"scope": "/",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"categories": ["productivity", "business"],
|
||||
"theme_color": "#15803d",
|
||||
"background_color": "#0b1224",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/",
|
||||
"method": "GET",
|
||||
"params": {
|
||||
"url": "add",
|
||||
"text": "addtext"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import ForgotPasswordPage from "./pages/ForgotPasswordPage";
|
||||
import ResetPasswordPage from "./pages/ResetPasswordPage";
|
||||
import RouteErrorPage from "./pages/RouteErrorPage";
|
||||
import { api } from "./api";
|
||||
import { resolveCaptureUrl } from "./captureUrl";
|
||||
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
||||
import AppShell, { NavItem } from "./layout/AppShell";
|
||||
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
|
||||
@@ -126,14 +127,16 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
|
||||
}, []);
|
||||
|
||||
// Quick-capture bookmarklet target: /?add=<encoded job url> opens Add Job pre-filled.
|
||||
// Quick-capture target: bookmarklet (/?add=<url>) or PWA share (url in `add`, or a link
|
||||
// embedded in shared `addtext`). Opens Add Job pre-filled and strips the params.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const add = params.get("add");
|
||||
if (!add) return;
|
||||
setCaptureUrl(add);
|
||||
const url = resolveCaptureUrl(location.search);
|
||||
if (!url) return;
|
||||
setCaptureUrl(url);
|
||||
setAddOpen(true);
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("add");
|
||||
params.delete("addtext");
|
||||
navigate({ pathname: location.pathname, search: params.toString() }, { replace: true });
|
||||
}, [location.search, location.pathname, navigate]);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { resolveCaptureUrl } from './captureUrl';
|
||||
|
||||
describe('resolveCaptureUrl', () => {
|
||||
test('reads the bookmarklet add param', () => {
|
||||
expect(resolveCaptureUrl('?add=https%3A%2F%2Fexample.com%2Fjob')).toBe('https://example.com/job');
|
||||
});
|
||||
|
||||
test('extracts a url embedded in shared text', () => {
|
||||
expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('Cool role here https://example.com/job/42 apply now')))
|
||||
.toBe('https://example.com/job/42');
|
||||
});
|
||||
|
||||
test('prefers add over addtext', () => {
|
||||
expect(resolveCaptureUrl('?add=https%3A%2F%2Fa.com&addtext=' + encodeURIComponent('https://b.com')))
|
||||
.toBe('https://a.com');
|
||||
});
|
||||
|
||||
test('returns null when there is no url', () => {
|
||||
expect(resolveCaptureUrl('')).toBeNull();
|
||||
expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('just some text, no link'))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
// Resolves the quick-capture URL from query params produced by the bookmarklet (`add`)
|
||||
// or the PWA share-target (a link in `add`, or embedded in shared `addtext`).
|
||||
export function resolveCaptureUrl(search: string): string | null {
|
||||
const params = new URLSearchParams(search);
|
||||
const add = params.get("add");
|
||||
if (add) return add;
|
||||
const addText = params.get("addtext");
|
||||
if (addText) return addText.match(/https?:\/\/\S+/)?.[0] ?? null;
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user