mirror of
https://github.com/VladSez/easy-invoice-pdf
synced 2026-04-21 13:37:40 +00:00
feat: update dependencies and enhance invoice form functionality (#14)
* feat: update dependencies and enhance invoice form functionality - Added new dependencies: @radix-ui/react-label, @radix-ui/react-slot, class-variance-authority, clsx, sonner, tailwind-merge, and tailwindcss-animate. - Updated package.json and pnpm-lock.yaml to reflect new versions. - Enhanced the invoice form with improved error handling and UI components. - Integrated a toast notification system for better user feedback. - Updated the PDF download link to include invoice language in the filename. - Improved layout and styling in the invoice form and PDF template components. - Refactored state management for invoice data to streamline data loading from URL and local storage. * feat: add Prettier configuration and update project files - Introduced a Prettier configuration file (.prettierrc.js) for consistent code formatting. - Updated package.json to include Prettier and its Tailwind CSS plugin as dependencies. - Made minor formatting adjustments across various components and configuration files for improved readability and consistency. - Enhanced the loading and invoice form components with better styling and layout adjustments. - Ensured all JSON files and configuration files have proper formatting and structure. * feat: enhance invoice sharing and download experience - Updated toast notification to include rich text formatting for better user guidance on sharing invoices. - Increased toast duration for improved visibility. - Refactored layout of the invoice regeneration and download button for better mobile responsiveness. - Added conditional rendering for the PDF download link based on mobile view. - Improved styling consistency across components. * feat: implement responsive PDF download link and add media query hook - Introduced a custom hook `useMediaQuery` to manage responsive behavior. - Updated the `InvoicePDFDownloadLink` component to remove mobile-specific prop and enhance styling. - Modified the main page to conditionally render the PDF download link based on desktop view. - Enabled server-side rendering for the PDF download link for improved performance. * feat: enhance invoice PDF layout and translations - Added a new center style to align content in the invoice items table. - Updated the invoice items table to apply the center style for better visual alignment of headers. - Modified translations for 'netAmount' and 'netPrice' to include line breaks for improved readability in the PDF output. * fix: update link in footer to point to new personal website * feat: add contact link to footer for user engagement
This commit is contained in:
parent
bd470ba447
commit
a342935e78
23 changed files with 3746 additions and 1958 deletions
8
.prettierrc.js
Normal file
8
.prettierrc.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('prettier').Config} */
|
||||
/** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig*/
|
||||
const config = {
|
||||
trailingComma: "es5",
|
||||
plugins: [require.resolve("prettier-plugin-tailwindcss")],
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
|
@ -4,12 +4,11 @@
|
|||
|
||||
## TODO
|
||||
|
||||
- [ ] Add OG images
|
||||
- [ ] Test with big values/numbers on the invoice, e.g. 1 000 000 (check if pdf is displayed correctly)
|
||||
- [ ] Add error handling and error pages/messages
|
||||
- [ ] Create a name for the project
|
||||
- [ ] TEST EVERYTHING on chrome, safari, firefox and maybe on mobile?? + introduce beta testers (colleagues, friends, family)
|
||||
- [ ] Add email to contact us
|
||||
- [ ] Add link to github repo and make it open source
|
||||
- Check https://x.com/levelsio/status/1860015822472905091
|
||||
|
||||
(Payments, not sure if we need this)
|
||||
|
|
@ -19,8 +18,6 @@
|
|||
|
||||
(BUGS and improvements, double check everything also if we need this)
|
||||
|
||||
- [ ] BindingError issue https://github.com/diegomura/react-pdf/issues/2892
|
||||
- [ ] Add a button to create a shareable link to invoice. Fix current logic, because now we always put all invoice data in url.
|
||||
- [ ] do not save those things (in page.tsx) in local storage, because they are not needed to be saved, we need them to be recalculated every time for better UX:
|
||||
|
||||
const today = dayjs().format("YYYY-MM-DD");
|
||||
|
|
@ -28,6 +25,10 @@
|
|||
const invoiceCurrentMonthAndYear = dayjs().format("MM-YYYY");
|
||||
const paymentDue = dayjs(today).add(14, "days").format("YYYY-MM-DD");
|
||||
|
||||
if data is in url, always use data from url
|
||||
if no data in url, use data from local storage
|
||||
if local storage data is last month, update it with today's data
|
||||
|
||||
(OTHER)
|
||||
|
||||
- [ ] cleanup code
|
||||
|
|
|
|||
21
components.json
Normal file
21
components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
12
package.json
12
package.json
|
|
@ -6,13 +6,18 @@
|
|||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"prettify": "prettier --write --cache '**/*.{ts?(x),json,js,mjs,yml,yaml,md}'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "3.9.0",
|
||||
"@loglib/tracker": "0.8.0",
|
||||
"@radix-ui/react-label": "2.1.1",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@react-pdf/layout": "4.0.1",
|
||||
"@react-pdf/renderer": "4.0.1",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"dayjs": "1.11.13",
|
||||
"lucide-react": "0.460.0",
|
||||
"lz-string": "1.5.0",
|
||||
|
|
@ -22,6 +27,9 @@
|
|||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "7.53.1",
|
||||
"sonner": "1.7.1",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"use-debounce": "10.0.4",
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
|
|
@ -32,6 +40,8 @@
|
|||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.15",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.4.2",
|
||||
"prettier-plugin-tailwindcss": "0.6.9",
|
||||
"tailwindcss": "3.4.14",
|
||||
"typescript": "5.6.3"
|
||||
}
|
||||
|
|
|
|||
3436
pnpm-lock.yaml
3436
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,21 +1,25 @@
|
|||
import { PDFDownloadLink } from "@react-pdf/renderer";
|
||||
import type { InvoiceData } from "@/app/schema";
|
||||
import dayjs from "dayjs";
|
||||
import { InvoicePdfTemplate } from "./invoice-pdf-template";
|
||||
import { loglib } from "@loglib/tracker";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function InvoicePDFDownloadLink({
|
||||
invoiceData,
|
||||
}: {
|
||||
invoiceData: InvoiceData;
|
||||
}) {
|
||||
const invoiceDate = dayjs().format("DD-MM-YYYY");
|
||||
const filename = `invoice-${
|
||||
invoiceData.language
|
||||
}-${invoiceData.invoiceNumber.replace("/", "-")}.pdf`;
|
||||
|
||||
return (
|
||||
<PDFDownloadLink
|
||||
document={<InvoicePdfTemplate invoiceData={invoiceData} />}
|
||||
fileName={`invoice-${invoiceDate}.pdf`}
|
||||
className="mb-4 lg:mb-0 w-full lg:w-[180px] text-center px-4 py-2 text-white bg-blue-500 rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||
fileName={filename}
|
||||
className={cn(
|
||||
"mb-4 w-full rounded-lg bg-slate-900 px-4 py-2 text-center text-sm font-medium text-slate-50 shadow-sm shadow-black/5 outline-offset-2 hover:bg-slate-900/90 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90 lg:mb-0 lg:w-[180px]"
|
||||
)}
|
||||
onClick={() => {
|
||||
loglib.track("download_invoice");
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,11 @@ export const styles = StyleSheet.create({
|
|||
fontSize: 8,
|
||||
color: "#000",
|
||||
},
|
||||
center: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
} as const);
|
||||
|
||||
// PDF Document component
|
||||
|
|
@ -187,8 +192,10 @@ export const InvoicePdfTemplate = ({
|
|||
const language = invoiceData.language;
|
||||
const t = translations[language];
|
||||
|
||||
const invoiceDocTitle = `${PROD_WEBSITE_URL} - ${t.invoiceNumber} ${invoiceData.invoiceNumber}`;
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Document title={invoiceDocTitle}>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<InvoiceHeader invoiceData={invoiceData} />
|
||||
<InvoiceSellerBuyerInfo invoiceData={invoiceData} />
|
||||
|
|
@ -239,10 +246,10 @@ export const InvoicePdfTemplate = ({
|
|||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<Text style={[styles.fontSize7]}>
|
||||
<Text style={[styles.fontSize9]}>
|
||||
Created with{" "}
|
||||
<Link
|
||||
style={[styles.fontSize7, { color: "black" }]}
|
||||
style={[styles.fontSize9, { color: "blue" }]}
|
||||
src={PROD_WEBSITE_URL}
|
||||
>
|
||||
{PROD_WEBSITE_URL}
|
||||
|
|
|
|||
|
|
@ -18,46 +18,48 @@ export function InvoiceItemsTable({
|
|||
<View style={styles.table}>
|
||||
{/* Table header */}
|
||||
<View style={styles.tableRow}>
|
||||
<View style={[styles.tableCol, styles.colNo]}>
|
||||
<View style={[styles.tableCol, styles.colNo, styles.center]}>
|
||||
<Text style={styles.tableCellBold}>{t.invoiceItemsTable.no}</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCol, styles.colName]}>
|
||||
<View style={[styles.tableCol, styles.colName, styles.center]}>
|
||||
<Text style={styles.tableCellBold}>
|
||||
{t.invoiceItemsTable.nameOfGoodsService}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCol, styles.colGTU]}>
|
||||
<View style={[styles.tableCol, styles.colGTU, styles.center]}>
|
||||
<Text style={styles.tableCellBold}>
|
||||
{t.invoiceItemsTable.typeOfGTU}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCol, styles.colAmount]}>
|
||||
<View style={[styles.tableCol, styles.colAmount, styles.center]}>
|
||||
<Text style={[styles.tableCellBold, {}]}>
|
||||
{t.invoiceItemsTable.amount}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCol, styles.colUnit]}>
|
||||
<View style={[styles.tableCol, styles.colUnit, styles.center]}>
|
||||
<Text style={styles.tableCellBold}>{t.invoiceItemsTable.unit}</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCol, styles.colNetPrice]}>
|
||||
<View style={[styles.tableCol, styles.colNetPrice, styles.center]}>
|
||||
<Text style={styles.tableCellBold}>
|
||||
{t.invoiceItemsTable.netPrice}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCol, styles.colVAT]}>
|
||||
<View style={[styles.tableCol, styles.colVAT, styles.center]}>
|
||||
<Text style={styles.tableCellBold}>{t.invoiceItemsTable.vat}</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCol, styles.colNetAmount]}>
|
||||
<View style={[styles.tableCol, styles.colNetAmount, styles.center]}>
|
||||
<Text style={styles.tableCellBold}>
|
||||
{t.invoiceItemsTable.netAmount}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCol, styles.colVATAmount]}>
|
||||
<View style={[styles.tableCol, styles.colVATAmount, styles.center]}>
|
||||
<Text style={styles.tableCellBold}>
|
||||
{t.invoiceItemsTable.vatAmount}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCol, styles.colPreTaxAmount]}>
|
||||
<View
|
||||
style={[styles.tableCol, styles.colPreTaxAmount, styles.center]}
|
||||
>
|
||||
<Text style={[styles.tableCellBold]}>
|
||||
{t.invoiceItemsTable.preTaxAmount}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const translations = {
|
|||
unit: "Unit",
|
||||
netPrice: "Net price",
|
||||
vat: "VAT",
|
||||
netAmount: "Net Amount",
|
||||
netAmount: "Net\n Amount",
|
||||
vatAmount: "VAT Amount",
|
||||
preTaxAmount: "Pre-tax amount",
|
||||
sum: "SUM",
|
||||
|
|
@ -127,9 +127,9 @@ export const translations = {
|
|||
typeOfGTU: "Typ GTU",
|
||||
amount: "Ilość",
|
||||
unit: "Jm",
|
||||
netPrice: "Cena netto",
|
||||
netPrice: "Cena\n netto",
|
||||
vat: "VAT",
|
||||
netAmount: "Kwota netto",
|
||||
netAmount: "Kwota\n netto",
|
||||
vatAmount: "Kwota VAT",
|
||||
preTaxAmount: "Kwota brutto",
|
||||
sum: "SUMA",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export function InvoicePDFViewer({ children }: { children: React.ReactNode }) {
|
|||
return (
|
||||
<PDFViewer
|
||||
width="100%"
|
||||
className="mb-4 h-[600px] lg:h-[650px] w-full"
|
||||
className="mb-4 h-[600px] w-full lg:h-[650px]"
|
||||
title="Invoice PDF Viewer"
|
||||
>
|
||||
{/* @ts-expect-error PR with fix?: https://github.com/diegomura/react-pdf/pull/2888 */}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,7 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
|
|
@ -25,3 +11,9 @@ body {
|
|||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { NuqsAdapter } from "nuqs/adapters/next/app";
|
|||
import Loglib from "@loglib/tracker/react";
|
||||
|
||||
import "./globals.css";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Free PDF Invoice Generator",
|
||||
|
|
@ -63,8 +64,10 @@ export default function RootLayout({
|
|||
<Loglib
|
||||
config={{
|
||||
id: "pdf-invoice-editor",
|
||||
webVitals: true,
|
||||
}}
|
||||
/>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen p-8 bg-gray-50">
|
||||
<div className="flex flex-col lg:flex-row gap-8 max-w-[1400px] mx-auto">
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="mx-auto flex max-w-[1400px] flex-col gap-8 lg:flex-row">
|
||||
{/* Left side - Form */}
|
||||
<div className="w-full lg:w-1/3 space-y-6">
|
||||
<div className="h-8 w-full lg:w-64 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="w-full space-y-6 lg:w-1/3">
|
||||
<div className="h-8 w-full animate-pulse rounded bg-gray-200 lg:w-64" />
|
||||
|
||||
{/* Form fields skeleton */}
|
||||
<div className="space-y-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="h-4 w-32 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-10 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-10 animate-pulse rounded bg-gray-200" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -19,12 +19,12 @@ export default function Loading() {
|
|||
|
||||
{/* Right side - PDF Preview */}
|
||||
<div className="w-full lg:w-2/3">
|
||||
<div className="bg-gray-200 rounded-lg h-[700px] animate-pulse">
|
||||
<div className="flex justify-between items-center p-4 bg-gray-300 rounded-t-lg">
|
||||
<div className="h-6 w-full lg:w-48 bg-gray-400 rounded animate-pulse" />
|
||||
<div className="gap-2 hidden lg:flex">
|
||||
<div className="h-8 w-32 bg-gray-400 rounded animate-pulse" />
|
||||
<div className="h-8 w-32 bg-blue-400 rounded animate-pulse" />
|
||||
<div className="h-[700px] animate-pulse rounded-lg bg-gray-200">
|
||||
<div className="flex items-center justify-between rounded-t-lg bg-gray-300 p-4">
|
||||
<div className="h-6 w-full animate-pulse rounded bg-gray-400 lg:w-48" />
|
||||
<div className="hidden gap-2 lg:flex">
|
||||
<div className="h-8 w-32 animate-pulse rounded bg-gray-400" />
|
||||
<div className="h-8 w-32 animate-pulse rounded bg-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
321
src/app/page.tsx
321
src/app/page.tsx
|
|
@ -5,21 +5,24 @@ import {
|
|||
SUPPORTED_LANGUAGES,
|
||||
type InvoiceData,
|
||||
} from "@/app/schema";
|
||||
import { startTransition, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
InvoiceForm,
|
||||
PDF_DATA_FORM_ID,
|
||||
PDF_DATA_LOCAL_STORAGE_KEY,
|
||||
} from "./components/invoice-form";
|
||||
import { SUPPORTED_CURRENCIES } from "@/app/schema";
|
||||
import dayjs from "dayjs";
|
||||
import { useQueryState } from "nuqs";
|
||||
import dynamic from "next/dynamic";
|
||||
import { InvoicePdfTemplate } from "./components/invoice-pdf-template";
|
||||
import {
|
||||
compressToEncodedURIComponent,
|
||||
decompressFromEncodedURIComponent,
|
||||
} from "lz-string";
|
||||
import { loglib } from "@loglib/tracker";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMediaQuery } from "@/lib/hooks/use-media-query";
|
||||
|
||||
const InvoicePDFDownloadLink = dynamic(
|
||||
() =>
|
||||
|
|
@ -28,12 +31,15 @@ const InvoicePDFDownloadLink = dynamic(
|
|||
),
|
||||
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="mb-4 lg:mb-0 w-full lg:w-[180px] text-center px-4 py-2 text-white bg-blue-500 rounded hover:bg-blue-600">
|
||||
Loading document...
|
||||
</div>
|
||||
),
|
||||
ssr: true,
|
||||
loading: () => {
|
||||
// fake button styles
|
||||
return (
|
||||
<div className="mb-4 w-full rounded-lg bg-slate-900 px-4 py-2 text-center text-sm font-medium text-slate-50 shadow-sm shadow-black/5 hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90 lg:mb-0 lg:w-[180px]">
|
||||
Loading document...
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -46,9 +52,9 @@ const InvoicePDFViewer = dynamic(
|
|||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-full w-full flex items-center justify-center bg-gray-200 border border-gray-200">
|
||||
<div className="flex h-full w-full items-center justify-center border border-gray-200 bg-gray-200">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full mx-auto mb-4" />
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
<p className="text-gray-600">Loading PDF viewer...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -104,129 +110,238 @@ const initialInvoiceData = {
|
|||
} as const satisfies InvoiceData;
|
||||
|
||||
export default function Home() {
|
||||
const [invoiceDataParam, setInvoiceDataParam] = useQueryState("data", {
|
||||
parse: (value: string) => {
|
||||
try {
|
||||
// Decompress the URL parameter before parsing
|
||||
const decompressed = decompressFromEncodedURIComponent(value);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [invoiceDataState, setInvoiceDataState] = useState<InvoiceData | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||
|
||||
// Initialize data from URL or localStorage on mount
|
||||
useEffect(() => {
|
||||
const urlData = searchParams.get("data");
|
||||
|
||||
if (urlData) {
|
||||
try {
|
||||
const decompressed = decompressFromEncodedURIComponent(urlData);
|
||||
const parsed = JSON.parse(decompressed);
|
||||
const validated = invoiceSchema.parse(parsed);
|
||||
|
||||
return validated;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
serialize: (value: InvoiceData | null) => {
|
||||
if (!value) return "";
|
||||
|
||||
const stringified = JSON.stringify(value);
|
||||
|
||||
// Compress the data before adding to URL
|
||||
const compressed = compressToEncodedURIComponent(stringified);
|
||||
|
||||
return compressed;
|
||||
},
|
||||
defaultValue: null,
|
||||
});
|
||||
|
||||
// Initialize data from localStorage only if URL param is not present
|
||||
useEffect(() => {
|
||||
if (!invoiceDataParam) {
|
||||
try {
|
||||
const savedData = localStorage.getItem(PDF_DATA_LOCAL_STORAGE_KEY);
|
||||
if (savedData) {
|
||||
const parsedData = invoiceSchema.parse(JSON.parse(savedData));
|
||||
setInvoiceDataParam(parsedData);
|
||||
} else {
|
||||
setInvoiceDataParam(initialInvoiceData);
|
||||
}
|
||||
setInvoiceDataState(validated);
|
||||
} catch (error) {
|
||||
console.error("Failed to load saved invoice data:", error);
|
||||
alert("Failed to load saved invoice data");
|
||||
setInvoiceDataParam(initialInvoiceData);
|
||||
// fallback to local storage
|
||||
console.error("Failed to parse URL data:", error);
|
||||
loadFromLocalStorage();
|
||||
}
|
||||
} else {
|
||||
// if no data in url, load from local storage
|
||||
loadFromLocalStorage();
|
||||
}
|
||||
}, [invoiceDataParam, setInvoiceDataParam]); // Only run on mount
|
||||
}, [searchParams]);
|
||||
|
||||
// Sync URL data to localStorage whenever URL params change
|
||||
useEffect(() => {
|
||||
if (invoiceDataParam) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
PDF_DATA_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(invoiceDataParam)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to save invoice data:", error);
|
||||
alert("Failed to save invoice data");
|
||||
// Helper function to load from localStorage
|
||||
const loadFromLocalStorage = () => {
|
||||
try {
|
||||
const savedData = localStorage.getItem(PDF_DATA_LOCAL_STORAGE_KEY);
|
||||
if (savedData) {
|
||||
const json = JSON.parse(savedData);
|
||||
const parsedData = invoiceSchema.parse(json);
|
||||
|
||||
// const today = dayjs();
|
||||
|
||||
// const isInvoiceFromPreviousMonth = dayjs(
|
||||
// parsedData?.dateOfIssue
|
||||
// ).isBefore(today, "month");
|
||||
|
||||
// if (isInvoiceFromPreviousMonth) {
|
||||
// parsedData.dateOfIssue = today.format("YYYY-MM-DD");
|
||||
// parsedData.dateOfService = today.endOf("month").format("YYYY-MM-DD");
|
||||
// parsedData.paymentDue = today.add(14, "days").format("YYYY-MM-DD");
|
||||
// parsedData.invoiceNumber = `1/${today.format("MM-YYYY")}`;
|
||||
// }
|
||||
|
||||
setInvoiceDataState(parsedData);
|
||||
// setInvoiceDataState(restOfInvoiceData);
|
||||
} else {
|
||||
// if no data in local storage, set initial data
|
||||
setInvoiceDataState(initialInvoiceData);
|
||||
}
|
||||
}
|
||||
}, [invoiceDataParam]);
|
||||
} catch (error) {
|
||||
console.error("Failed to load saved invoice data:", error);
|
||||
|
||||
const handleInvoiceDataChange = (updatedData: InvoiceData) => {
|
||||
startTransition(() => {
|
||||
setInvoiceDataParam(updatedData);
|
||||
});
|
||||
setInvoiceDataState(initialInvoiceData);
|
||||
}
|
||||
};
|
||||
|
||||
if (!invoiceDataParam) {
|
||||
// Save to localStorage whenever data changes on form update
|
||||
useEffect(() => {
|
||||
if (invoiceDataState) {
|
||||
try {
|
||||
const newInvoiceDataValidated = invoiceSchema.parse(invoiceDataState);
|
||||
|
||||
localStorage.setItem(
|
||||
PDF_DATA_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(newInvoiceDataValidated)
|
||||
);
|
||||
|
||||
// Check if URL has data and current data is different
|
||||
const urlData = searchParams.get("data");
|
||||
|
||||
if (urlData) {
|
||||
try {
|
||||
const decompressed = decompressFromEncodedURIComponent(urlData);
|
||||
const urlParsed = JSON.parse(decompressed);
|
||||
|
||||
const urlValidated = invoiceSchema.parse(urlParsed);
|
||||
|
||||
if (
|
||||
JSON.stringify(urlValidated) !==
|
||||
JSON.stringify(newInvoiceDataValidated)
|
||||
) {
|
||||
toast.info(
|
||||
<p>
|
||||
<span className="font-semibold">Changes detected:</span> This
|
||||
invoice differs from the shared link version. To share your
|
||||
updated invoice, please click 'Generate a link to
|
||||
invoice' to create a new shareable link.
|
||||
</p>,
|
||||
{
|
||||
duration: 10000,
|
||||
closeButton: true,
|
||||
richColors: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Clean URL if data differs
|
||||
router.replace("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to compare with URL data:", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save invoice data:", error);
|
||||
toast.error("Failed to save invoice data");
|
||||
}
|
||||
}
|
||||
}, [invoiceDataState, router, searchParams]);
|
||||
|
||||
const handleInvoiceDataChange = (updatedData: InvoiceData) => {
|
||||
setInvoiceDataState(updatedData);
|
||||
};
|
||||
|
||||
const handleShareInvoice = async () => {
|
||||
if (invoiceDataState) {
|
||||
try {
|
||||
const newInvoiceDataValidated = invoiceSchema.parse(invoiceDataState);
|
||||
const stringified = JSON.stringify(newInvoiceDataValidated);
|
||||
const compressed = compressToEncodedURIComponent(stringified);
|
||||
|
||||
// Use push instead of replace to maintain history
|
||||
router.push(`/?data=${compressed}`);
|
||||
|
||||
const newFullUrl = `${window.location.origin}/?data=${compressed}`;
|
||||
await navigator.clipboard.writeText(newFullUrl);
|
||||
|
||||
toast.success("URL copied to clipboard!");
|
||||
} catch (error) {
|
||||
console.error("Failed to share invoice:", error);
|
||||
toast.error("Failed to generate shareable link");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// we only want to render the page on client side
|
||||
if (!invoiceDataState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 p-4">
|
||||
<div className="bg-white p-6 rounded-lg shadow-lg w-full max-w-7xl">
|
||||
<div className="flex flex-row flex-wrap lg:flex-nowrap items-center justify-between w-full">
|
||||
<h1 className="text-2xl font-bold mb-4 w-full text-center lg:text-left">
|
||||
Free Invoice PDF Generator •{" "}
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 sm:p-4">
|
||||
<div className="w-full max-w-7xl rounded-lg bg-white p-3 shadow-lg sm:p-6">
|
||||
<div className="flex w-full flex-row flex-wrap items-center justify-between lg:flex-nowrap">
|
||||
<h1 className="mb-6 mt-6 w-full text-balance text-center text-xl font-bold sm:mb-4 sm:mt-0 sm:text-2xl lg:text-left">
|
||||
Free Invoice PDF Generator
|
||||
</h1>
|
||||
|
||||
<div className="mb-1 flex w-full flex-wrap justify-center gap-3 lg:flex-nowrap lg:justify-end">
|
||||
<Button
|
||||
onClick={handleShareInvoice}
|
||||
variant="outline"
|
||||
className="w-full lg:w-auto"
|
||||
>
|
||||
Generate a link to invoice
|
||||
</Button>
|
||||
{isDesktop ? (
|
||||
<InvoicePDFDownloadLink invoiceData={invoiceDataState} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 flex flex-row items-center justify-center lg:mb-0 lg:justify-start">
|
||||
<span className="relative bottom-0 text-sm text-gray-900 lg:bottom-3">
|
||||
Made by{" "}
|
||||
<a
|
||||
href="https://dub.sh/vldzn.me"
|
||||
className="underline transition-colors hover:text-blue-600"
|
||||
target="_blank"
|
||||
>
|
||||
Vlad Sazonau
|
||||
</a>
|
||||
{" | "}
|
||||
<a
|
||||
href="https://github.com/VladSez/pdf-invoice-generator"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 hover:underline"
|
||||
className="group inline-flex items-center gap-2"
|
||||
title="View on GitHub"
|
||||
>
|
||||
Open Source <GithubIcon />
|
||||
<span className="transition-all group-hover:text-blue-600 group-hover:underline">
|
||||
Open Source
|
||||
</span>
|
||||
<GithubIcon />
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<div className="w-full flex justify-center flex-wrap lg:flex-nowrap lg:justify-end gap-3 mb-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard
|
||||
.writeText(window.location.href)
|
||||
.then(() => {
|
||||
alert("URL copied to clipboard!");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to copy URL:", err);
|
||||
alert("Failed to copy URL to clipboard");
|
||||
});
|
||||
|
||||
loglib.track("copy_link_to_invoice");
|
||||
}}
|
||||
className="inline-flex w-full lg:w-auto justify-center items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 rounded-md"
|
||||
{" | "}
|
||||
<a
|
||||
href="mailto:vladsazon27@gmail.com"
|
||||
className="transition-colors hover:text-blue-600 hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
Copy link to invoice
|
||||
</button>
|
||||
<InvoicePDFDownloadLink invoiceData={invoiceDataParam} />
|
||||
</div>
|
||||
Contact me
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
|
||||
<div className="lg:col-span-4">
|
||||
<div className="h-[400px] lg:h-[600px] overflow-auto p-3">
|
||||
<div className="h-[400px] overflow-auto p-3 lg:h-[600px]">
|
||||
<InvoiceForm
|
||||
invoiceData={invoiceDataParam}
|
||||
invoiceData={invoiceDataState}
|
||||
onInvoiceDataChange={handleInvoiceDataChange}
|
||||
/>
|
||||
</div>
|
||||
<hr className="my-4 block w-full lg:hidden" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
form={PDF_DATA_FORM_ID}
|
||||
variant="outline"
|
||||
className="mt-2 w-full"
|
||||
>
|
||||
Regenerate invoice
|
||||
</Button>
|
||||
{/* We show the pdf download link here only on mobile/tables */}
|
||||
{isDesktop ? null : (
|
||||
<InvoicePDFDownloadLink invoiceData={invoiceDataState} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="my-2 block w-full lg:hidden" />
|
||||
</div>
|
||||
<div className="lg:col-span-8 h-[600px] lg:h-[630px] w-full max-w-full">
|
||||
<div className="h-[600px] w-full max-w-full lg:col-span-8 lg:h-[630px]">
|
||||
<InvoicePDFViewer>
|
||||
<InvoicePdfTemplate invoiceData={invoiceDataParam} />
|
||||
<InvoicePdfTemplate invoiceData={invoiceDataState} />
|
||||
</InvoicePDFViewer>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -241,7 +356,7 @@ const GithubIcon = () => {
|
|||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
className="h-5 w-5 transition-all group-hover:fill-blue-600"
|
||||
>
|
||||
<title>View on GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
|
|
|
|||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-slate-900 text-slate-50 shadow-sm shadow-black/5 hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90",
|
||||
destructive:
|
||||
"bg-red-500 text-slate-50 shadow-sm shadow-black/5 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
|
||||
outline:
|
||||
"border border-gray-200 bg-white shadow shadow-black/5 hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50",
|
||||
secondary:
|
||||
"bg-slate-100 text-slate-900 shadow-sm shadow-black/5 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
|
||||
ghost:
|
||||
"hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
|
||||
link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-lg px-3 text-xs",
|
||||
lg: "h-10 rounded-lg px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import * as React from "react";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"h-8 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow placeholder:text-slate-500/70 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:placeholder:text-slate-400/70 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20",
|
||||
type === "search" &&
|
||||
"[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none",
|
||||
type === "file" &&
|
||||
"p-0 pr-3 italic text-slate-500/70 file:me-3 file:h-full file:border-0 file:border-r file:border-solid file:border-slate-200 file:bg-transparent file:px-3 file:text-sm file:font-medium file:not-italic file:text-slate-950 dark:text-slate-400/70 dark:file:border-slate-800 dark:file:text-slate-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
22
src/components/ui/label.tsx
Normal file
22
src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
HTMLLabelElement,
|
||||
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"block text-balance text-xs font-medium text-gray-700 peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-slate-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = "Label";
|
||||
|
||||
export { Label };
|
||||
38
src/components/ui/select-native.tsx
Normal file
38
src/components/ui/select-native.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
export interface SelectPropsNative
|
||||
extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SelectNative = React.forwardRef<HTMLSelectElement, SelectPropsNative>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
className={cn(
|
||||
"peer inline-flex w-full cursor-pointer appearance-none items-center rounded-lg border border-slate-200 bg-white text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 has-[option[disabled]:checked]:text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20 dark:has-[option[disabled]:checked]:text-slate-400",
|
||||
props.multiple
|
||||
? "py-1 [&>*]:px-3 [&>*]:py-1 [&_option:checked]:bg-slate-100 dark:[&_option:checked]:bg-slate-800"
|
||||
: "h-9 pe-8 ps-3",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
{!props.multiple && (
|
||||
<span className="pointer-events-none absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center text-slate-500/80 peer-disabled:opacity-50 dark:text-slate-400/80">
|
||||
<ChevronDown size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
SelectNative.displayName = "SelectNative";
|
||||
|
||||
export { SelectNative };
|
||||
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow placeholder:text-slate-500/70 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:placeholder:text-slate-400/70 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
64
src/lib/hooks/use-media-query.tsx
Normal file
64
src/lib/hooks/use-media-query.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface UseMediaQueryOptions {
|
||||
getInitialValueInEffect: boolean;
|
||||
}
|
||||
|
||||
type MediaQueryCallback = (event: { matches: boolean; media: string }) => void;
|
||||
|
||||
/**
|
||||
* Older versions of Safari (shipped withCatalina and before) do not support addEventListener on matchMedia
|
||||
* https://stackoverflow.com/questions/56466261/matchmedia-addlistener-marked-as-deprecated-addeventlistener-equivalent
|
||||
* */
|
||||
function attachMediaListener(
|
||||
query: MediaQueryList,
|
||||
callback: MediaQueryCallback
|
||||
) {
|
||||
try {
|
||||
query.addEventListener("change", callback);
|
||||
return () => query.removeEventListener("change", callback);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e: unknown) {
|
||||
query.addListener(callback);
|
||||
return () => query.removeListener(callback);
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialValue(query: string, initialValue?: boolean) {
|
||||
if (typeof initialValue === "boolean") {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && "matchMedia" in window) {
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useMediaQuery(
|
||||
query: string,
|
||||
initialValue?: boolean,
|
||||
{ getInitialValueInEffect }: UseMediaQueryOptions = {
|
||||
getInitialValueInEffect: true,
|
||||
}
|
||||
) {
|
||||
const [matches, setMatches] = useState(
|
||||
getInitialValueInEffect ? initialValue : getInitialValue(query)
|
||||
);
|
||||
const queryRef = useRef<MediaQueryList | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if ("matchMedia" in window) {
|
||||
queryRef.current = window.matchMedia(query);
|
||||
setMatches(queryRef.current.matches);
|
||||
return attachMediaListener(queryRef.current, (event) =>
|
||||
setMatches(event.matches)
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
|
|
@ -12,8 +13,13 @@ const config: Config = {
|
|||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
export default config;
|
||||
|
|
|
|||
Loading…
Reference in a new issue