chore: format
This commit is contained in:
@@ -1,57 +1,57 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@repo/ui"
|
||||
import { ReactNode } from "react"
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@repo/ui";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface AlertDialogConfirmationProps {
|
||||
trigger: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
cancelText?: string
|
||||
confirmText?: string
|
||||
onConfirm: () => void
|
||||
variant?: "default" | "destructive"
|
||||
trigger: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
cancelText?: string;
|
||||
confirmText?: string;
|
||||
onConfirm: () => void;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
|
||||
export function AlertDialogConfirmation({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
cancelText = "Cancel",
|
||||
confirmText = "Confirm",
|
||||
onConfirm,
|
||||
variant = "default",
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
cancelText = "Cancel",
|
||||
confirmText = "Confirm",
|
||||
onConfirm,
|
||||
variant = "default",
|
||||
}: AlertDialogConfirmationProps) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={
|
||||
variant === "destructive"
|
||||
? "bg-destructive hover:bg-destructive/90 text-white"
|
||||
: undefined
|
||||
}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={
|
||||
variant === "destructive"
|
||||
? "bg-destructive hover:bg-destructive/90 text-white"
|
||||
: undefined
|
||||
}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Skeleton } from "@repo/ui"
|
||||
import { Skeleton } from "@repo/ui";
|
||||
|
||||
export const CardSkeleton = () => (
|
||||
<div className="grid gap-2">
|
||||
<Skeleton className="w-[150px] h-6" />
|
||||
<Skeleton className="w-[80px] h-4" />
|
||||
</div>
|
||||
)
|
||||
<div className="grid gap-2">
|
||||
<Skeleton className="w-[150px] h-6" />
|
||||
<Skeleton className="w-[80px] h-4" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function CenteredLoader() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen w-full">
|
||||
<Loader2 className="animate-spin h-8 w-8 text-primary" />
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen w-full">
|
||||
<Loader2 className="animate-spin h-8 w-8 text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { Check, Copy } from "lucide-react"
|
||||
import { Button } from "@repo/ui"
|
||||
import { useState } from "react"
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Button } from "@repo/ui";
|
||||
import { useState } from "react";
|
||||
|
||||
interface CopyButtonProps {
|
||||
onCopy: () => void | Promise<void>
|
||||
className?: string
|
||||
onCopy: () => void | Promise<void>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CopyButton({ onCopy, className }: CopyButtonProps) {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await onCopy()
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
}
|
||||
const handleCopy = async () => {
|
||||
await onCopy();
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
className={className}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
className={className}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
import { cn } from "@repo/ui"
|
||||
import { useEffect, useRef } from "react"
|
||||
import { cn } from "@repo/ui";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface EmailPreviewProps {
|
||||
content: string
|
||||
className?: string
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmailPreview({ content, className = "" }: EmailPreviewProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return;
|
||||
|
||||
const doc = iframeRef.current.contentDocument
|
||||
if (!doc) return
|
||||
const doc = iframeRef.current.contentDocument;
|
||||
if (!doc) return;
|
||||
|
||||
// Modify the tracking pixel URL for preview
|
||||
// Regex to match /img/any-id/img.png
|
||||
const trackingPixelRegex = /\/img\/[^/]+\/img\.png/g
|
||||
const modifiedContent = content.replace(
|
||||
trackingPixelRegex,
|
||||
"#invalid-tracking-pixel-for-preview"
|
||||
)
|
||||
// Modify the tracking pixel URL for preview
|
||||
// Regex to match /img/any-id/img.png
|
||||
const trackingPixelRegex = /\/img\/[^/]+\/img\.png/g;
|
||||
const modifiedContent = content.replace(
|
||||
trackingPixelRegex,
|
||||
"#invalid-tracking-pixel-for-preview",
|
||||
);
|
||||
|
||||
// Write the content to the iframe
|
||||
doc.open()
|
||||
doc.write(modifiedContent)
|
||||
doc.close()
|
||||
// Write the content to the iframe
|
||||
doc.open();
|
||||
doc.write(modifiedContent);
|
||||
doc.close();
|
||||
|
||||
// Make links open in new tab
|
||||
const links = doc.getElementsByTagName("a")
|
||||
for (const link of links) {
|
||||
link.target = "_blank"
|
||||
link.rel = "noopener noreferrer"
|
||||
}
|
||||
}, [content])
|
||||
// Make links open in new tab
|
||||
const links = doc.getElementsByTagName("a");
|
||||
for (const link of links) {
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={cn("w-full scroll-hidden rounded-md bg-white", className)}
|
||||
sandbox="allow-same-origin"
|
||||
title="Email Preview"
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={cn("w-full scroll-hidden rounded-md bg-white", className)}
|
||||
sandbox="allow-same-origin"
|
||||
title="Email Preview"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
import { Component, ErrorInfo, ReactNode } from "react"
|
||||
import { Button } from "@repo/ui"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { Component, ErrorInfo, ReactNode } from "react";
|
||||
import { Button } from "@repo/ui";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
}
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Uncaught error:", error, errorInfo)
|
||||
}
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Uncaught error:", error, errorInfo);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center p-8 max-w-2xl mx-auto">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="h-12 w-12 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">Something went wrong</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
An unexpected error has occurred.
|
||||
</p>
|
||||
<div className="bg-muted/50 rounded-lg p-4 mb-6">
|
||||
<pre className="text-sm text-muted-foreground break-words whitespace-pre-wrap">
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
</div>
|
||||
<Button onClick={() => window.location.reload()}>Try Again</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center p-8 max-w-2xl mx-auto">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="h-12 w-12 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">Something went wrong</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
An unexpected error has occurred.
|
||||
</p>
|
||||
<div className="bg-muted/50 rounded-lg p-4 mb-6">
|
||||
<pre className="text-sm text-muted-foreground break-words whitespace-pre-wrap">
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
</div>
|
||||
<Button onClick={() => window.location.reload()}>Try Again</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { Control, FieldPath, FieldValues } from "react-hook-form"
|
||||
import type { ComponentProps } from "react"
|
||||
import { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@repo/ui"
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@repo/ui";
|
||||
|
||||
interface FormControlledInputProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
> {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPath<TFieldValues>
|
||||
label: string
|
||||
description?: string
|
||||
inputProps?: ComponentProps<typeof Input>
|
||||
control: Control<TFieldValues>;
|
||||
name: FieldPath<TFieldValues>;
|
||||
label: string;
|
||||
description?: string;
|
||||
inputProps?: ComponentProps<typeof Input>;
|
||||
}
|
||||
|
||||
export function FormControlledInput<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
inputProps,
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
inputProps,
|
||||
}: FormControlledInputProps<TFieldValues>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...inputProps} {...field} />
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...inputProps} {...field} />
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,182 +1,182 @@
|
||||
import { useState } from "react"
|
||||
import { Upload, FileText, AlertCircle, Check } from "lucide-react"
|
||||
import { useState } from "react";
|
||||
import { Upload, FileText, AlertCircle, Check } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Button,
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@repo/ui"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
import { WithTooltip } from "./with-tooltip"
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Button,
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@repo/ui";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
import { WithTooltip } from "./with-tooltip";
|
||||
|
||||
const VALID_HEADERS = [
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"phone",
|
||||
"company",
|
||||
"job_title",
|
||||
"city",
|
||||
"country",
|
||||
"subscribed_at",
|
||||
"tags",
|
||||
] as const
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"phone",
|
||||
"company",
|
||||
"job_title",
|
||||
"city",
|
||||
"country",
|
||||
"subscribed_at",
|
||||
"tags",
|
||||
] as const;
|
||||
|
||||
export type ValidHeader = (typeof VALID_HEADERS)[number]
|
||||
export type ValidHeader = (typeof VALID_HEADERS)[number];
|
||||
|
||||
interface ImportSubscribersDialogProps {
|
||||
onSuccess?: () => void
|
||||
listId?: string
|
||||
onSuccess?: () => void;
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
export function ImportSubscribersDialog({
|
||||
onSuccess,
|
||||
listId,
|
||||
onSuccess,
|
||||
listId,
|
||||
}: ImportSubscribersDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { organization } = useSession()
|
||||
const [open, setOpen] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { organization } = useSession();
|
||||
|
||||
const importMutation = trpc.subscriber.import.useMutation({
|
||||
onSuccess: () => {
|
||||
setOpen(false)
|
||||
setFile(null)
|
||||
setError(null)
|
||||
onSuccess?.()
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error.message)
|
||||
},
|
||||
})
|
||||
const importMutation = trpc.subscriber.import.useMutation({
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setFile(null);
|
||||
setError(null);
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
setError("Please upload a CSV file")
|
||||
return
|
||||
}
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
setError("Please upload a CSV file");
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(file)
|
||||
setError(null)
|
||||
}
|
||||
setFile(file);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file || !organization?.id) return
|
||||
const handleImport = async () => {
|
||||
if (!file || !organization?.id) return;
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("organizationId", organization.id)
|
||||
if (listId) {
|
||||
formData.append("listId", listId)
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("organizationId", organization.id);
|
||||
if (listId) {
|
||||
formData.append("listId", listId);
|
||||
}
|
||||
|
||||
importMutation.mutate({
|
||||
file: formData,
|
||||
organizationId: organization.id,
|
||||
listId,
|
||||
})
|
||||
}
|
||||
importMutation.mutate({
|
||||
file: formData,
|
||||
organizationId: organization.id,
|
||||
listId,
|
||||
});
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = VALID_HEADERS.join(",")
|
||||
const blob = new Blob([headers], { type: "text/csv" })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "subscribers_template.csv"
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
const downloadTemplate = () => {
|
||||
const headers = VALID_HEADERS.join(",");
|
||||
const blob = new Blob([headers], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "subscribers_template.csv";
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<WithTooltip content="Import subscribers">
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</WithTooltip>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Subscribers</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a CSV file with subscriber data. Download the template to see
|
||||
the correct format.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<WithTooltip content="Import subscribers">
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</WithTooltip>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Subscribers</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a CSV file with subscriber data. Download the template to see
|
||||
the correct format.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Button variant="outline" onClick={downloadTemplate}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Download Template
|
||||
</Button>
|
||||
<div className="space-y-4">
|
||||
<Button variant="outline" onClick={downloadTemplate}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Download Template
|
||||
</Button>
|
||||
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<label
|
||||
htmlFor="file"
|
||||
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-2 text-sm">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
{file.name}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Click to upload or drag and drop
|
||||
<br />
|
||||
CSV files only
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<label
|
||||
htmlFor="file"
|
||||
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-2 text-sm">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
{file.name}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Click to upload or drag and drop
|
||||
<br />
|
||||
CSV files only
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!file || importMutation.isPending}
|
||||
>
|
||||
{importMutation.isPending ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
"Import"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!file || importMutation.isPending}
|
||||
>
|
||||
{importMutation.isPending ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
"Import"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export * from "./copy-button"
|
||||
export * from "./error-boundary"
|
||||
export * from "./import-subscribers-dialog"
|
||||
export * from "./with-tooltip"
|
||||
export * from "./email-preview"
|
||||
export * from "./theme-toggle"
|
||||
export * from "./theme-provider"
|
||||
export * from "./card-skeleton"
|
||||
export * from "./stat-card"
|
||||
export * from "./alert-dialog-confirmation"
|
||||
export * from "./pagination"
|
||||
export * from "./centered-loader"
|
||||
export * from "./loader"
|
||||
export * from "./letter-space-text"
|
||||
export * from "./form-controlled-input"
|
||||
export * from "./copy-button";
|
||||
export * from "./error-boundary";
|
||||
export * from "./import-subscribers-dialog";
|
||||
export * from "./with-tooltip";
|
||||
export * from "./email-preview";
|
||||
export * from "./theme-toggle";
|
||||
export * from "./theme-provider";
|
||||
export * from "./card-skeleton";
|
||||
export * from "./stat-card";
|
||||
export * from "./alert-dialog-confirmation";
|
||||
export * from "./pagination";
|
||||
export * from "./centered-loader";
|
||||
export * from "./loader";
|
||||
export * from "./letter-space-text";
|
||||
export * from "./form-controlled-input";
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { cn } from "@repo/ui"
|
||||
import { cn } from "@repo/ui";
|
||||
|
||||
type LetterSpaceTextProps = {
|
||||
className?: string
|
||||
as?: React.ElementType
|
||||
}
|
||||
className?: string;
|
||||
as?: React.ElementType;
|
||||
};
|
||||
|
||||
export const LetterSpaceText = ({
|
||||
className,
|
||||
as = "p",
|
||||
className,
|
||||
as = "p",
|
||||
}: LetterSpaceTextProps) => {
|
||||
const Comp = as
|
||||
const Comp = as;
|
||||
|
||||
return (
|
||||
<Comp className={cn("text-lg font-semibold", className)}>
|
||||
<span>Letter</span>
|
||||
<span className="text-brand-primary">Space</span>
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Comp className={cn("text-lg font-semibold", className)}>
|
||||
<span>Letter</span>
|
||||
<span className="text-brand-primary">Space</span>
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
type LoaderProps = {
|
||||
size?: number
|
||||
text?: string
|
||||
className?: string
|
||||
height?: string
|
||||
}
|
||||
size?: number;
|
||||
text?: string;
|
||||
className?: string;
|
||||
height?: string;
|
||||
};
|
||||
|
||||
export function Loader({
|
||||
size = 8,
|
||||
text,
|
||||
className = "",
|
||||
height = "h-[300px]",
|
||||
size = 8,
|
||||
text,
|
||||
className = "",
|
||||
height = "h-[300px]",
|
||||
}: LoaderProps) {
|
||||
return (
|
||||
<div className={`flex ${height} items-center justify-center ${className}`}>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className={`h-${size} w-${size} animate-spin text-primary`} />
|
||||
{text && <p className="text-sm text-muted-foreground">{text}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className={`flex ${height} items-center justify-center ${className}`}>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className={`h-${size} w-${size} animate-spin text-primary`} />
|
||||
{text && <p className="text-sm text-muted-foreground">{text}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,113 +1,117 @@
|
||||
import { Pagination as UIPagination, PaginationContent, Button } from "@repo/ui"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
import {
|
||||
Pagination as UIPagination,
|
||||
PaginationContent,
|
||||
Button,
|
||||
} from "@repo/ui";
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
type PaginationProps = {
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
hasNextPage: boolean
|
||||
}
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
hasNextPage,
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
hasNextPage,
|
||||
}: PaginationProps) {
|
||||
const totalButtons = 7
|
||||
const totalButtons = 7;
|
||||
|
||||
const renderPageButton = (pageNumber: number) => (
|
||||
<Button
|
||||
key={pageNumber}
|
||||
variant={page === pageNumber ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => onPageChange(pageNumber)}
|
||||
>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
)
|
||||
const renderPageButton = (pageNumber: number) => (
|
||||
<Button
|
||||
key={pageNumber}
|
||||
variant={page === pageNumber ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => onPageChange(pageNumber)}
|
||||
>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderEllipsis = (key: string) => (
|
||||
<Button key={key} variant="outline" size="icon" disabled>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
const renderEllipsis = (key: string) => (
|
||||
<Button key={key} variant="outline" size="icon" disabled>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderPaginationButtons = () => {
|
||||
const buttons = []
|
||||
const renderPaginationButtons = () => {
|
||||
const buttons = [];
|
||||
|
||||
if (totalPages <= totalButtons) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
buttons.push(renderPageButton(i))
|
||||
}
|
||||
} else {
|
||||
buttons.push(renderPageButton(1))
|
||||
if (totalPages <= totalButtons) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
buttons.push(renderPageButton(i));
|
||||
}
|
||||
} else {
|
||||
buttons.push(renderPageButton(1));
|
||||
|
||||
if (page > 3) {
|
||||
buttons.push(renderEllipsis("start-ellipsis"))
|
||||
}
|
||||
if (page > 3) {
|
||||
buttons.push(renderEllipsis("start-ellipsis"));
|
||||
}
|
||||
|
||||
let start = Math.max(2, page - 1)
|
||||
let end = Math.min(totalPages - 1, page + 1)
|
||||
let start = Math.max(2, page - 1);
|
||||
let end = Math.min(totalPages - 1, page + 1);
|
||||
|
||||
if (page <= 3) {
|
||||
end = Math.min(totalPages - 1, totalButtons - 2)
|
||||
}
|
||||
if (page <= 3) {
|
||||
end = Math.min(totalPages - 1, totalButtons - 2);
|
||||
}
|
||||
|
||||
if (page >= totalPages - 2) {
|
||||
start = Math.max(2, totalPages - (totalButtons - 2))
|
||||
}
|
||||
if (page >= totalPages - 2) {
|
||||
start = Math.max(2, totalPages - (totalButtons - 2));
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
buttons.push(renderPageButton(i))
|
||||
}
|
||||
for (let i = start; i <= end; i++) {
|
||||
buttons.push(renderPageButton(i));
|
||||
}
|
||||
|
||||
if (page < totalPages - 2) {
|
||||
buttons.push(renderEllipsis("end-ellipsis"))
|
||||
}
|
||||
if (page < totalPages - 2) {
|
||||
buttons.push(renderEllipsis("end-ellipsis"));
|
||||
}
|
||||
|
||||
buttons.push(renderPageButton(totalPages))
|
||||
}
|
||||
buttons.push(renderPageButton(totalPages));
|
||||
}
|
||||
|
||||
while (buttons.length < totalButtons) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key={`filler-${buttons.length}`}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled
|
||||
>
|
||||
{" "}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
while (buttons.length < totalButtons) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key={`filler-${buttons.length}`}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled
|
||||
>
|
||||
{" "}
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
|
||||
return buttons
|
||||
}
|
||||
return buttons;
|
||||
};
|
||||
|
||||
return (
|
||||
<UIPagination className="select-none">
|
||||
<PaginationContent className="flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous page</span>
|
||||
</Button>
|
||||
{renderPaginationButtons()}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next page</span>
|
||||
</Button>
|
||||
</PaginationContent>
|
||||
</UIPagination>
|
||||
)
|
||||
return (
|
||||
<UIPagination className="select-none">
|
||||
<PaginationContent className="flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous page</span>
|
||||
</Button>
|
||||
{renderPaginationButtons()}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next page</span>
|
||||
</Button>
|
||||
</PaginationContent>
|
||||
</UIPagination>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
import { TrendingUp } from "lucide-react"
|
||||
import { TrendingDown } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui"
|
||||
import { LucideIcon } from "lucide-react"
|
||||
import { CardSkeleton } from "./card-skeleton"
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { TrendingDown } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { CardSkeleton } from "./card-skeleton";
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
change,
|
||||
subtitle,
|
||||
isLoading,
|
||||
smallTitle,
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
change,
|
||||
subtitle,
|
||||
isLoading,
|
||||
smallTitle,
|
||||
}: {
|
||||
title: string
|
||||
value: string | number
|
||||
icon: LucideIcon
|
||||
change?: number
|
||||
subtitle?: string
|
||||
isLoading: boolean
|
||||
smallTitle?: string
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
change?: number;
|
||||
subtitle?: string;
|
||||
isLoading: boolean;
|
||||
smallTitle?: string;
|
||||
}) {
|
||||
const showChange = change !== undefined && !isNaN(change)
|
||||
const showChange = change !== undefined && !isNaN(change);
|
||||
|
||||
return (
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{title}{" "}
|
||||
{smallTitle && (
|
||||
<small className="text-xs text-muted-foreground">
|
||||
({smallTitle})
|
||||
</small>
|
||||
)}
|
||||
</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{showChange && (
|
||||
<div
|
||||
className={`flex items-center text-sm ${
|
||||
change >= 0 ? "text-emerald-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{change >= 0 ? (
|
||||
<TrendingUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<TrendingDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{change >= 0 ? "+" : "-"}
|
||||
{Math.abs(change).toFixed(1)}% from last month
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return (
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{title}{" "}
|
||||
{smallTitle && (
|
||||
<small className="text-xs text-muted-foreground">
|
||||
({smallTitle})
|
||||
</small>
|
||||
)}
|
||||
</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{showChange && (
|
||||
<div
|
||||
className={`flex items-center text-sm ${
|
||||
change >= 0 ? "text-emerald-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{change >= 0 ? (
|
||||
<TrendingUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<TrendingDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{change >= 0 ? "+" : "-"}
|
||||
{Math.abs(change).toFixed(1)}% from last month
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./theme-provider"
|
||||
export * from "./theme-provider";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ThemeProviderState } from "./theme-provider"
|
||||
import { ThemeProviderState } from "./theme-provider";
|
||||
|
||||
export const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext } from "react"
|
||||
import { ThemeProviderState } from "./theme-provider"
|
||||
import { initialState } from "./state"
|
||||
import { createContext } from "react";
|
||||
import { ThemeProviderState } from "./theme-provider";
|
||||
import { initialState } from "./state";
|
||||
|
||||
export const ThemeProviderContext =
|
||||
createContext<ThemeProviderState>(initialState)
|
||||
createContext<ThemeProviderState>(initialState);
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { ThemeProviderContext } from "./theme-context"
|
||||
import { useEffect, useState } from "react";
|
||||
import { ThemeProviderContext } from "./theme-context";
|
||||
|
||||
export type Theme = "dark" | "light" | "system"
|
||||
export type Theme = "dark" | "light" | "system";
|
||||
|
||||
export type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
export type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { Moon, Sun, Laptop } from "lucide-react"
|
||||
import { Button } from "@repo/ui"
|
||||
import { useTheme } from "@/hooks"
|
||||
import { Moon, Sun, Laptop } from "lucide-react";
|
||||
import { Button } from "@repo/ui";
|
||||
import { useTheme } from "@/hooks";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@repo/ui"
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@repo/ui";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Laptop className="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Laptop className="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@repo/ui"
|
||||
import { ReactNode } from "react"
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@repo/ui";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface WithTooltipProps {
|
||||
content: string
|
||||
children: ReactNode
|
||||
content: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function WithTooltip({ content, children }: WithTooltipProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{content}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{content}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user