diff --git a/extension/src/pages/popup/hooks/use-screenshot.hook.ts b/extension/src/pages/popup/hooks/use-screenshot.hook.ts index ac7bb89..7ce06d3 100644 --- a/extension/src/pages/popup/hooks/use-screenshot.hook.ts +++ b/extension/src/pages/popup/hooks/use-screenshot.hook.ts @@ -3,72 +3,118 @@ import { useRef, useState } from "react"; const useScreenshot = () => { const canvas = useRef(null); const [capturing, setCapturing] = useState(false); + const [isError, setIsError] = useState(false); - async function captureFullPageScreenshot( + const captureFullPageScreenshot = async ( callback: (dataUrl: string) => void - ) { + ) => { if (capturing) { return; } + setIsError(false); setCapturing(true); const dataUrls: string[] = []; - const activeTab = await new Promise( - (resolve) => { - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - resolve(tabs[0]); - }); - } - ); - - if (!activeTab?.id) { - return; - } - - await chrome.scripting.executeScript({ - target: { tabId: activeTab.id }, - function: async () => { - window.scrollTo({ top: 0, left: 0, behavior: "instant" }); - await new Promise((resolve) => setTimeout(resolve, 100)); - }, - }); - - async function captureScreenshot() { - const screenshot = await new Promise((resolve) => { - chrome.tabs.captureVisibleTab({ format: "png" }, (dataUrl) => { - resolve(dataUrl); - }); - }); - - dataUrls.push(screenshot); + try { + const activeTab = await new Promise( + (resolve) => { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + resolve(tabs[0]); + }); + } + ); if (!activeTab?.id) { return; } + // Delete "fixed" CSS properties to avoid overlapping elements await chrome.scripting.executeScript({ target: { tabId: activeTab.id }, - function: () => window.scrollBy(0, window.innerHeight), + function: async () => { + const elements = document.querySelectorAll("*"); + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const style = window.getComputedStyle(element); + + if (style["position"] === "fixed") { + (element as HTMLElement).setAttribute( + "style", + "position:static !important" + ); + (element as HTMLElement).setAttribute( + "job-down-static", + "active" + ); + } + } + + await new Promise((resolve) => setTimeout(resolve, 200)); + }, }); - const reachedBottom = await chrome.scripting.executeScript({ + await chrome.scripting.executeScript({ target: { tabId: activeTab.id }, - function: () => - window.scrollY >= document.body.scrollHeight - window.innerHeight, + function: async () => { + window.scrollTo({ top: 0, left: 0, behavior: "instant" }); + await new Promise((resolve) => setTimeout(resolve, 200)); + }, }); - if (reachedBottom[0].result) { - await stitchScreenshots(dataUrls, callback); - } else { - await new Promise((resolve) => setTimeout(resolve, 500)); - captureScreenshot(); - } - } + const captureScreenshot = async () => { + const screenshot = await new Promise((resolve) => { + chrome.tabs.captureVisibleTab({ format: "png" }, (dataUrl) => { + resolve(dataUrl); + }); + }); - await captureScreenshot(); - setCapturing(false); - } + dataUrls.push(screenshot); + + if (!activeTab?.id) { + return; + } + + await chrome.scripting.executeScript({ + target: { tabId: activeTab.id }, + function: () => window.scrollBy(0, window.innerHeight), + }); + + const reachedBottom = await chrome.scripting.executeScript({ + target: { tabId: activeTab.id }, + function: () => + window.scrollY >= document.body.scrollHeight - window.innerHeight, + }); + + if (reachedBottom[0].result) { + await chrome.scripting.executeScript({ + target: { tabId: activeTab.id }, + function: async () => { + const staticElements = document.querySelectorAll( + '[job-down-static="active"]' + ); + staticElements.forEach((element) => { + element.removeAttribute("style"); + element.removeAttribute("job-down-static"); + }); + }, + }); + + await stitchScreenshots(dataUrls, callback); + } else { + await new Promise((resolve) => setTimeout(resolve, 500)); + await captureScreenshot(); + } + }; + + await captureScreenshot(); + setCapturing(false); + } catch (error) { + setIsError(true); + setCapturing(false); + } + }; async function stitchScreenshots( dataUrls: string[], @@ -114,7 +160,7 @@ const useScreenshot = () => { ); } - return { captureFullPageScreenshot, canvasRef: canvas }; + return { captureFullPageScreenshot, canvasRef: canvas, capturing, isError }; }; export default useScreenshot; diff --git a/extension/src/pages/popup/hooks/use-visible.hook.ts b/extension/src/pages/popup/hooks/use-visible.hook.ts new file mode 100644 index 0000000..acbd815 --- /dev/null +++ b/extension/src/pages/popup/hooks/use-visible.hook.ts @@ -0,0 +1,22 @@ +import { useSyncExternalStore } from "react"; + +export const useVisible = () => { + const visibilitySubscription = (callback: () => void) => { + document.addEventListener("visibilitychange", callback); + + return () => { + document.removeEventListener("visibilitychange", callback); + }; + }; + + const getVisibilitySnapshot = () => { + return document.visibilityState; + }; + + const visibilityState = useSyncExternalStore( + visibilitySubscription, + getVisibilitySnapshot + ); + + return visibilityState === "visible"; +}; diff --git a/extension/src/pages/popup/routes/__root.tsx b/extension/src/pages/popup/routes/__root.tsx index 6d48d4a..8da1272 100644 --- a/extension/src/pages/popup/routes/__root.tsx +++ b/extension/src/pages/popup/routes/__root.tsx @@ -13,9 +13,14 @@ import { import { useEffect } from "react"; import { PlusSquare } from "lucide-react"; import { env } from "@src/env"; +import { useVisible } from "../hooks/use-visible.hook"; export const Route = createRootRoute({ component: () => { + const visibilityChange = useVisible(); + const { callAsync: getTokenAsync } = useMessage({ + type: "userToken", + }); const { data: user, isPending, error } = useMessage({ type: "user" }); const { callAsync: signOutAsync } = useMessage( { type: "signOut" }, @@ -43,6 +48,14 @@ export const Route = createRootRoute({ } }, [user]); + // Grab a new user token when the visibility changes to + // avoid having a stale token each time the popup is opened. + useEffect(() => { + if (visibilityChange) { + getTokenAsync({ type: "userToken" }); + } + }, [visibilityChange]); + if (isPending) { return Authenticating; } diff --git a/extension/src/pages/popup/routes/jobs/add.lazy.tsx b/extension/src/pages/popup/routes/jobs/add.lazy.tsx index 815dbc5..a07f162 100644 --- a/extension/src/pages/popup/routes/jobs/add.lazy.tsx +++ b/extension/src/pages/popup/routes/jobs/add.lazy.tsx @@ -4,39 +4,80 @@ import { LoadingDialog, useAddJob, ScrollArea, + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogFooter, + AlertDialogAction, + AlertDialogDescription, } from "@application-tracker/frontend"; import { createLazyFileRoute } from "@tanstack/react-router"; import useMessage from "@pages/popup/hooks/use-message.hook"; import useScreenshot from "@pages/popup/hooks/use-screenshot.hook"; import { Aperture, Loader2 } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; export const Route = createLazyFileRoute("/jobs/add")({ component: AddJob, }); function AddJob() { - const [capturing, setCapturing] = useState(false); - const { captureFullPageScreenshot, canvasRef } = useScreenshot(); + const [currentUrl, setCurrentUrl] = useState(""); + const { captureFullPageScreenshot, canvasRef, isError, capturing } = + useScreenshot(); const { data: token } = useMessage({ type: "userToken" }); const { onSubmit, onClose, setJobImage, jobImage, isPending } = useAddJob(token); - const onCapture = () => { - setCapturing(true); + const onCapture = async () => { captureFullPageScreenshot((dataUrl) => { setJobImage(dataUrl); - setCapturing(false); }); }; + const retrieveCurrentUrl = async () => { + const activeTab = await new Promise( + (resolve) => { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + resolve(tabs[0]); + }); + } + ); + + if (!activeTab?.url) { + return; + } + + setCurrentUrl(activeTab.url); + }; + + useEffect(() => { + if (currentUrl) { + return; + } + (async () => { + await retrieveCurrentUrl(); + })(); + }, [retrieveCurrentUrl, currentUrl]); + return ( <>
- +
@@ -60,6 +101,22 @@ function AddJob() {
Adding job + {isError && ( + + + + Capture Error + + An error occurred while attempting to capture your current + webpage. Ensure you are on a valid webpage and try again. + + + + Continue + + + + )} ); diff --git a/extension/src/pages/popup/routes/jobs/index.tsx b/extension/src/pages/popup/routes/jobs/index.tsx index e3b2640..a77ae48 100644 --- a/extension/src/pages/popup/routes/jobs/index.tsx +++ b/extension/src/pages/popup/routes/jobs/index.tsx @@ -4,11 +4,10 @@ import { JobResponse, LoadingDialog, useDeleteJobMutation, - useGetJobsSuspenseQuery, useUpdateJobMutation, } from "@application-tracker/frontend"; -import { Suspense, useEffect } from "react"; import useMessage from "@pages/popup/hooks/use-message.hook"; +import { useGetJobsQuery } from "@application-tracker/frontend/src/hooks/use-query.hook"; export const Route = createFileRoute("/jobs/")({ component: Index, @@ -19,51 +18,33 @@ function Index() { data: token, isPending, callAsync: getTokenAsync, - } = useMessage({ type: "userToken" }, { enabled: false }); - - useEffect(() => { - if (!token) { - getTokenAsync({ type: "userToken" }); - } - }, [token]); - - if (isPending) { - return Loading; - } - - return ( -
- Loading} - > - {token && } - -
- ); -} - -interface AllJobsTableAsyncProps { - token: string; -} - -const AllJobsTableAsync: React.FC = ({ token }) => { + } = useMessage({ type: "userToken" }); const navigate = useNavigate(); - const { data: jobs } = useGetJobsSuspenseQuery(token); - const { mutate, isPending: isPendingDelete } = useDeleteJobMutation(); - const { mutate: updateJob } = useUpdateJobMutation(); + const { data: jobs, isPending: isPendingJobs } = useGetJobsQuery( + token as string | undefined + ); + const { mutateAsync, isPending: isPendingDelete } = useDeleteJobMutation(); + const { mutateAsync: updateJobAsync } = useUpdateJobMutation(); - const onDeleteJob = (job: JobResponse) => { - mutate({ jobId: job.id, token }); + const onDeleteJob = async (job: JobResponse) => { + await getTokenAsync({ type: "userToken" }); + await mutateAsync({ jobId: job.id, token: token || "" }); }; - const onDeleteJobs = (jobs: JobResponse[]) => { - jobs.forEach((job) => { - mutate({ jobId: job.id, token }); + const onDeleteJobs = async (jobs: JobResponse[]) => { + await getTokenAsync({ type: "userToken" }); + jobs.forEach(async (job) => { + if (!token) { + return; + } + + await mutateAsync({ jobId: job.id, token }); }); }; - const onUpdateJob = (job: JobResponse) => { - updateJob({ payload: job, token }); + const onUpdateJob = async (job: JobResponse) => { + await getTokenAsync({ type: "userToken" }); + await updateJobAsync({ payload: job, token }); }; const onViewJob = (job: JobResponse) => { @@ -72,15 +53,20 @@ const AllJobsTableAsync: React.FC = ({ token }) => { return (
- + {jobs && ( + + )} + {(isPendingJobs || isPending) && ( + Loading + )}
); -}; +} diff --git a/frontend/package.json b/frontend/package.json index bf44cdd..ba45de5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "dependencies": { "@hookform/resolvers": "^3.3.4", "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index c30b4fa..d7ce191 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -9,4 +9,15 @@ export { LoadingDialog } from "./ui/loading-dialog"; export { DataTable } from "./ui/data-table"; export { Card } from "./ui/card"; export { ScrollArea } from "./ui/scroll-area"; +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "./ui/alert-dialog"; export { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs"; diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..8722561 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/frontend/src/hooks/use-query.hook.ts b/frontend/src/hooks/use-query.hook.ts index 851ddc0..df41b27 100644 --- a/frontend/src/hooks/use-query.hook.ts +++ b/frontend/src/hooks/use-query.hook.ts @@ -23,10 +23,6 @@ const getBaseUrl = () => { return "/api"; }; -/** - * A hook to get a job by its ID. - * @param jobId - The job's id. - */ export const useGetJobByIdQuery = (jobId: string, token?: string) => { const [user] = useIdToken(auth); @@ -55,9 +51,6 @@ export const useGetJobByIdQuery = (jobId: string, token?: string) => { }); }; -/** - * A hook to get all jobs. - */ export const useGetJobsSuspenseQuery = (token?: string) => { const [user] = useIdToken(auth); @@ -86,6 +79,35 @@ export const useGetJobsSuspenseQuery = (token?: string) => { }); }; +/** + * This should only be used with the extension. + * Use useGetJobsSuspenseQuery for the web app. + */ +export const useGetJobsQuery = (token?: string) => { + return useQuery({ + queryKey: ["getJobs"], + queryFn: async () => { + const response = await fetch( + `${getBaseUrl()}${AppConstants.JobsApiRoute}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + if (!response.ok) { + throw new Error( + "An error has occurred: " + httpStatusToText(response.status), + ); + } + const jobs = (await response.json()) as JobsResponseJson; + + return new JobsResponse(jobs).jobs; + }, + enabled: Boolean(token), + }); +}; + export const useCreateJobQuery = () => { const queryClient = useQueryClient(); return useQuery({ @@ -101,9 +123,6 @@ export const useCreateJobQuery = () => { }); }; -/** - * A hook to add a job. - */ export const useAddJobMutation = () => { const [user] = useIdToken(auth); const queryClient = useQueryClient(); @@ -180,9 +199,6 @@ export const useAddJobMutation = () => { }); }; -/** - * A hook to update a job. - */ export const useUpdateJobMutation = () => { const [user] = useIdToken(auth); const queryClient = useQueryClient(); @@ -231,9 +247,6 @@ export const useUpdateJobMutation = () => { }); }; -/** - * A hook to delete a job by its ID. - */ export const useDeleteJobMutation = () => { const [user] = useIdToken(auth); const queryClient = useQueryClient(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48ddca0..d133877 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: '@radix-ui/react-accordion': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-alert-dialog': + specifier: ^1.0.5 + version: 1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-avatar': specifier: ^1.0.4 version: 1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) @@ -1718,6 +1721,32 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-alert-dialog@1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-OrVIOcZL0tl6xibeuGt5/+UxoT2N27KCFOPjFyfXMnchxSHZ/OW7cCX2nGlIYJrbHK/fczPcFzAwvNBB6XBNMA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.0 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.66)(react@18.2.0) + '@types/react': 18.2.66 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-arrow@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==} peerDependencies: