From fb11469a4874c1e3bf4eec659fac2ca908e10c6f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 04:12:36 +0200 Subject: [PATCH] feat: quick-capture bookmarklet One-click job capture from any posting, reusing the existing jobimport/preview parser. - AddJobModal accepts initialUrl and auto-imports once on open - App reads a /?add= param, opens Add Job pre-filled, and strips the param from the address bar - QuickCaptureCard in Settings offers a draggable bookmarklet (href set via ref since React blocks javascript: URLs) plus copyable code - EN/NB translations; README feature note - 2 frontend tests; full suite green (22 suites / 50 tests) Co-Authored-By: Claude Fable 5 --- README.md | 1 + job-tracker-ui/src/App.tsx | 14 +++- job-tracker-ui/src/components/AddJobModal.tsx | 27 +++++-- .../src/components/QuickCaptureCard.tsx | 69 ++++++++++++++++++ .../src/components/SettingsView.tsx | 3 + job-tracker-ui/src/i18n/translations.ts | 10 +++ job-tracker-ui/src/quick-capture.test.tsx | 70 +++++++++++++++++++ 7 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 job-tracker-ui/src/components/QuickCaptureCard.tsx create mode 100644 job-tracker-ui/src/quick-capture.test.tsx diff --git a/README.md b/README.md index 55cd4aa..23c5865 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ 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=` to pre-fill Add Job from any posting - Optional local AI service for short/full descriptions - Optional Google sign-in (Google ID tokens) to protect the API diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 5f5cd27..3f4ccee 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -109,6 +109,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo const compactHeaderActions = useMediaQuery("(max-width:767.95px)"); const [addOpen, setAddOpen] = useState(false); + const [captureUrl, setCaptureUrl] = useState(undefined); const [quickOpen, setQuickOpen] = useState(false); const [refreshToken, setRefreshToken] = useState(0); const [requireAuth, setRequireAuth] = useState(null); @@ -124,6 +125,17 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo useEffect(() => { api.get("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false)); }, []); + + // Quick-capture bookmarklet target: /?add= opens Add Job pre-filled. + useEffect(() => { + const params = new URLSearchParams(location.search); + const add = params.get("add"); + if (!add) return; + setCaptureUrl(add); + setAddOpen(true); + params.delete("add"); + navigate({ pathname: location.pathname, search: params.toString() }, { replace: true }); + }, [location.search, location.pathname, navigate]); useEffect(() => { let active = true; api.get("/auth/me") @@ -288,7 +300,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo - setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} /> + { setAddOpen(false); setCaptureUrl(undefined); }} onCreated={() => { setRefreshToken((t) => t + 1); }} /> setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} /> diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index c5592bf..68584af 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; @@ -37,6 +37,7 @@ interface Props { open: boolean; onClose: () => void; onCreated: () => void; + initialUrl?: string; } type DuplicateCandidate = { @@ -97,7 +98,7 @@ function normalizeLanguage(value?: string | null) { return raw; } -export default function AddJobModal({ open, onClose, onCreated }: Props) { +export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Props) { const { toast } = useToast(); const { t, language } = useI18n(); @@ -137,6 +138,21 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { setCompanies(cachedCompanies); }, [cachedCompanies]); + // Quick-capture: when opened with a URL (from the bookmarklet), prefill and auto-import once. + const autoImportedUrlRef = useRef(null); + useEffect(() => { + if (!open) { + autoImportedUrlRef.current = null; + return; + } + const url = initialUrl?.trim(); + if (!url || autoImportedUrlRef.current === url) return; + autoImportedUrlRef.current = url; + setJobUrl(url); + void importFromUrl(url); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, initialUrl]); + const resetForm = () => { setCompany(null); setCompanyInput(""); @@ -223,16 +239,17 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { } }; - const importFromUrl = async () => { + const importFromUrl = async (urlArg?: string) => { if (importing) return; - if (!jobUrl.trim()) { + const url = (urlArg ?? jobUrl).trim(); + if (!url) { toast(t("addJobModalPasteUrlFirst"), "warning"); return; } setImporting(true); try { - const res = await api.post("/jobimport/preview", { url: jobUrl.trim() }); + const res = await api.post("/jobimport/preview", { url }); const r = res.data; if (!r?.success) throw new Error(r?.error || t("addJobModalImportFailed")); diff --git a/job-tracker-ui/src/components/QuickCaptureCard.tsx b/job-tracker-ui/src/components/QuickCaptureCard.tsx new file mode 100644 index 0000000..2b57109 --- /dev/null +++ b/job-tracker-ui/src/components/QuickCaptureCard.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useRef } from "react"; + +import { Box, Paper, TextField, Typography } from "@mui/material"; + +import { useI18n } from "../i18n/I18nProvider"; +import { useToast } from "../toast"; + +/** The bookmarklet opens the app at /?add=, which triggers quick-capture. */ +function buildBookmarklet(origin: string): string { + // Kept as a single minified expression; opens a small popup so the user's tab is undisturbed. + return `javascript:void(window.open('${origin}/?add='+encodeURIComponent(location.href),'jobbjakt','width=520,height=720'))`; +} + +export default function QuickCaptureCard() { + const { t } = useI18n(); + const { toast } = useToast(); + const linkRef = useRef(null); + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const bookmarklet = buildBookmarklet(origin); + + // React refuses to render javascript: hrefs, so set it directly on the DOM node. + useEffect(() => { + if (linkRef.current) linkRef.current.setAttribute("href", bookmarklet); + }, [bookmarklet]); + + return ( + + {t("settingsQuickCaptureTitle")} + {t("settingsQuickCaptureSubtitle")} + + + { + // Clicking (vs dragging) shouldn't navigate; the value is meant to be dragged to the bar. + e.preventDefault(); + toast(t("settingsQuickCaptureDragHint"), "info"); + }} + sx={{ + display: "inline-block", + px: 2, + py: 1, + borderRadius: 2, + border: "1px solid", + borderColor: "primary.main", + color: "primary.main", + fontWeight: 800, + textDecoration: "none", + cursor: "grab", + userSelect: "none", + }} + > + {t("settingsQuickCaptureButton")} + + {t("settingsQuickCaptureDragHint")} + + + e.target.select()} + /> + + ); +} diff --git a/job-tracker-ui/src/components/SettingsView.tsx b/job-tracker-ui/src/components/SettingsView.tsx index 30ee4d9..b77c628 100644 --- a/job-tracker-ui/src/components/SettingsView.tsx +++ b/job-tracker-ui/src/components/SettingsView.tsx @@ -24,6 +24,7 @@ import ImportExportJobs from "./ImportExportJobs"; import GoogleAuthCard from "./GoogleAuthCard"; import RulesSettingsCard from "./RulesSettingsCard"; import BackupCard from "./BackupCard"; +import QuickCaptureCard from "./QuickCaptureCard"; import AuthStatusCard from "./AuthStatusCard"; import { ThemeModePref } from "../themePrefs"; import { useI18n } from "../i18n/I18nProvider"; @@ -297,6 +298,8 @@ export default function SettingsView({ + + diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 6c72577..fae2022 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -157,6 +157,11 @@ export const translations = { settingsOpenReminderInbox: "Open reminders", settingsReviewJobs: "Review jobs", settingsNotificationsTitle: "Notification settings", + settingsQuickCaptureTitle: "Quick capture bookmarklet", + settingsQuickCaptureSubtitle: "Drag this button to your bookmarks bar. On any job posting, click it to open Add Job pre-filled from that page.", + settingsQuickCaptureButton: "+ Save to Jobbjakt", + settingsQuickCaptureDragHint: "Drag me to your bookmarks bar", + settingsQuickCaptureManual: "Or copy the bookmarklet code", settingsNotificationsBody: "Choose which reminders should show up in your workflow. SMTP delivery can be checked from the system page.", settingsNotificationsDelivery: "SMTP delivery and test mail live under Admin → System → Settings.", settingsNotificationsFollowUpReminders: "Email reminders for follow-ups", @@ -1096,6 +1101,11 @@ export const translations = { settingsOpenReminderInbox: "Åpne påminnelser", settingsReviewJobs: "Gå til jobber", settingsNotificationsTitle: "Varslingsinnstillinger", + settingsQuickCaptureTitle: "Hurtiglagring (bokmerke)", + settingsQuickCaptureButton: "+ Lagre til Jobbjakt", + settingsQuickCaptureSubtitle: "Dra denne knappen til bokmerkelinjen. På en stillingsannonse klikker du på den for å åpne Legg til jobb forhåndsutfylt fra siden.", + settingsQuickCaptureDragHint: "Dra meg til bokmerkelinjen", + settingsQuickCaptureManual: "Eller kopier bokmerkekoden", settingsNotificationsBody: "Velg hvilke påminnelser som skal vises i arbeidsflyten din. SMTP-levering kan kontrolleres fra systemsiden.", settingsNotificationsDelivery: "SMTP-levering og test-epost ligger under Admin → System → Innstillinger.", settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger", diff --git a/job-tracker-ui/src/quick-capture.test.tsx b/job-tracker-ui/src/quick-capture.test.tsx new file mode 100644 index 0000000..c996d13 --- /dev/null +++ b/job-tracker-ui/src/quick-capture.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import { ToastProvider } from './toast'; +import { I18nProvider } from './i18n/I18nProvider'; +import { api } from './api'; + +// Avoid pulling the date-fns v4 ESM adapter into Jest; the picker isn't under test here. +jest.mock('@mui/x-date-pickers/DatePicker', () => ({ + DatePicker: ({ label }: any) =>
{label}
, +})); + +// eslint-disable-next-line import/first +import AddJobModal from './components/AddJobModal'; + +jest.setTimeout(15000); + +jest.mock('./api', () => ({ + api: { + get: jest.fn(() => Promise.resolve({ data: [] })), + post: jest.fn(() => Promise.resolve({ data: {} })), + put: jest.fn(() => Promise.resolve({ data: {} })), + patch: jest.fn(() => Promise.resolve({ data: {} })), + delete: jest.fn(() => Promise.resolve({ data: {} })), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, + getApiErrorMessage: jest.fn(() => 'error'), +})); + +const mockedApi = api as jest.Mocked; + +function renderModal(initialUrl?: string) { + return render( + + + {}} onCreated={() => {}} /> + + , + ); +} + +beforeEach(() => { + mockedApi.get.mockResolvedValue({ data: [] } as any); + mockedApi.post.mockImplementation((url: string) => { + if (url === '/jobimport/preview') { + return Promise.resolve({ data: { success: true, title: 'Imported Backend Role', company: 'Acme', location: 'Oslo', description: 'desc', tags: ['C#'] } } as any); + } + return Promise.resolve({ data: {} } as any); + }); +}); + +afterEach(() => jest.clearAllMocks()); + +test('auto-imports from initialUrl and prefills the form', async () => { + renderModal('https://example.com/jobs/123'); + + await waitFor(() => { + expect(mockedApi.post).toHaveBeenCalledWith('/jobimport/preview', { url: 'https://example.com/jobs/123' }); + }); + + expect(await screen.findByDisplayValue('Imported Backend Role')).toBeInTheDocument(); +}); + +test('does not auto-import when no initialUrl is given', async () => { + renderModal(undefined); + + // Wait for the modal to render, then confirm no import was triggered. + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalledWith('/jobimport/preview', expect.anything()); +});