Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/bulk download qr #1616

Merged
merged 23 commits into from
Feb 13, 2025
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3076613
feat(bulk-download-qr): create dialog for bulk qr download
rockingrohit9639 Jan 30, 2025
c3eec61
feat(bulk-download-qr): create function to handle downloadng bulk qr …
rockingrohit9639 Jan 31, 2025
5f9b36d
feat(bulk-download-qr): create dialog for bulk download qr codes
rockingrohit9639 Jan 31, 2025
93259ec
Merge branch 'main' of github.com:Shelf-nu/shelf.nu into feature/bulk…
rockingrohit9639 Jan 31, 2025
2204ec4
Merge branch 'main' into feature/bulk-download-qr
DonKoko Jan 31, 2025
ddcc8d7
Merge branch 'main' of github.com:Shelf-nu/shelf.nu into feature/bulk…
rockingrohit9639 Feb 3, 2025
89a09f3
Merge branch 'main' of github.com:Shelf-nu/shelf.nu into feature/bulk…
rockingrohit9639 Feb 5, 2025
c15a928
feat(bulk-download-qr): update style, add feedback for loading, updat…
rockingrohit9639 Feb 5, 2025
ca63a5a
feat(bulk-download-qr): optimize qr code generation
rockingrohit9639 Feb 5, 2025
15c6b56
Merge branch 'main' of github.com:Shelf-nu/shelf.nu into feature/bulk…
rockingrohit9639 Feb 6, 2025
e05e61a
feat(bulk-download-qr): change dom-to-image to html-to-image
rockingrohit9639 Feb 7, 2025
eddf093
feat(bulk-download-qr): remove dom-to-image
rockingrohit9639 Feb 7, 2025
a2ad053
feat(bulk-download-qr): remove padding from qr image
rockingrohit9639 Feb 7, 2025
c1556af
Merge branch 'main' into feature/bulk-download-qr
DonKoko Feb 7, 2025
fced6f1
Merge branch 'main' of github.com:Shelf-nu/shelf.nu into feature/bulk…
rockingrohit9639 Feb 11, 2025
451b17d
feat(bulk-download-qr): fix sizing of downloaded qr
rockingrohit9639 Feb 11, 2025
f247add
Merge branch 'main' of github.com:Shelf-nu/shelf.nu into feature/bulk…
rockingrohit9639 Feb 11, 2025
b221248
add comment
rockingrohit9639 Feb 11, 2025
b0859f7
slight adjustment to component used for rendering qr code
DonKoko Feb 11, 2025
b859c97
adding sanitization to filenames in bulk qr download
DonKoko Feb 12, 2025
c7e0476
fix sanitization to handle dots
DonKoko Feb 12, 2025
8ca3cd9
cleanup
DonKoko Feb 12, 2025
f3348d7
locking html-to-image version to prevent error when downloading on fi…
DonKoko Feb 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/atoms/bulk-update-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { BulkDialogType } from "~/components/bulk-update-dialog/bulk-update
* This atom is responsible for holding the open state for dialogs
* Open Dialog must be open at a time
*/
const DEFAULT_STATE = {
const DEFAULT_STATE: Record<BulkDialogType, boolean> = {
location: false,
category: false,
"assign-custody": false,
Expand All @@ -21,6 +21,7 @@ const DEFAULT_STATE = {
unavailable: false,
bookings: false,
"booking-exist": false,
"download-qr": false,
};

export const bulkDialogAtom =
Expand Down
29 changes: 29 additions & 0 deletions app/components/assets/bulk-actions-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from "react";
import { useNavigation } from "@remix-run/react";
import { useAtomValue } from "jotai";
import { useHydrated } from "remix-utils/use-hydrated";
Expand Down Expand Up @@ -32,6 +33,8 @@ import {
} from "../shared/dropdown";
import When from "../when/when";
import BookSelectedAssetsDropdown from "./assets-index/book-selected-assets-dropdown";
import BulkDownloadQrDialog from "./bulk-download-qr-dialog";
import Icon from "../icons/icon";

export default function BulkActionsDropdown() {
const isHydrated = useHydrated();
Expand All @@ -57,6 +60,7 @@ export default function BulkActionsDropdown() {
function ConditionalDropdown() {
const navigation = useNavigation();
const isLoading = isFormProcessing(navigation.state);
const [isBulkDownloadQrOpen, setIsBulkDownloadQrOpen] = useState(false);

const {
ref: dropdownRef,
Expand Down Expand Up @@ -129,6 +133,13 @@ function ConditionalDropdown() {
<BulkMarkAvailabilityDialog type="unavailable" />
</When>

<BulkDownloadQrDialog
isDialogOpen={isBulkDownloadQrOpen}
onClose={() => {
setIsBulkDownloadQrOpen(false);
}}
/>

<When
truthy={userHasPermission({
roles,
Expand Down Expand Up @@ -204,6 +215,24 @@ function ConditionalDropdown() {
ref={dropdownRef}
>
<div className="order fixed bottom-0 left-0 w-screen rounded-b-none rounded-t-[4px] bg-white p-0 text-right md:static md:w-[180px] md:rounded-t-[4px]">
<DropdownMenuItem
onClick={() => {
closeMenu();
setIsBulkDownloadQrOpen(true);
}}
className="border-b py-1 lg:p-0"
>
<Button
variant="link"
className="w-full justify-start px-4 py-3 text-gray-700 hover:text-gray-700"
width="full"
>
<span className="flex items-center gap-2">
<Icon icon="download" /> Download QR Codes
</span>
</Button>
</DropdownMenuItem>

<When
truthy={userHasPermission({
roles,
Expand Down
218 changes: 218 additions & 0 deletions app/components/assets/bulk-download-qr-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { useState } from "react";
import { toBlob } from "html-to-image";
import { useAtomValue } from "jotai";
import JSZip from "jszip";
import { DownloadIcon } from "lucide-react";
import { selectedBulkItemsAtom } from "~/atoms/list";
import { useSearchParams } from "~/hooks/search-params";
import { generateHtmlFromComponent } from "~/utils/component-to-html";
import { isSelectingAllItems } from "~/utils/list";
import { Dialog, DialogPortal } from "../layout/dialog";
import type { QrDef } from "../qr/qr-preview";
import { QrPreview } from "../qr/qr-preview";
import { Button } from "../shared/button";
import { Spinner } from "../shared/spinner";
import When from "../when/when";

type BulkDownloadQrDialogProps = {
className?: string;
isDialogOpen: boolean;
onClose: () => void;
};

type DownloadState =
| { status: "idle" }
| { status: "loading" }
| { status: "success" }
| { status: "error"; error: string };

export default function BulkDownloadQrDialog({
className,
isDialogOpen,
onClose,
}: BulkDownloadQrDialogProps) {
const [downloadState, setDownloadState] = useState<DownloadState>({
status: "idle",
});
const [searchParams] = useSearchParams();

const selectedAssets = useAtomValue(selectedBulkItemsAtom);
const allAssetsSelected = isSelectingAllItems(selectedAssets);

const disabled =
selectedAssets.length === 0 || downloadState.status === "loading";

function handleClose() {
setDownloadState({ status: "idle" });
onClose();
}

async function handleBulkDownloadQr() {
const query = new URLSearchParams();

selectedAssets.forEach((asset) => {
query.append("assetIds", asset.id);
});

setDownloadState({ status: "loading" });

try {
/* Getting all validated assets with qr object */
const response = await fetch(
`/api/assets/get-assets-for-bulk-qr-download?${query}&${searchParams}`
).then((response) => response.json());

const assets = response.assets as Array<{
id: string;
title: string;
createdAt: string;
qr: QrDef;
}>;

const zip = new JSZip();
const qrFolder = zip.folder("qr-codes");

/* Converting our React component to html so that we can later convert it into an image */
const qrNodes = assets.map((asset) =>
generateHtmlFromComponent(
<QrPreview
style={{
border: "3px solid #e5e7eb",
borderRadius: "4px",
padding: "16px",
}}
hideButton
qrObj={{ qr: asset.qr }}
item={{ name: asset.title, type: "asset" }}
/>
)
);

const toBlobOptions = {
height: 700,
width: 700,
backgroundColor: "white",
style: {
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
transform: "scale(2)",
transformOrigin: "center",
},
};

/**
* We are converting first qr to image separately because toBlob will cache the font
* and will not make further network requests for other qr codes.
*/
const firstQrImage = await toBlob(qrNodes[0], toBlobOptions);

/* Converting all qr nodes into images */
const qrImages = await Promise.all(
qrNodes.slice(1).map((qrNode) => toBlob(qrNode, toBlobOptions))
);

qrImages.push(firstQrImage);

/* Appending qr code image to zip file */
qrImages.forEach((qrImage, index) => {
const asset = assets[index];
const filename = `${asset.title}_${asset.qr.id}.jpg`;
if (!qrImage) {
return;
}

if (qrFolder) {
qrFolder.file(filename, qrImage);
} else {
zip.file(filename, qrImage);
}
});

const zipBlob = await zip.generateAsync({ type: "blob" });
const downloadLink = document.createElement("a");

downloadLink.href = URL.createObjectURL(zipBlob);
downloadLink.download = "qr-codes.zip";

downloadLink.click();

setTimeout(() => {
URL.revokeObjectURL(downloadLink.href);
}, 4e4);

setDownloadState({ status: "success" });
} catch (error) {
setDownloadState({
status: "error",
error: error instanceof Error ? error.message : "Something went wrong.",
});
}
}

return (
<DialogPortal>
<Dialog
open={isDialogOpen}
onClose={handleClose}
className={className}
title={
<div className="flex items-center justify-center rounded-full border-8 border-primary-50 bg-primary-100 p-2 text-primary-600">
<DownloadIcon />
</div>
}
>
<div className="px-6 py-4">
{downloadState.status === "loading" ? (
<div className="mb-6 flex flex-col items-center gap-4">
<Spinner />
<h3>Generating Zip file ...</h3>
</div>
) : (
<>
<h4 className="mb-1">
Download qr codes for{" "}
{allAssetsSelected ? "all" : selectedAssets.length} asset(s).
</h4>
<p className="mb-4">
{allAssetsSelected ? "All" : selectedAssets.length} qr code(s)
will be downloaded in a zip file.
</p>
<When truthy={downloadState.status === "success"}>
<p className="mb-4 text-success-500">
Successfully downloaded qr codes.
</p>
</When>

{downloadState.status === "error" ? (
<p className="mb-4 text-error-500">{downloadState.error}</p>
) : null}

<div className="flex w-full items-center justify-center gap-4">
<Button
className="flex-1"
variant="secondary"
onClick={handleClose}
disabled={disabled}
>
Close
</Button>

<When truthy={downloadState.status !== "success"}>
<Button
className="flex-1"
onClick={handleBulkDownloadQr}
disabled={disabled}
>
Download
</Button>
</When>
</div>
</>
)}
</div>
</Dialog>
</DialogPortal>
);
}
3 changes: 2 additions & 1 deletion app/components/bulk-update-dialog/bulk-update-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ type BulkDialogType =
| "available"
| "unavailable"
| "bookings"
| "booking-exist";
| "booking-exist"
| "download-qr";

type CommonBulkDialogProps = {
type: BulkDialogType;
Expand Down
2 changes: 1 addition & 1 deletion app/components/icons/library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1273,7 +1273,7 @@ export const DownloadIcon = (props: SVGProps<SVGSVGElement>) => (
>
<path
d="M4 16.2422C2.79401 15.435 2 14.0602 2 12.5C2 10.1564 3.79151 8.23129 6.07974 8.01937C6.54781 5.17213 9.02024 3 12 3C14.9798 3 17.4522 5.17213 17.9203 8.01937C20.2085 8.23129 22 10.1564 22 12.5C22 14.0602 21.206 15.435 20 16.2422M8 17L12 21M12 21L16 17M12 21V12"
stroke="#667085"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
Expand Down
Loading