add new design (#70)
* add new design stuff * add more ui things * add more ui changes * more ui changes * add more design * update emoji
This commit is contained in:
@@ -38,6 +38,8 @@
|
||||
"bullmq": "^5.8.2",
|
||||
"chrono-node": "^2.7.6",
|
||||
"date-fns": "^3.6.0",
|
||||
"emoji-picker-react": "^4.12.0",
|
||||
"framer-motion": "^11.0.24",
|
||||
"hono": "^4.2.2",
|
||||
"html-to-text": "^9.0.5",
|
||||
"install": "^0.13.0",
|
||||
|
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ContactBook" ADD COLUMN "emoji" TEXT NOT NULL DEFAULT '📙';
|
@@ -221,6 +221,7 @@ model ContactBook {
|
||||
properties Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
emoji String @default("📙")
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contacts Contact[]
|
||||
|
||||
|
@@ -19,7 +19,7 @@ export default function SesConfigurations() {
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="border rounded-xl">
|
||||
<div className="border rounded-xl shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
|
@@ -35,6 +35,12 @@ import {
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@unsend/ui/src/accordion";
|
||||
|
||||
const sendSchema = z.object({
|
||||
confirmation: z.string(),
|
||||
@@ -97,6 +103,12 @@ function CampaignEditor({
|
||||
const [subject, setSubject] = useState(campaign.subject);
|
||||
const [from, setFrom] = useState(campaign.from);
|
||||
const [contactBookId, setContactBookId] = useState(campaign.contactBookId);
|
||||
const [replyTo, setReplyTo] = useState<string | undefined>(
|
||||
campaign.replyTo[0]
|
||||
);
|
||||
const [previewText, setPreviewText] = useState<string | null>(
|
||||
campaign.previewText
|
||||
);
|
||||
const [openSendDialog, setOpenSendDialog] = useState(false);
|
||||
|
||||
const updateCampaignMutation = api.campaign.updateCampaign.useMutation({
|
||||
@@ -179,10 +191,14 @@ function CampaignEditor({
|
||||
|
||||
const confirmation = sendForm.watch("confirmation");
|
||||
|
||||
const contactBook = contactBooksQuery.data?.find(
|
||||
(book) => book.id === contactBookId
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 container mx-auto">
|
||||
<div className="w-[664px] mx-auto">
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<div className="p-4 container mx-auto ">
|
||||
<div className="mx-auto">
|
||||
<div className="mb-4 flex justify-between items-center w-[700px] mx-auto">
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
@@ -269,9 +285,15 @@ function CampaignEditor({
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 mt-8">
|
||||
<label className="block text-sm font-medium ">Subject</label>
|
||||
<Input
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="item-1">
|
||||
<div className="flex flex-col border shadow rounded-lg mt-12 mb-12 p-4 w-[700px] mx-auto z-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="block text-sm w-[80px] text-muted-foreground">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => {
|
||||
@@ -294,21 +316,26 @@ function CampaignEditor({
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="mt-1 block w-full rounded-md shadow-sm"
|
||||
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
|
||||
/>
|
||||
<AccordionTrigger className="py-0"></AccordionTrigger>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium ">From</label>
|
||||
<Input
|
||||
|
||||
<AccordionContent className=" flex flex-col gap-4">
|
||||
<div className=" flex items-center gap-4 mt-4">
|
||||
<label className=" text-sm w-[80px] text-muted-foreground">
|
||||
From
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={from}
|
||||
onChange={(e) => {
|
||||
setFrom(e.target.value);
|
||||
}}
|
||||
className="mt-1 block w-full rounded-md shadow-sm"
|
||||
className="mt-1 py-1 w-full text-sm outline-none border-b border-transparent focus:border-border bg-transparent"
|
||||
placeholder="Friendly name<hello@example.com>"
|
||||
onBlur={() => {
|
||||
if (from === campaign.from) {
|
||||
if (from === campaign.from || !from) {
|
||||
return;
|
||||
}
|
||||
updateCampaignMutation.mutate(
|
||||
@@ -326,8 +353,75 @@ function CampaignEditor({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-12">
|
||||
<label className="block text-sm font-medium mb-1">To</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="block text-sm w-[80px] text-muted-foreground">
|
||||
Reply To
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={replyTo}
|
||||
onChange={(e) => {
|
||||
setReplyTo(e.target.value);
|
||||
}}
|
||||
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
|
||||
placeholder="hello@example.com"
|
||||
onBlur={() => {
|
||||
if (replyTo === campaign.replyTo[0]) {
|
||||
return;
|
||||
}
|
||||
updateCampaignMutation.mutate(
|
||||
{
|
||||
campaignId: campaign.id,
|
||||
replyTo: replyTo ? [replyTo] : [],
|
||||
},
|
||||
{
|
||||
onError: (e) => {
|
||||
toast.error(`${e.message}. Reverting changes.`);
|
||||
setReplyTo(campaign.replyTo[0]);
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="block text-sm w-[80px] text-muted-foreground">
|
||||
Preview
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={previewText ?? undefined}
|
||||
onChange={(e) => {
|
||||
setPreviewText(e.target.value);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (
|
||||
previewText === campaign.previewText ||
|
||||
!previewText
|
||||
) {
|
||||
return;
|
||||
}
|
||||
updateCampaignMutation.mutate(
|
||||
{
|
||||
campaignId: campaign.id,
|
||||
previewText,
|
||||
},
|
||||
{
|
||||
onError: (e) => {
|
||||
toast.error(`${e.message}. Reverting changes.`);
|
||||
setPreviewText(campaign.previewText ?? "");
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className=" flex items-center gap-2">
|
||||
<label className="block text-sm w-[80px] text-muted-foreground">
|
||||
To
|
||||
</label>
|
||||
{contactBooksQuery.isLoading ? (
|
||||
<Spinner className="w-6 h-6" />
|
||||
) : (
|
||||
@@ -350,21 +444,31 @@ function CampaignEditor({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
{contactBooksQuery.data?.find(
|
||||
(book) => book.id === contactBookId
|
||||
)?.name || "Select a contact book"}
|
||||
{contactBook
|
||||
? `${contactBook.emoji} ${contactBook.name}`
|
||||
: "Select a contact book"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contactBooksQuery.data?.map((book) => (
|
||||
<SelectItem key={book.id} value={book.id}>
|
||||
{book.name}
|
||||
{book.emoji} {book.name}{" "}
|
||||
<span className="text-xs text-muted-foreground ml-4">
|
||||
{" "}
|
||||
{book._count.contacts} contacts
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
|
||||
<div className="w-[600px] mx-auto">
|
||||
<Editor
|
||||
initialContent={json}
|
||||
onUpdate={(content) => {
|
||||
@@ -379,5 +483,7 @@ function CampaignEditor({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -60,7 +60,7 @@ export default function CampaignDetailsPage({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="container mx-auto">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
@@ -78,13 +78,13 @@ export default function CampaignDetailsPage({
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className=" rounded-lg shadow mt-10">
|
||||
<div className="mt-10">
|
||||
<h2 className="text-xl font-semibold mb-4"> Statistics</h2>
|
||||
<div className="flex gap-4">
|
||||
{statusCards.map((card) => (
|
||||
<div
|
||||
key={card.status}
|
||||
className="h-[100px] w-1/4 bg-secondary/10 border rounded-lg p-4 flex flex-col gap-3"
|
||||
className="h-[100px] w-1/4 bg-secondary/10 border rounded-lg shadow p-4 flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{card.status !== "total" ? (
|
||||
@@ -108,36 +108,33 @@ export default function CampaignDetailsPage({
|
||||
</div>
|
||||
|
||||
{campaign.html && (
|
||||
<div className=" rounded-lg shadow mt-16">
|
||||
<div className=" rounded-lg mt-16">
|
||||
<h2 className="text-xl font-semibold mb-4">Email</h2>
|
||||
|
||||
<div className="p-2 rounded-lg border flex flex-col gap-4 w-full">
|
||||
<div className="flex gap-2 mt-2">
|
||||
<span className="w-[65px] text-muted-foreground ">From</span>
|
||||
<span>{campaign.from}</span>
|
||||
<div className="p-2 rounded-lg border shadow flex flex-col gap-4 w-full">
|
||||
<div className="flex flex-col gap-3 px-4 py-1">
|
||||
<div className=" flex text-sm">
|
||||
<div className="w-[70px] text-muted-foreground">Subject</div>
|
||||
<div> {campaign.subject}</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex gap-2">
|
||||
<span className="w-[65px] text-muted-foreground ">To</span>
|
||||
{campaign.contactBookId ? (
|
||||
<div className="flex text-sm">
|
||||
<div className="w-[70px] text-muted-foreground">From</div>
|
||||
<div> {campaign.from}</div>
|
||||
</div>
|
||||
<div className="flex text-sm items-center">
|
||||
<div className="w-[70px] text-muted-foreground">Contact</div>
|
||||
<Link
|
||||
href={`/contacts/${campaign.contactBookId}`}
|
||||
className="text-primary px-4 p-1 bg-muted text-sm rounded-md flex gap-1 items-center"
|
||||
target="_blank"
|
||||
>
|
||||
<div className="bg-secondary p-0.5 px-2 rounded-md ">
|
||||
{campaign.contactBook?.emoji}
|
||||
{campaign.contactBook?.name}
|
||||
<ExternalLinkIcon className="w-4 h-4 " />
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div>No one</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex gap-2">
|
||||
<span className="w-[65px] text-muted-foreground ">Subject</span>
|
||||
<span>{campaign.subject}</span>
|
||||
</div>
|
||||
<div className=" dark:bg-slate-50 overflow-auto text-black rounded py-8">
|
||||
<div className=" dark:bg-slate-50 overflow-auto text-black rounded py-8 border-t">
|
||||
<div dangerouslySetInnerHTML={{ __html: campaign.html ?? "" }} />
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -11,19 +11,18 @@ import {
|
||||
import { api } from "~/trpc/react";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@unsend/ui/src/select";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { CampaignStatus } from "@prisma/client";
|
||||
import DeleteCampaign from "./delete-campaign";
|
||||
import { Edit2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import DuplicateCampaign from "./duplicate-campaign";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@unsend/ui/src/select";
|
||||
|
||||
export default function CampaignList() {
|
||||
const [page, setPage] = useUrlState("page", "1");
|
||||
@@ -33,30 +32,37 @@ export default function CampaignList() {
|
||||
|
||||
const campaignsQuery = api.campaign.getCampaigns.useQuery({
|
||||
page: pageNumber,
|
||||
status: status as CampaignStatus | null,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-10 flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
{/* <Select
|
||||
value={status ?? "All"}
|
||||
onValueChange={(val) => setStatus(val === "All" ? null : val)}
|
||||
<Select
|
||||
value={status ?? "all"}
|
||||
onValueChange={(val) => setStatus(val === "all" ? null : val)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] capitalize">
|
||||
{status || "All statuses"}
|
||||
{status ? status.toLowerCase() : "All statuses"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className=" capitalize">
|
||||
<SelectItem value="all" className=" capitalize">
|
||||
All statuses
|
||||
</SelectItem>
|
||||
<SelectItem value="Active" className=" capitalize">
|
||||
Active
|
||||
<SelectItem value={CampaignStatus.DRAFT} className=" capitalize">
|
||||
Draft
|
||||
</SelectItem>
|
||||
<SelectItem value="Inactive" className=" capitalize">
|
||||
Inactive
|
||||
<SelectItem
|
||||
value={CampaignStatus.SCHEDULED}
|
||||
className=" capitalize"
|
||||
>
|
||||
Scheduled
|
||||
</SelectItem>
|
||||
<SelectItem value={CampaignStatus.SENT} className=" capitalize">
|
||||
Sent
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select> */}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-xl border border-border shadow">
|
||||
<Table className="">
|
||||
@@ -97,10 +103,10 @@ export default function CampaignList() {
|
||||
<div
|
||||
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
|
||||
campaign.status === CampaignStatus.DRAFT
|
||||
? "bg-gray-500/10 text-gray-500 border-gray-600/10"
|
||||
? "bg-gray-500/15 dark:bg-gray-400/15 text-gray-700 dark:text-gray-400/90 border border-gray-500/25 dark:border-gray-700/25"
|
||||
: campaign.status === CampaignStatus.SENT
|
||||
? "bg-emerald-500/10 text-emerald-500 border-emerald-600/10"
|
||||
: "bg-yellow-500/10 text-yellow-600 border-yellow-600/10"
|
||||
? "bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25"
|
||||
: "bg-yellow-500/15 dark:bg-yellow-600/10 text-yellow-700 dark:text-yellow-600/90 border border-yellow-500/25 dark:border-yellow-700/25"
|
||||
}`}
|
||||
>
|
||||
{campaign.status.toLowerCase()}
|
||||
@@ -148,3 +154,170 @@ export default function CampaignList() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// "use client";
|
||||
|
||||
// import {
|
||||
// Table,
|
||||
// TableHeader,
|
||||
// TableRow,
|
||||
// TableHead,
|
||||
// TableBody,
|
||||
// TableCell,
|
||||
// } from "@unsend/ui/src/table";
|
||||
// import { api } from "~/trpc/react";
|
||||
// import { useUrlState } from "~/hooks/useUrlState";
|
||||
// import { Button } from "@unsend/ui/src/button";
|
||||
// import Spinner from "@unsend/ui/src/spinner";
|
||||
// import { formatDistanceToNow } from "date-fns";
|
||||
// import { CampaignStatus } from "@prisma/client";
|
||||
// import DeleteCampaign from "./delete-campaign";
|
||||
// import Link from "next/link";
|
||||
// import DuplicateCampaign from "./duplicate-campaign";
|
||||
// import { motion } from "framer-motion";
|
||||
// import { useRouter } from "next/navigation";
|
||||
// import {
|
||||
// Select,
|
||||
// SelectTrigger,
|
||||
// SelectContent,
|
||||
// SelectItem,
|
||||
// } from "@unsend/ui/src/select";
|
||||
|
||||
// export default function CampaignList() {
|
||||
// const [page, setPage] = useUrlState("page", "1");
|
||||
// const [status, setStatus] = useUrlState("status");
|
||||
|
||||
// const pageNumber = Number(page);
|
||||
|
||||
// const campaignsQuery = api.campaign.getCampaigns.useQuery({
|
||||
// page: pageNumber,
|
||||
// status: status as CampaignStatus | null,
|
||||
// });
|
||||
|
||||
// const router = useRouter();
|
||||
|
||||
// return (
|
||||
// <div className="mt-10 flex flex-col gap-4">
|
||||
// <div className="flex justify-end">
|
||||
// <Select
|
||||
// value={status ?? "all"}
|
||||
// onValueChange={(val) => setStatus(val === "all" ? null : val)}
|
||||
// >
|
||||
// <SelectTrigger className="w-[180px] capitalize">
|
||||
// {status ? status.toLowerCase() : "All statuses"}
|
||||
// </SelectTrigger>
|
||||
// <SelectContent>
|
||||
// <SelectItem value="all" className=" capitalize">
|
||||
// All statuses
|
||||
// </SelectItem>
|
||||
// <SelectItem value={CampaignStatus.DRAFT} className=" capitalize">
|
||||
// Draft
|
||||
// </SelectItem>
|
||||
// <SelectItem
|
||||
// value={CampaignStatus.SCHEDULED}
|
||||
// className=" capitalize"
|
||||
// >
|
||||
// Scheduled
|
||||
// </SelectItem>
|
||||
// <SelectItem value={CampaignStatus.SENT} className=" capitalize">
|
||||
// Sent
|
||||
// </SelectItem>
|
||||
// </SelectContent>
|
||||
// </Select>
|
||||
// </div>
|
||||
|
||||
// {campaignsQuery.isLoading ? (
|
||||
// <div className="flex justify-center items-center mt-20">
|
||||
// <Spinner
|
||||
// className="w-5 h-5 text-primary"
|
||||
// innerSvgClass="stroke-primary"
|
||||
// />
|
||||
// </div>
|
||||
// ) : (
|
||||
// <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 ">
|
||||
// {campaignsQuery.data?.campaigns.map((campaign) => (
|
||||
// <motion.div
|
||||
// whileHover={{ scale: 1.01 }}
|
||||
// transition={{ type: "spring", stiffness: 600, damping: 10 }}
|
||||
// whileTap={{ scale: 0.99 }}
|
||||
// className="border rounded-xl shadow hover:shadow-lg"
|
||||
// key={campaign.id}
|
||||
// >
|
||||
// <div className="flex flex-col">
|
||||
// <Link
|
||||
// href={
|
||||
// campaign.status === CampaignStatus.DRAFT
|
||||
// ? `/campaigns/${campaign.id}/edit`
|
||||
// : `/campaigns/${campaign.id}`
|
||||
// }
|
||||
// >
|
||||
// <div className="h-40 overflow-hidden flex justify-center rounded-t-xl bg-muted/10">
|
||||
// <div
|
||||
// className="transform scale-[0.5] "
|
||||
// dangerouslySetInnerHTML={{ __html: campaign.html ?? "" }}
|
||||
// />
|
||||
// </div>
|
||||
// </Link>
|
||||
|
||||
// <div className="flex justify-between items-center shadow-[0px_-5px_25px_-8px_rgba(0,0,0,0.3)] rounded-xl -mt-2 z-10 bg-background">
|
||||
// <div
|
||||
// className="cursor-pointer w-full py-3 pl-4 flex gap-2 items-start"
|
||||
// onClick={() => router.push(`/campaigns/${campaign.id}`)}
|
||||
// >
|
||||
// <div className="flex flex-col gap-2">
|
||||
// <div className="flex gap-4">
|
||||
// <div className="font-semibold text-sm">
|
||||
// {campaign.name}
|
||||
// </div>
|
||||
// <div
|
||||
// className={`text-center px-4 rounded capitalize py-0.5 text-xs ${
|
||||
// campaign.status === CampaignStatus.DRAFT
|
||||
// ? "bg-gray-500/15 dark:bg-gray-600/10 text-gray-700 dark:text-gray-600/90 border border-gray-500/25 dark:border-gray-700/25"
|
||||
// : campaign.status === CampaignStatus.SENT
|
||||
// ? "bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25"
|
||||
// : "bg-yellow-500/15 dark:bg-yellow-600/10 text-yellow-700 dark:text-yellow-600/90 border border-yellow-500/25 dark:border-yellow-700/25"
|
||||
// }`}
|
||||
// >
|
||||
// {campaign.status.toLowerCase()}
|
||||
// </div>
|
||||
// </div>
|
||||
// <div className="text-muted-foreground text-xs">
|
||||
// {formatDistanceToNow(campaign.createdAt, {
|
||||
// addSuffix: true,
|
||||
// })}
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="flex gap-2 pr-4">
|
||||
// <DuplicateCampaign campaign={campaign} />
|
||||
// <DeleteCampaign campaign={campaign} />
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// </motion.div>
|
||||
// ))}
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {campaignsQuery.data?.totalPage && campaignsQuery.data.totalPage > 1 ? (
|
||||
// <div className="flex gap-4 justify-end">
|
||||
// <Button
|
||||
// size="sm"
|
||||
// onClick={() => setPage((pageNumber - 1).toString())}
|
||||
// disabled={pageNumber === 1}
|
||||
// >
|
||||
// Previous
|
||||
// </Button>
|
||||
// <Button
|
||||
// size="sm"
|
||||
// onClick={() => setPage((pageNumber + 1).toString())}
|
||||
// disabled={pageNumber >= (campaignsQuery.data?.totalPage ?? 0)}
|
||||
// >
|
||||
// Next
|
||||
// </Button>
|
||||
// </div>
|
||||
// ) : null}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
@@ -74,8 +74,8 @@ export const DeleteCampaign: React.FC<{
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red-600/80" />
|
||||
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
|
||||
<Trash2 className="h-[18px] w-[18px] text-red-600/80" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
|
@@ -45,8 +45,8 @@ export const DuplicateCampaign: React.FC<{
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Copy className="h-4 w-4 text-blue-600/80" />
|
||||
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
|
||||
<Copy className="h-[18px] w-[18px] text-blue-600/80" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
|
@@ -120,7 +120,7 @@ export default function AddContact({
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||
className=" w-[100px]"
|
||||
type="submit"
|
||||
disabled={addContactsMutation.isPending}
|
||||
>
|
||||
|
@@ -94,8 +94,8 @@ export default function ContactList({
|
||||
<div
|
||||
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
|
||||
contact.subscribed
|
||||
? "bg-emerald-500/10 text-emerald-500 border-emerald-600/10"
|
||||
: "bg-red-500/10 text-red-600 border-red-600/10"
|
||||
? "bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25"
|
||||
: "bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10"
|
||||
}`}
|
||||
>
|
||||
{contact.subscribed ? "Subscribed" : "Unsubscribed"}
|
||||
|
@@ -10,22 +10,55 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from "@unsend/ui/src/breadcrumb";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import AddContact from "./add-contact";
|
||||
import ContactList from "./contact-list";
|
||||
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import EmojiPicker, { Theme } from "emoji-picker-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@unsend/ui/src/popover";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import { useTheme } from "@unsend/ui";
|
||||
|
||||
export default function ContactsPage({
|
||||
params,
|
||||
}: {
|
||||
params: { contactBookId: string };
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const contactBookDetailQuery = api.contacts.getContactBookDetails.useQuery({
|
||||
contactBookId: params.contactBookId,
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const updateContactBookMutation = api.contacts.updateContactBook.useMutation({
|
||||
onMutate: async (data) => {
|
||||
await utils.contacts.getContactBookDetails.cancel();
|
||||
utils.contacts.getContactBookDetails.setData(
|
||||
{
|
||||
contactBookId: params.contactBookId,
|
||||
},
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.contacts.getContactBookDetails.invalidate({
|
||||
contactBookId: params.contactBookId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -34,15 +67,51 @@ export default function ContactsPage({
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/contacts" className="text-lg">
|
||||
<Link href="/contacts" className="text-xl">
|
||||
Contact books
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="text-lg" />
|
||||
<BreadcrumbSeparator className="text-xl" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-lg ">
|
||||
<BreadcrumbPage className="text-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 hover:bg-transparent text-lg"
|
||||
type="button"
|
||||
>
|
||||
{contactBookDetailQuery.data?.emoji}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full rounded-none border-0 !bg-transparent !p-0 shadow-none drop-shadow-md">
|
||||
<EmojiPicker
|
||||
onEmojiClick={(emojiObject) => {
|
||||
// Handle emoji selection here
|
||||
// You might want to update the contactBook's emoji
|
||||
updateContactBookMutation.mutate({
|
||||
contactBookId: params.contactBookId,
|
||||
emoji: emojiObject.emoji,
|
||||
});
|
||||
}}
|
||||
theme={
|
||||
theme === "system"
|
||||
? Theme.AUTO
|
||||
: theme === "dark"
|
||||
? Theme.DARK
|
||||
: Theme.LIGHT
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</span>
|
||||
<span className="text-xl">
|
||||
{contactBookDetailQuery.data?.name}
|
||||
</span>
|
||||
</div>
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
@@ -53,26 +122,47 @@ export default function ContactsPage({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-16">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<div className=" text-muted-foreground">Total Contacts</div>
|
||||
<div className="text-xl mt-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
|
||||
<p className="font-semibold mb-1">Metrics</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-muted-foreground w-[130px] text-sm">
|
||||
Total Contacts
|
||||
</div>
|
||||
<div className="font-mono text-sm">
|
||||
{contactBookDetailQuery.data?.totalContacts !== undefined
|
||||
? contactBookDetailQuery.data?.totalContacts
|
||||
: "--"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Unsubscribed</div>
|
||||
<div className="text-xl mt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-muted-foreground w-[130px] text-sm">
|
||||
Unsubscribed
|
||||
</div>
|
||||
<div className="font-mono text-sm">
|
||||
{contactBookDetailQuery.data?.unsubscribedContacts !== undefined
|
||||
? contactBookDetailQuery.data?.unsubscribedContacts
|
||||
: "--"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Created at</div>
|
||||
<div className="text-xl mt-3">
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
|
||||
<p className="font-semibold">Details</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-muted-foreground w-[130px] text-sm">
|
||||
Contact book ID
|
||||
</div>
|
||||
<TextWithCopyButton
|
||||
value={params.contactBookId}
|
||||
alwaysShowCopy
|
||||
className="text-sm w-[130px] overflow-hidden text-ellipsis font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-muted-foreground w-[130px] text-sm">
|
||||
Created at
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{contactBookDetailQuery.data?.createdAt
|
||||
? formatDistanceToNow(contactBookDetailQuery.data.createdAt, {
|
||||
addSuffix: true,
|
||||
@@ -80,11 +170,29 @@ export default function ContactsPage({
|
||||
: "--"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Contact book id</div>
|
||||
<div className="border mt-3 px-3 rounded bg-muted/30 ">
|
||||
<TextWithCopyButton value={params.contactBookId} alwaysShowCopy />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
|
||||
<p className="font-semibold">Recent campaigns</p>
|
||||
{!contactBookDetailQuery.isLoading &&
|
||||
contactBookDetailQuery.data?.campaigns.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No campaigns yet.
|
||||
</div>
|
||||
) : null}
|
||||
{contactBookDetailQuery.data?.campaigns.map((campaign) => (
|
||||
<div key={campaign.id} className="flex items-center gap-2">
|
||||
<Link href={`/campaigns/${campaign.id}`}>
|
||||
<div className="text-sm hover:underline hover:decoration-dashed text-nowrap w-[200px] overflow-hidden text-ellipsis">
|
||||
{campaign.name}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{formatDistanceToNow(campaign.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-16">
|
||||
|
@@ -106,7 +106,7 @@ export default function AddContactBook() {
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||
className=" w-[100px]"
|
||||
type="submit"
|
||||
disabled={createContactBookMutation.isPending}
|
||||
>
|
||||
|
@@ -1,78 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@unsend/ui/src/table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { api } from "~/trpc/react";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
import DeleteContactBook from "./delete-contact-book";
|
||||
import Link from "next/link";
|
||||
import EditContactBook from "./edit-contact-book";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function ContactBooksList() {
|
||||
const contactBooksQuery = api.contacts.getContactBooks.useQuery();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="border rounded-xl">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Name</TableHead>
|
||||
<TableHead>Contacts</TableHead>
|
||||
<TableHead>Created at</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{contactBooksQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={6} className="text-center py-4">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : contactBooksQuery.data?.length === 0 ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={6} className="text-center py-4">
|
||||
<p>No contact books added</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
contactBooksQuery.data?.map((contactBook) => (
|
||||
<TableRow>
|
||||
<TableHead scope="row">
|
||||
<Link
|
||||
href={`/contacts/${contactBook.id}`}
|
||||
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-primary"
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 ">
|
||||
{contactBooksQuery.data?.map((contactBook) => (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
transition={{ type: "spring", stiffness: 600, damping: 10 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="border rounded-xl shadow hover:shadow-lg"
|
||||
>
|
||||
{contactBook.name}
|
||||
<div className="flex flex-col">
|
||||
<Link href={`/contacts/${contactBook.id}`} key={contactBook.id}>
|
||||
<div className="flex justify-between items-center p-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{contactBook.emoji}</div>
|
||||
<div className="font-semibold">{contactBook.name}</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="font-mono">
|
||||
{contactBook._count.contacts}
|
||||
</span>{" "}
|
||||
contacts
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</TableHead>
|
||||
{/* <TableCell>{contactBook.name}</TableCell> */}
|
||||
<TableCell>{contactBook._count.contacts}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
<div className="flex justify-between items-center border-t bg-muted/50">
|
||||
<div
|
||||
className="text-muted-foreground text-xs cursor-pointer w-full py-3 pl-4"
|
||||
onClick={() => router.push(`/contacts/${contactBook.id}`)}
|
||||
>
|
||||
{formatDistanceToNow(contactBook.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
</div>
|
||||
<div className="flex gap-3 pr-4">
|
||||
<EditContactBook contactBook={contactBook} />
|
||||
<DeleteContactBook contactBook={contactBook} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -77,8 +77,8 @@ export const DeleteContactBook: React.FC<{
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red-600/80" />
|
||||
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent ">
|
||||
<Trash2 className="h-[18px] w-[18px] text-red-600/80 hover:text-red-600/70" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
@@ -103,7 +103,7 @@ export const DeleteContactBook: React.FC<{
|
||||
name="name"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>name</FormLabel>
|
||||
<FormLabel>Contact book name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
@@ -17,7 +17,6 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@unsend/ui/src/form";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { Edit } from "lucide-react";
|
||||
@@ -73,8 +72,13 @@ export const EditContactBook: React.FC<{
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-0 hover:bg-transparent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Edit className="h-4 w-4 text-primary/80 hover:text-primary/70" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
@@ -102,7 +106,7 @@ export const EditContactBook: React.FC<{
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||
className=" w-[100px]"
|
||||
type="submit"
|
||||
disabled={updateContactBookMutation.isPending}
|
||||
>
|
||||
|
@@ -7,7 +7,7 @@ export default function ContactsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="font-bold text-lg">Contact books</h1>
|
||||
<h1 className="font-semibold text-xl">Contact books</h1>
|
||||
<AddContactBook />
|
||||
</div>
|
||||
<ContactBooksList />
|
||||
|
@@ -32,6 +32,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@unsend/ui/src/dropdown-menu";
|
||||
import { ThemeSwitcher } from "~/components/theme/ThemeSwitcher";
|
||||
|
||||
export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { data: session } = useSession();
|
||||
@@ -50,7 +51,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
<div className="flex-1 h-full">
|
||||
<nav className=" flex-1 h-full flex-col justify-between items-center px-2 text-sm font-medium lg:px-4">
|
||||
<div>
|
||||
<div className="h-[calc(100%-120px)]">
|
||||
<NavButton href="/dashboard">
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
Dashboard
|
||||
@@ -61,11 +62,6 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
Emails
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/domains">
|
||||
<Globe className="h-4 w-4" />
|
||||
Domains
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/contacts">
|
||||
<BookUser className="h-4 w-4" />
|
||||
Contacts
|
||||
@@ -76,6 +72,11 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
Campaigns
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/domains">
|
||||
<Globe className="h-4 w-4" />
|
||||
Domains
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/dev-settings">
|
||||
<Code className="h-4 w-4" />
|
||||
Developer settings
|
||||
@@ -87,7 +88,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
</NavButton>
|
||||
) : null}
|
||||
</div>
|
||||
<div className=" absolute bottom-10 p-4 flex flex-col gap-2">
|
||||
<div className="pl-4 flex flex-col gap-2 w-full">
|
||||
<Link
|
||||
href="https://docs.unsend.dev"
|
||||
target="_blank"
|
||||
@@ -97,6 +98,9 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
<span className="">Docs</span>
|
||||
</Link>
|
||||
<LogoutButton />
|
||||
<div>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
@@ -21,7 +21,7 @@ export default function DashboardChart() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="font-bold text-lg">Dashboard</h1>
|
||||
<h1 className="font-semibold text-xl">Dashboard</h1>
|
||||
<Tabs
|
||||
value={days || "7"}
|
||||
onValueChange={(value) => setDays(value)}
|
||||
@@ -100,7 +100,7 @@ export default function DashboardChart() {
|
||||
)}
|
||||
</div>
|
||||
{!statusQuery.isLoading && statusQuery.data ? (
|
||||
<div className="w-full h-[400px] border rounded-lg p-4">
|
||||
<div className="w-full h-[400px] border shadow rounded-lg p-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
width={900}
|
||||
@@ -205,7 +205,7 @@ const DashboardItemCard: React.FC<DashboardItemCardProps> = ({
|
||||
percentage,
|
||||
}) => {
|
||||
return (
|
||||
<div className="h-[100px] w-[16%] min-w-[170px] bg-secondary/10 border rounded-lg p-4 flex flex-col gap-3">
|
||||
<div className="h-[100px] w-[16%] min-w-[170px] bg-secondary/10 border shadow rounded-lg p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{status !== "total" ? <EmailStatusIcon status={status} /> : null}
|
||||
<div className=" capitalize">{status.toLowerCase()}</div>
|
||||
|
@@ -18,7 +18,7 @@ export default function ApiList() {
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="border rounded-xl">
|
||||
<div className="border rounded-xl shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
|
@@ -111,7 +111,7 @@ export default function DomainItemPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className=" border rounded-lg p-4">
|
||||
<div className=" border rounded-lg p-4 shadow">
|
||||
<p className="font-semibold text-xl">DNS records</p>
|
||||
<Table className="mt-2">
|
||||
<TableHeader className="">
|
||||
@@ -266,7 +266,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg p-4 border flex flex-col gap-6">
|
||||
<div className="rounded-lg shadow p-4 border flex flex-col gap-6">
|
||||
<p className="font-semibold text-xl">Settings</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold">Click tracking</div>
|
||||
@@ -309,30 +309,29 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
const DnsVerificationStatus: React.FC<{ status: string }> = ({ status }) => {
|
||||
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
|
||||
switch (status) {
|
||||
case DomainStatus.NOT_STARTED:
|
||||
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
|
||||
break;
|
||||
case DomainStatus.SUCCESS:
|
||||
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
|
||||
badgeColor =
|
||||
"bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25";
|
||||
break;
|
||||
case DomainStatus.FAILED:
|
||||
badgeColor = "bg-red-500/10 text-red-600 border-red-500/20";
|
||||
badgeColor =
|
||||
"bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10";
|
||||
break;
|
||||
case DomainStatus.TEMPORARY_FAILURE:
|
||||
case DomainStatus.PENDING:
|
||||
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
|
||||
badgeColor =
|
||||
"bg-yellow-500/20 dark:bg-yellow-500/10 text-yellow-600 border border-yellow-600/10";
|
||||
break;
|
||||
default:
|
||||
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
|
||||
badgeColor =
|
||||
"bg-gray-200/70 dark:bg-gray-400/10 text-gray-600 dark:text-gray-400 border border-gray-300 dark:border-gray-400/20";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={` text-center min-w-[70px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
|
||||
className={` text-xs text-center min-w-[70px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
|
||||
>
|
||||
<span className="text-xs">
|
||||
{status.split("_").join(" ").toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -169,7 +169,7 @@ export default function AddDomain() {
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||
className=" w-[100px]"
|
||||
type="submit"
|
||||
disabled={addDomainMutation.isPending}
|
||||
>
|
||||
|
@@ -5,21 +5,22 @@ export const DomainStatusBadge: React.FC<{ status: DomainStatus }> = ({
|
||||
}) => {
|
||||
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
|
||||
switch (status) {
|
||||
case DomainStatus.NOT_STARTED:
|
||||
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
|
||||
break;
|
||||
case DomainStatus.SUCCESS:
|
||||
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
|
||||
badgeColor =
|
||||
"bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25";
|
||||
break;
|
||||
case DomainStatus.FAILED:
|
||||
badgeColor = "bg-red-500/10 text-red-600 border-red-500/20";
|
||||
badgeColor =
|
||||
"bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10";
|
||||
break;
|
||||
case DomainStatus.TEMPORARY_FAILURE:
|
||||
case DomainStatus.PENDING:
|
||||
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
|
||||
badgeColor =
|
||||
"bg-yellow-500/20 dark:bg-yellow-500/10 text-yellow-600 border border-yellow-600/10";
|
||||
break;
|
||||
default:
|
||||
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
|
||||
badgeColor =
|
||||
"bg-gray-200/70 dark:bg-gray-400/10 text-gray-600 dark:text-gray-400 border border-gray-300 dark:border-gray-400/20";
|
||||
}
|
||||
|
||||
return (
|
||||
|
@@ -70,7 +70,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
|
||||
return (
|
||||
<div key={domain.id}>
|
||||
<div className=" pr-8 border rounded-lg flex items-stretch">
|
||||
<div className=" pr-8 border rounded-lg flex items-stretch shadow">
|
||||
<StatusIndicator status={domain.status} />
|
||||
<div className="flex justify-between w-full pl-8 py-4">
|
||||
<div className="flex flex-col gap-4 w-1/5">
|
||||
|
@@ -22,5 +22,5 @@ export const StatusIndicator: React.FC<{ status: DomainStatus }> = ({
|
||||
badgeColor = "bg-gray-400";
|
||||
}
|
||||
|
||||
return <div className={` w-[1px] ${badgeColor} my-1.5 rounded-full`}></div>;
|
||||
return <div className={` w-[2px] ${badgeColor} my-1.5 rounded-full`}></div>;
|
||||
};
|
||||
|
@@ -28,16 +28,16 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
|
||||
const emailQuery = api.email.getEmail.useQuery({ id: emailId });
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="h-full overflow-auto px-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-4 items-center">
|
||||
<h1 className="font-bold text-lg">{emailQuery.data?.to}</h1>
|
||||
<h1 className="font-bold">{emailQuery.data?.to}</h1>
|
||||
<EmailStatusBadge status={emailQuery.data?.latestStatus ?? "SENT"} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-8 mt-10 items-start ">
|
||||
<div className="p-2 rounded-lg border flex flex-col gap-4 w-full">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-8 mt-8 items-start">
|
||||
<div className="p-2 rounded-lg border flex flex-col gap-4 w-full shadow">
|
||||
{/* <div className="flex gap-2">
|
||||
<span className="w-[100px] text-muted-foreground text-sm">
|
||||
From
|
||||
</span>
|
||||
@@ -54,6 +54,15 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
|
||||
Subject
|
||||
</span>
|
||||
<span className="text-sm">{emailQuery.data?.subject}</span>
|
||||
</div> */}
|
||||
<div className="flex flex-col gap-1 px-4 py-1">
|
||||
{/* <div className=" text-[15px] font-medium">
|
||||
{emailQuery.data?.to}
|
||||
</div> */}
|
||||
<div className=" text-sm">Subject: {emailQuery.data?.subject}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
From: {emailQuery.data?.from}
|
||||
</div>
|
||||
</div>
|
||||
{emailQuery.data?.latestStatus === "SCHEDULED" &&
|
||||
emailQuery.data?.scheduledAt ? (
|
||||
@@ -75,19 +84,20 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className=" dark:bg-slate-200 h-[250px] overflow-auto text-black rounded">
|
||||
<div
|
||||
className="px-4 py-4 overflow-auto"
|
||||
dangerouslySetInnerHTML={{ __html: emailQuery.data?.html ?? "" }}
|
||||
<div className=" dark:bg-slate-200 h-[350px] overflow-visible rounded border-t ">
|
||||
<iframe
|
||||
className="w-full h-full"
|
||||
srcDoc={emailQuery.data?.html ?? ""}
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{emailQuery.data?.latestStatus !== "SCHEDULED" ? (
|
||||
<div className=" border rounded-lg w-full ">
|
||||
<div className=" border rounded-lg w-full shadow ">
|
||||
<div className=" p-4 flex flex-col gap-8 w-full">
|
||||
<div className="font-medium">Events History</div>
|
||||
<div className="flex items-stretch px-4 w-full">
|
||||
<div className="border-r border-dashed" />
|
||||
<div className="border-r border-gray-300 dark:border-gray-700 border-dashed" />
|
||||
<div className="flex flex-col gap-12 w-full">
|
||||
{emailQuery.data?.emailEvents.map((evt) => (
|
||||
<div
|
||||
@@ -150,7 +160,7 @@ const EmailStatusText = ({
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<p>{getErrorMessage(_errorData)}</p>
|
||||
<div className="rounded-xl p-4 bg-muted/20 flex flex-col gap-4">
|
||||
<div className="rounded-xl p-4 bg-muted/30 flex flex-col gap-4">
|
||||
<div className="flex gap-2 w-full">
|
||||
<div className="w-1/2">
|
||||
<p className="text-sm text-muted-foreground">Type</p>
|
||||
@@ -176,7 +186,7 @@ const EmailStatusText = ({
|
||||
const userAgent = getUserAgent(_data.userAgent);
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-xl p-4 bg-muted/20 mt-4">
|
||||
<div className="w-full rounded-xl p-4 bg-muted/30 mt-4">
|
||||
<div className="flex w-full ">
|
||||
{userAgent.os.name ? (
|
||||
<div className="w-1/2">
|
||||
@@ -198,7 +208,7 @@ const EmailStatusText = ({
|
||||
const userAgent = getUserAgent(_data.userAgent);
|
||||
|
||||
return (
|
||||
<div className="w-full mt-4 flex flex-col gap-4 rounded-xl p-4 bg-muted/20">
|
||||
<div className="w-full mt-4 flex flex-col gap-4 rounded-xl p-4 bg-muted/30">
|
||||
<div className="flex w-full ">
|
||||
{userAgent.os.name ? (
|
||||
<div className="w-1/2">
|
||||
|
@@ -87,7 +87,17 @@ export default function EmailsList() {
|
||||
<SelectItem value="All statuses" className=" capitalize">
|
||||
All statuses
|
||||
</SelectItem>
|
||||
{Object.values(EmailStatus).map((status) => (
|
||||
{Object.values([
|
||||
"SENT",
|
||||
"SCHEDULED",
|
||||
"QUEUED",
|
||||
"DELIVERED",
|
||||
"BOUNCED",
|
||||
"CLICKED",
|
||||
"OPENED",
|
||||
"DELIVERY_DELAYED",
|
||||
"COMPLAINED",
|
||||
]).map((status) => (
|
||||
<SelectItem value={status} className=" capitalize">
|
||||
{status.toLowerCase().replace("_", " ")}
|
||||
</SelectItem>
|
||||
@@ -101,7 +111,7 @@ export default function EmailsList() {
|
||||
<div className="flex flex-col rounded-xl border shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableRow className=" bg-muted dark:bg-muted/70">
|
||||
<TableHead className="rounded-tl-xl">To</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Subject</TableHead>
|
||||
@@ -129,8 +139,8 @@ export default function EmailsList() {
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex gap-4 items-center">
|
||||
<EmailIcon status={email.latestStatus ?? "Sent"} />
|
||||
<p>{email.to}</p>
|
||||
{/* <EmailIcon status={email.latestStatus ?? "Sent"} /> */}
|
||||
<p> {email.to}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -155,7 +165,9 @@ export default function EmailsList() {
|
||||
<EmailStatusBadge status={email.latestStatus ?? "Sent"} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{email.subject}</TableCell>
|
||||
<TableCell className="">
|
||||
<div className=" max-w-xs truncate">{email.subject}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{email.latestStatus !== "SCHEDULED"
|
||||
? formatDistanceToNow(
|
||||
|
@@ -3,32 +3,36 @@ import { EmailStatus } from "@prisma/client";
|
||||
export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
|
||||
status,
|
||||
}) => {
|
||||
let badgeColor = "bg-gray-400/10 text-gray-400 border-gray-400/10"; // Default color
|
||||
let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; // Default color
|
||||
switch (status) {
|
||||
case "SENT":
|
||||
badgeColor = "bg-gray-400/10 text-gray-400 border-gray-400/10";
|
||||
break;
|
||||
case "DELIVERED":
|
||||
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
|
||||
badgeColor =
|
||||
"bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25";
|
||||
break;
|
||||
case "BOUNCED":
|
||||
case "FAILED":
|
||||
badgeColor = "bg-red-500/10 text-red-600 border-red-600/10";
|
||||
badgeColor =
|
||||
"bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10";
|
||||
break;
|
||||
case "CLICKED":
|
||||
badgeColor = "bg-cyan-500/10 text-cyan-500 border-cyan-600/10";
|
||||
badgeColor =
|
||||
"bg-sky-500/15 text-sky-700 dark:text-sky-600 border border-sky-600/20";
|
||||
break;
|
||||
case "OPENED":
|
||||
badgeColor = "bg-indigo-500/10 text-indigo-500 border-indigo-600/10";
|
||||
badgeColor =
|
||||
"bg-indigo-500/15 text-indigo-600 dark:text-indigo-500 border border-indigo-600/20";
|
||||
break;
|
||||
case "DELIVERY_DELAYED":
|
||||
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
|
||||
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/20";
|
||||
break;
|
||||
case "COMPLAINED":
|
||||
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
|
||||
badgeColor =
|
||||
"bg-yellow-500/20 dark:bg-yellow-500/10 text-yellow-600 border border-yellow-600/10";
|
||||
break;
|
||||
|
||||
default:
|
||||
badgeColor = "bg-gray-400/10 text-gray-400 border-gray-400/10";
|
||||
badgeColor =
|
||||
"bg-gray-200/70 dark:bg-gray-400/10 text-gray-600 dark:text-gray-400 border border-gray-300 dark:border-gray-400/20";
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -43,13 +47,13 @@ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
|
||||
export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
|
||||
status,
|
||||
}) => {
|
||||
let outsideColor = "bg-gray-600";
|
||||
let insideColor = "bg-gray-600/50";
|
||||
let outsideColor = "bg-gray-500";
|
||||
let insideColor = "bg-gray-500/50";
|
||||
|
||||
switch (status) {
|
||||
case "DELIVERED":
|
||||
outsideColor = "bg-emerald-500/30";
|
||||
insideColor = "bg-emerald-500";
|
||||
outsideColor = "bg-green-500/30";
|
||||
insideColor = "bg-green-500";
|
||||
break;
|
||||
case "BOUNCED":
|
||||
case "FAILED":
|
||||
@@ -57,8 +61,8 @@ export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
|
||||
insideColor = "bg-red-500";
|
||||
break;
|
||||
case "CLICKED":
|
||||
outsideColor = "bg-cyan-500/30";
|
||||
insideColor = "bg-cyan-500";
|
||||
outsideColor = "bg-sky-500/30";
|
||||
insideColor = "bg-sky-500";
|
||||
break;
|
||||
case "OPENED":
|
||||
outsideColor = "bg-indigo-500/30";
|
||||
@@ -73,8 +77,8 @@ export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
|
||||
insideColor = "bg-yellow-500";
|
||||
break;
|
||||
default:
|
||||
outsideColor = "bg-gray-600/40";
|
||||
insideColor = "bg-gray-600";
|
||||
outsideColor = "bg-gray-500/20";
|
||||
insideColor = "bg-gray-500";
|
||||
}
|
||||
|
||||
return (
|
||||
|
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import EmailList from "./email-list";
|
||||
|
||||
export default function EmailsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="font-bold text-lg">Emails</h1>
|
||||
<h1 className="font-semibold text-xl">Emails</h1>
|
||||
</div>
|
||||
<EmailList />
|
||||
</div>
|
||||
|
@@ -26,7 +26,7 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`font-sans ${inter.variable} app`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||
<ThemeProvider attribute="class" defaultTheme="light">
|
||||
<Toaster />
|
||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||
</ThemeProvider>
|
||||
|
@@ -6,7 +6,7 @@ import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useState } from "react";
|
||||
import { ClientSafeProvider, signIn } from "next-auth/react";
|
||||
import { ClientSafeProvider, LiteralUnion, signIn } from "next-auth/react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -23,7 +23,9 @@ import {
|
||||
} from "@unsend/ui/src/input-otp";
|
||||
import { Input } from "@unsend/ui/src/input";
|
||||
import { env } from "~/env";
|
||||
import { Provider } from "next-auth/providers/index";
|
||||
import { BuiltInProviderType, Provider } from "next-auth/providers/index";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
import Link from "next/link";
|
||||
|
||||
const emailSchema = z.object({
|
||||
email: z
|
||||
@@ -42,7 +44,7 @@ const providerSvgs = {
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 496 512"
|
||||
className="h-6 w-6 stroke-black fill-black mr-4"
|
||||
className="h-5 w-5 fill-primary-foreground "
|
||||
>
|
||||
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
|
||||
</svg>
|
||||
@@ -51,7 +53,7 @@ const providerSvgs = {
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 488 512"
|
||||
className="h-6 w-6 stroke-black fill-black mr-4"
|
||||
className="h-5 w-5 fill-primary-foreground"
|
||||
>
|
||||
<path d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z" />
|
||||
</svg>
|
||||
@@ -60,8 +62,10 @@ const providerSvgs = {
|
||||
|
||||
export default function LoginPage({
|
||||
providers,
|
||||
isSignup = false,
|
||||
}: {
|
||||
providers?: ClientSafeProvider[];
|
||||
isSignup?: boolean;
|
||||
}) {
|
||||
const [emailStatus, setEmailStatus] = useState<
|
||||
"idle" | "sending" | "success"
|
||||
@@ -85,7 +89,7 @@ export default function LoginPage({
|
||||
}
|
||||
|
||||
async function onOTPSubmit(values: z.infer<typeof otpSchema>) {
|
||||
const { href: callbackUrl } = window.location;
|
||||
const { origin: callbackUrl } = window.location;
|
||||
const email = emailForm.getValues().email;
|
||||
console.log("email", email);
|
||||
|
||||
@@ -98,9 +102,17 @@ export default function LoginPage({
|
||||
(provider) => provider.type === "email"
|
||||
);
|
||||
|
||||
const [submittedProvider, setSubmittedProvider] =
|
||||
useState<LiteralUnion<BuiltInProviderType> | null>(null);
|
||||
|
||||
const handleSubmit = (provider: LiteralUnion<BuiltInProviderType>) => {
|
||||
setSubmittedProvider(provider);
|
||||
signIn(provider);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="h-screen flex justify-center items-center">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<Image
|
||||
src="/logo-dark.png"
|
||||
alt="Unsend"
|
||||
@@ -108,8 +120,22 @@ export default function LoginPage({
|
||||
height={60}
|
||||
className="mx-auto border rounded-lg p-2 bg-black"
|
||||
/>
|
||||
<p className="text-2xl text-center">Log in to unsend</p>
|
||||
<div className="flex flex-col gap-8 mt-8">
|
||||
<div>
|
||||
<p className="text-2xl text-center font-semibold">
|
||||
{isSignup ? "Create new account" : "Sign into Unsend"}
|
||||
</p>
|
||||
<p className="text-center mt-2 text-sm text-muted-foreground">
|
||||
{isSignup ? "Already have an account?" : "New to Unsend?"}
|
||||
<Link
|
||||
href={isSignup ? "/login" : "/signup"}
|
||||
className=" text-primary hover:underline ml-1"
|
||||
>
|
||||
{isSignup ? "Sign in" : "Create new account"}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8 mt-8 border p-8 rounded-lg shadow">
|
||||
{providers &&
|
||||
Object.values(providers).map((provider) => {
|
||||
if (provider.type === "email") return null;
|
||||
@@ -118,10 +144,17 @@ export default function LoginPage({
|
||||
key={provider.id}
|
||||
className="w-[350px]"
|
||||
size="lg"
|
||||
onClick={() => signIn(provider.id)}
|
||||
onClick={() => handleSubmit(provider.id)}
|
||||
>
|
||||
{providerSvgs[provider.id as keyof typeof providerSvgs]}
|
||||
Continue with {provider.name}
|
||||
{submittedProvider === provider.id ? (
|
||||
<Spinner className="w-5 h-5" />
|
||||
) : (
|
||||
providerSvgs[provider.id as keyof typeof providerSvgs]
|
||||
)}
|
||||
<span className="ml-4">
|
||||
{isSignup ? "Sign up with" : "Continue with"}{" "}
|
||||
{provider.name}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
@@ -131,7 +164,7 @@ export default function LoginPage({
|
||||
<p className=" z-10 ml-[175px] -translate-x-1/2 bg-background px-4 text-sm">
|
||||
or
|
||||
</p>
|
||||
<div className="absolute h-[1px] w-[350px] bg-gradient-to-r from-zinc-800 via-zinc-300 to-zinc-800"></div>
|
||||
<div className="absolute h-[1px] w-[350px] bg-gradient-to-l from-zinc-300 via-zinc-800 to-zinc-300"></div>
|
||||
</div>
|
||||
{emailStatus === "success" ? (
|
||||
<>
|
||||
@@ -141,7 +174,7 @@ export default function LoginPage({
|
||||
<Form {...otpForm}>
|
||||
<form
|
||||
onSubmit={otpForm.handleSubmit(onOTPSubmit)}
|
||||
className="space-y-4"
|
||||
className=""
|
||||
>
|
||||
<FormField
|
||||
control={otpForm.control}
|
||||
@@ -186,7 +219,7 @@ export default function LoginPage({
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button className="mt-6 w-[350px] bg-white hover:bg-gray-100 focus:bg-gray-100">
|
||||
<Button size="lg" className=" mt-9 w-[350px]">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
@@ -197,7 +230,7 @@ export default function LoginPage({
|
||||
<Form {...emailForm}>
|
||||
<form
|
||||
onSubmit={emailForm.handleSubmit(onEmailSubmit)}
|
||||
className="space-y-4"
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={emailForm.control}
|
||||
@@ -218,13 +251,13 @@ export default function LoginPage({
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
className=" w-[350px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||
type="submit"
|
||||
className=" w-[350px] "
|
||||
size="lg"
|
||||
disabled={emailStatus === "sending"}
|
||||
>
|
||||
{emailStatus === "sending"
|
||||
? "Sending..."
|
||||
: "Send magic link"}
|
||||
: "Continue with email"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
16
apps/web/src/app/signup/page.tsx
Normal file
16
apps/web/src/app/signup/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerAuthSession } from "~/server/auth";
|
||||
import LoginPage from "../login/login-page";
|
||||
import { getProviders } from "next-auth/react";
|
||||
|
||||
export default async function Login() {
|
||||
const session = await getServerAuthSession();
|
||||
|
||||
if (session) {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const providers = await getProviders();
|
||||
|
||||
return <LoginPage providers={Object.values(providers ?? {})} isSignup />;
|
||||
}
|
45
apps/web/src/components/theme/ThemeSwitcher.tsx
Normal file
45
apps/web/src/components/theme/ThemeSwitcher.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { cn, useTheme } from "@unsend/ui";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import { Monitor, Sun, Moon, SunMoonIcon } from "lucide-react";
|
||||
|
||||
export const ThemeSwitcher = () => {
|
||||
const { theme, setTheme, systemTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center justify-between w-full">
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<SunMoonIcon className="h-4 w-4" />
|
||||
Theme
|
||||
</p>
|
||||
<div className="flex gap-2 border rounded-md p-0.5 ">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("p-0.5 h-5 w-5", theme === "system" ? "bg-muted" : "")}
|
||||
onClick={() => setTheme("system")}
|
||||
>
|
||||
<Monitor className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"p-0.5 h-5 w-5",
|
||||
theme === "light" ? " bg-gray-200" : ""
|
||||
)}
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
<Sun className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("p-0.5 h-5 w-5", theme === "dark" ? "bg-muted" : "")}
|
||||
onClick={() => setTheme("dark")}
|
||||
>
|
||||
<Moon className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,5 +1,6 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { CampaignStatus, Prisma } from "@prisma/client";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env";
|
||||
import {
|
||||
@@ -20,11 +21,14 @@ import {
|
||||
isStorageConfigured,
|
||||
} from "~/server/service/storage-service";
|
||||
|
||||
const statuses = Object.values(CampaignStatus) as [CampaignStatus];
|
||||
|
||||
export const campaignRouter = createTRPCRouter({
|
||||
getCampaigns: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().optional(),
|
||||
status: z.enum(statuses).optional().nullable(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx: { db, team }, input }) => {
|
||||
@@ -36,6 +40,10 @@ export const campaignRouter = createTRPCRouter({
|
||||
teamId: team.id,
|
||||
};
|
||||
|
||||
if (input.status) {
|
||||
whereConditions.status = input.status;
|
||||
}
|
||||
|
||||
const countP = db.campaign.count({ where: whereConditions });
|
||||
|
||||
const campaignsP = db.campaign.findMany({
|
||||
@@ -48,6 +56,7 @@ export const campaignRouter = createTRPCRouter({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
status: true,
|
||||
html: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
@@ -92,6 +101,7 @@ export const campaignRouter = createTRPCRouter({
|
||||
previewText: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
contactBookId: z.string().optional(),
|
||||
replyTo: z.string().array().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
|
||||
@@ -113,10 +123,21 @@ export const campaignRouter = createTRPCRouter({
|
||||
const domain = await validateDomainFromEmail(data.from, team.id);
|
||||
domainId = domain.id;
|
||||
}
|
||||
|
||||
let html: string | null = null;
|
||||
|
||||
if (data.content) {
|
||||
const jsonContent = data.content ? JSON.parse(data.content) : null;
|
||||
|
||||
const renderer = new EmailRenderer(jsonContent);
|
||||
html = await renderer.render();
|
||||
}
|
||||
|
||||
const campaign = await db.campaign.update({
|
||||
where: { id: campaignId },
|
||||
data: {
|
||||
...data,
|
||||
html,
|
||||
domainId,
|
||||
},
|
||||
});
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { CampaignStatus, Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
@@ -43,19 +43,31 @@ export const contactsRouter = createTRPCRouter({
|
||||
|
||||
getContactBookDetails: contactBookProcedure.query(
|
||||
async ({ ctx: { contactBook, db } }) => {
|
||||
const [totalContacts, unsubscribedContacts] = await Promise.all([
|
||||
const [totalContacts, unsubscribedContacts, campaigns] =
|
||||
await Promise.all([
|
||||
db.contact.count({
|
||||
where: { contactBookId: contactBook.id },
|
||||
}),
|
||||
db.contact.count({
|
||||
where: { contactBookId: contactBook.id, subscribed: false },
|
||||
}),
|
||||
db.campaign.findMany({
|
||||
where: {
|
||||
contactBookId: contactBook.id,
|
||||
status: CampaignStatus.SENT,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 2,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...contactBook,
|
||||
totalContacts,
|
||||
unsubscribedContacts,
|
||||
campaigns,
|
||||
};
|
||||
}
|
||||
),
|
||||
@@ -66,6 +78,7 @@ export const contactsRouter = createTRPCRouter({
|
||||
contactBookId: z.string(),
|
||||
name: z.string().optional(),
|
||||
properties: z.record(z.string()).optional(),
|
||||
emoji: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx: { db }, input }) => {
|
||||
|
@@ -177,7 +177,7 @@ export const emailRouter = createTRPCRouter({
|
||||
select: {
|
||||
emailEvents: {
|
||||
orderBy: {
|
||||
status: "asc",
|
||||
status: "desc",
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
|
@@ -188,6 +188,7 @@ type CampainEmail = {
|
||||
from: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
previewText?: string;
|
||||
replyTo?: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
@@ -199,8 +200,17 @@ export async function sendCampaignEmail(
|
||||
campaign: Campaign,
|
||||
emailData: CampainEmail
|
||||
) {
|
||||
const { campaignId, from, subject, replyTo, cc, bcc, teamId, contacts } =
|
||||
emailData;
|
||||
const {
|
||||
campaignId,
|
||||
from,
|
||||
subject,
|
||||
replyTo,
|
||||
cc,
|
||||
bcc,
|
||||
teamId,
|
||||
contacts,
|
||||
previewText,
|
||||
} = emailData;
|
||||
|
||||
const jsonContent = JSON.parse(campaign.content || "{}");
|
||||
const renderer = new EmailRenderer(jsonContent);
|
||||
@@ -242,6 +252,7 @@ export async function sendCampaignEmail(
|
||||
from,
|
||||
subject,
|
||||
html: contact.html,
|
||||
text: previewText,
|
||||
teamId,
|
||||
campaignId,
|
||||
contactId: contact.id,
|
||||
|
390
packages/email-editor/src/extensions/dragHandle.ts
Normal file
390
packages/email-editor/src/extensions/dragHandle.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import {
|
||||
NodeSelection,
|
||||
Plugin,
|
||||
PluginKey,
|
||||
TextSelection,
|
||||
} from "@tiptap/pm/state";
|
||||
import { Fragment, Slice, Node } from "@tiptap/pm/model";
|
||||
|
||||
// @ts-ignore
|
||||
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
||||
|
||||
export interface GlobalDragHandleOptions {
|
||||
/**
|
||||
* The width of the drag handle
|
||||
*/
|
||||
dragHandleWidth: number;
|
||||
|
||||
/**
|
||||
* The treshold for scrolling
|
||||
*/
|
||||
scrollTreshold: number;
|
||||
|
||||
/*
|
||||
* The css selector to query for the drag handle. (eg: '.custom-handle').
|
||||
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
|
||||
*/
|
||||
dragHandleSelector?: string;
|
||||
|
||||
/**
|
||||
* Tags to be excluded for drag handle
|
||||
*/
|
||||
excludedTags: string[];
|
||||
}
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
const modal = node.closest('[role="dialog"]');
|
||||
|
||||
if (modal && window.getComputedStyle(modal).transform !== "none") {
|
||||
const modalRect = modal.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: data.top - modalRect.top,
|
||||
left: data.left - modalRect.left,
|
||||
width: data.width,
|
||||
};
|
||||
}
|
||||
return {
|
||||
top: data.top,
|
||||
left: data.left,
|
||||
width: data.width,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
||||
return document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find(
|
||||
(elem: Element) =>
|
||||
elem.parentElement?.matches?.(".ProseMirror") ||
|
||||
elem.matches(
|
||||
[
|
||||
"li",
|
||||
"p:not(:first-child)",
|
||||
"pre",
|
||||
"blockquote",
|
||||
"h1, h2, h3, h4, h5, h6",
|
||||
].join(", ")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function nodePosAtDOM(
|
||||
node: Element,
|
||||
view: EditorView,
|
||||
options: GlobalDragHandleOptions
|
||||
) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 50 + options.dragHandleWidth,
|
||||
top: boundingRect.top + 1,
|
||||
})?.inside;
|
||||
}
|
||||
|
||||
function calcNodePos(pos: number, view: EditorView) {
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
if ($pos.depth > 1) return $pos.before($pos.depth);
|
||||
return pos;
|
||||
}
|
||||
|
||||
export function DragHandlePlugin(
|
||||
options: GlobalDragHandleOptions & { pluginKey: string }
|
||||
) {
|
||||
let listType = "";
|
||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||
view.focus();
|
||||
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
||||
draggedNodePos = calcNodePos(draggedNodePos, view);
|
||||
|
||||
const { from, to } = view.state.selection;
|
||||
const diff = from - to;
|
||||
|
||||
const fromSelectionPos = calcNodePos(from, view);
|
||||
let differentNodeSelected = false;
|
||||
|
||||
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
||||
|
||||
// Check if nodePos points to the top level node
|
||||
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
|
||||
else {
|
||||
const nodeSelection = NodeSelection.create(
|
||||
view.state.doc,
|
||||
nodePos.before()
|
||||
);
|
||||
|
||||
// Check if the node where the drag event started is part of the current selection
|
||||
differentNodeSelected = !(
|
||||
draggedNodePos + 1 >= nodeSelection.$from.pos &&
|
||||
draggedNodePos <= nodeSelection.$to.pos
|
||||
);
|
||||
}
|
||||
let selection = view.state.selection;
|
||||
if (
|
||||
!differentNodeSelected &&
|
||||
diff !== 0 &&
|
||||
!(view.state.selection instanceof NodeSelection)
|
||||
) {
|
||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
||||
selection = TextSelection.create(
|
||||
view.state.doc,
|
||||
draggedNodePos,
|
||||
endSelection.$to.pos
|
||||
);
|
||||
} else {
|
||||
selection = NodeSelection.create(view.state.doc, draggedNodePos);
|
||||
|
||||
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
|
||||
// if table row is selected, go to the parent node to select the whole node
|
||||
if (
|
||||
(selection as NodeSelection).node.type.isInline ||
|
||||
(selection as NodeSelection).node.type.name === "tableRow"
|
||||
) {
|
||||
let $pos = view.state.doc.resolve(selection.from);
|
||||
selection = NodeSelection.create(view.state.doc, $pos.before());
|
||||
}
|
||||
}
|
||||
view.dispatch(view.state.tr.setSelection(selection));
|
||||
|
||||
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem"
|
||||
) {
|
||||
listType = node.parentElement!.tagName;
|
||||
}
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = __serializeForClipboard(view, slice);
|
||||
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData("text/html", dom.innerHTML);
|
||||
event.dataTransfer.setData("text/plain", text);
|
||||
event.dataTransfer.effectAllowed = "copyMove";
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
|
||||
function hideDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.add("hide");
|
||||
}
|
||||
}
|
||||
|
||||
function showDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.remove("hide");
|
||||
}
|
||||
}
|
||||
|
||||
function hideHandleOnEditorOut(event: MouseEvent) {
|
||||
if (event.target instanceof Element) {
|
||||
const isInsideEditor = !!event.target.closest(".tiptap.ProseMirror");
|
||||
const isHandle =
|
||||
!!event.target.attributes.getNamedItem("data-drag-handle");
|
||||
if (isInsideEditor || isHandle) return;
|
||||
}
|
||||
hideDragHandle();
|
||||
}
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey(options.pluginKey),
|
||||
view: (view) => {
|
||||
const handleBySelector = options.dragHandleSelector
|
||||
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
||||
: null;
|
||||
dragHandleElement = handleBySelector ?? document.createElement("div");
|
||||
dragHandleElement.draggable = true;
|
||||
dragHandleElement.dataset.dragHandle = "";
|
||||
dragHandleElement.classList.add("drag-handle");
|
||||
|
||||
function onDragHandleDragStart(e: DragEvent) {
|
||||
handleDragStart(e, view);
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener("dragstart", onDragHandleDragStart);
|
||||
|
||||
function onDragHandleDrag(e: DragEvent) {
|
||||
hideDragHandle();
|
||||
let scrollY = window.scrollY;
|
||||
if (e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
|
||||
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY + 30, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener("drag", onDragHandleDrag);
|
||||
|
||||
hideDragHandle();
|
||||
|
||||
if (!handleBySelector) {
|
||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||
}
|
||||
view?.dom?.parentElement?.parentElement?.addEventListener(
|
||||
"mouseleave",
|
||||
hideHandleOnEditorOut
|
||||
);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
if (!handleBySelector) {
|
||||
dragHandleElement?.remove?.();
|
||||
}
|
||||
dragHandleElement?.removeEventListener("drag", onDragHandleDrag);
|
||||
dragHandleElement?.removeEventListener(
|
||||
"dragstart",
|
||||
onDragHandleDragStart
|
||||
);
|
||||
dragHandleElement = null;
|
||||
view?.dom?.parentElement?.parentElement?.removeEventListener(
|
||||
"mouseleave",
|
||||
hideHandleOnEditorOut
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
if (!view.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const notDragging = node?.closest(".not-draggable");
|
||||
const excludedTagList = options.excludedTags
|
||||
.concat(["ol", "ul"])
|
||||
.join(", ");
|
||||
|
||||
if (
|
||||
!(node instanceof Element) ||
|
||||
node.matches(excludedTagList) ||
|
||||
notDragging
|
||||
) {
|
||||
hideDragHandle();
|
||||
return;
|
||||
}
|
||||
|
||||
const compStyle = window.getComputedStyle(node);
|
||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
||||
const lineHeight = isNaN(parsedLineHeight)
|
||||
? parseInt(compStyle.fontSize) * 1.2
|
||||
: parsedLineHeight;
|
||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||
|
||||
const rect = absoluteRect(node);
|
||||
|
||||
rect.top += (lineHeight - 24) / 2;
|
||||
rect.top += paddingTop;
|
||||
// Li markers
|
||||
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
|
||||
rect.left -= options.dragHandleWidth;
|
||||
}
|
||||
rect.width = options.dragHandleWidth;
|
||||
|
||||
if (!dragHandleElement) return;
|
||||
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||
dragHandleElement.style.top = `${rect.top}px`;
|
||||
showDragHandle();
|
||||
},
|
||||
keydown: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
mousewheel: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
// dragging class is used for CSS
|
||||
dragstart: (view) => {
|
||||
view.dom.classList.add("dragging");
|
||||
},
|
||||
drop: (view, event) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
hideDragHandle();
|
||||
let droppedNode: Node | null = null;
|
||||
const dropPos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (!dropPos) return;
|
||||
|
||||
if (view.state.selection instanceof NodeSelection) {
|
||||
droppedNode = view.state.selection.node;
|
||||
}
|
||||
if (!droppedNode) return;
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||
|
||||
const isDroppedInsideList =
|
||||
resolvedPos.parent.type.name === "listItem";
|
||||
|
||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem" &&
|
||||
!isDroppedInsideList &&
|
||||
listType == "OL"
|
||||
) {
|
||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(
|
||||
null,
|
||||
droppedNode
|
||||
);
|
||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
},
|
||||
dragend: (view) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const GlobalDragHandle = Extension.create({
|
||||
name: "globalDragHandle",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
dragHandleWidth: 20,
|
||||
scrollTreshold: 100,
|
||||
excludedTags: [],
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
DragHandlePlugin({
|
||||
pluginKey: "globalDragHandle",
|
||||
dragHandleWidth: this.options.dragHandleWidth,
|
||||
scrollTreshold: this.options.scrollTreshold,
|
||||
dragHandleSelector: this.options.dragHandleSelector,
|
||||
excludedTags: this.options.excludedTags,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default GlobalDragHandle;
|
@@ -10,7 +10,7 @@ import { Color } from "@tiptap/extension-color";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||
import GlobalDragHandle from "./dragHandle";
|
||||
import { ButtonExtension } from "./ButtonExtension";
|
||||
import { SlashCommand, getSlashCommandSuggestions } from "./SlashCommand";
|
||||
import { VariableExtension } from "./VariableExtension";
|
||||
|
@@ -64,8 +64,8 @@ const config = {
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"accordion-down": "accordion-down 0.4s ease-out",
|
||||
"accordion-up": "accordion-up 0.4s ease-out",
|
||||
},
|
||||
// fontFamily: {
|
||||
// sans: ["var(--font-geist-sans)"],
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { cn } from "./lib/utils";
|
||||
|
||||
export { cn };
|
||||
export { ThemeProvider } from "next-themes";
|
||||
export { ThemeProvider, useTheme } from "next-themes";
|
||||
|
@@ -30,6 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
@@ -44,12 +45,14 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^1.0.0",
|
||||
"framer-motion": "^11.0.24",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.359.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"pnpm": "^8.15.5",
|
||||
"react-hook-form": "^7.51.3",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"recharts": "^2.12.5",
|
||||
"sonner": "^1.4.41",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
55
packages/ui/src/accordion.tsx
Normal file
55
packages/ui/src/accordion.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item ref={ref} className={cn("", className)} {...props} />
|
||||
));
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
365
packages/ui/src/charts.tsx
Normal file
365
packages/ui/src/charts.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "Chart";
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([_, config]) => config.theme || config.color
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltip";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus-visible:ring-primary/10 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
@@ -25,7 +25,7 @@ export const TextWithCopyButton: React.FC<{
|
||||
<div className={className}>{value}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`hover:bg-transparent p-0 cursor-pointer text-muted-foreground ${
|
||||
className={`hover:bg-transparent p-0 h-6 cursor-pointer text-muted-foreground ${
|
||||
alwaysShowCopy ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
onClick={copyToClipboard}
|
||||
|
@@ -26,14 +26,20 @@
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214 1% 71%;
|
||||
--border: 220 14% 96%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -64,6 +70,12 @@
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 217.2 32.6% 17.5%;
|
||||
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
|
193
pnpm-lock.yaml
generated
193
pnpm-lock.yaml
generated
@@ -194,6 +194,12 @@ importers:
|
||||
date-fns:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
emoji-picker-react:
|
||||
specifier: ^4.12.0
|
||||
version: 4.12.0(react@18.2.0)
|
||||
framer-motion:
|
||||
specifier: ^11.0.24
|
||||
version: 11.0.24(react-dom@18.2.0)(react@18.2.0)
|
||||
hono:
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2
|
||||
@@ -524,6 +530,9 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^3.3.4
|
||||
version: 3.3.4(react-hook-form@7.51.3)
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.0.5
|
||||
version: 1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
|
||||
@@ -566,6 +575,9 @@ importers:
|
||||
cmdk:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
|
||||
framer-motion:
|
||||
specifier: ^11.0.24
|
||||
version: 11.0.24(react-dom@18.2.0)(react@18.2.0)
|
||||
input-otp:
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4(react-dom@18.2.0)(react@18.2.0)
|
||||
@@ -584,6 +596,9 @@ importers:
|
||||
react-syntax-highlighter:
|
||||
specifier: ^15.5.0
|
||||
version: 15.5.0(react@18.2.0)
|
||||
recharts:
|
||||
specifier: ^2.12.5
|
||||
version: 2.12.5(react-dom@18.2.0)(react@18.2.0)
|
||||
sonner:
|
||||
specifier: ^1.4.41
|
||||
version: 1.4.41(react-dom@18.2.0)(react@18.2.0)
|
||||
@@ -4399,6 +4414,34 @@ packages:
|
||||
resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==}
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-accordion@1.2.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-HJOzSX8dQqtsp/3jVxCU3CXEONF7/2jlGAB28oX8TTw1Dz8JYbEI1UcL8355PuLBE41/IRRMvCw7VkiK/jcUOQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.0
|
||||
'@radix-ui/react-collapsible': 1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-collection': 1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.1.0(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-direction': 1.1.0(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-id': 1.1.0(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.66)(react@18.2.0)
|
||||
'@types/react': 18.2.66
|
||||
'@types/react-dom': 18.2.22
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-arrow@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==}
|
||||
peerDependencies:
|
||||
@@ -4487,6 +4530,33 @@ packages:
|
||||
react-dom: 18.2.0(react@18.3.1)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-collapsible@1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.0
|
||||
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.1.0(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-id': 1.1.0(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-presence': 1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.66)(react@18.2.0)
|
||||
'@types/react': 18.2.66
|
||||
'@types/react-dom': 18.2.22
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==}
|
||||
peerDependencies:
|
||||
@@ -4534,6 +4604,29 @@ packages:
|
||||
react-dom: 18.2.0(react@18.3.1)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-collection@1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.1.0(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-slot': 1.1.0(@types/react@18.2.66)(react@18.2.0)
|
||||
'@types/react': 18.2.66
|
||||
'@types/react-dom': 18.2.22
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.66)(react@18.2.0):
|
||||
resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==}
|
||||
peerDependencies:
|
||||
@@ -4678,6 +4771,19 @@ packages:
|
||||
react: 18.3.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-direction@1.1.0(@types/react@18.2.66)(react@18.2.0):
|
||||
resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 18.2.66
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==}
|
||||
peerDependencies:
|
||||
@@ -9831,6 +9937,16 @@ packages:
|
||||
minimalistic-crypto-utils: 1.0.1
|
||||
dev: false
|
||||
|
||||
/emoji-picker-react@4.12.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-q2c8UcZH0eRIMj41bj0k1akTjk69tsu+E7EzkW7giN66iltF6H9LQvQvw6ugscsxdC+1lmt3WZpQkkY65J95tg==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
react: '>=16'
|
||||
dependencies:
|
||||
flairup: 1.0.0
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
@@ -10140,7 +10256,7 @@ packages:
|
||||
eslint: 8.57.0
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0)
|
||||
eslint-plugin-import: 2.29.1(eslint@8.57.0)
|
||||
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
|
||||
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
|
||||
eslint-plugin-react: 7.34.0(eslint@8.57.0)
|
||||
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
|
||||
@@ -10198,7 +10314,7 @@ packages:
|
||||
enhanced-resolve: 5.16.0
|
||||
eslint: 8.57.0
|
||||
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
|
||||
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0)
|
||||
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
|
||||
fast-glob: 3.3.2
|
||||
get-tsconfig: 4.7.3
|
||||
is-core-module: 2.13.1
|
||||
@@ -10332,6 +10448,41 @@ packages:
|
||||
ignore: 5.3.1
|
||||
dev: true
|
||||
|
||||
/eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
|
||||
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
|
||||
engines: {node: '>=4'}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': '*'
|
||||
eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
|
||||
peerDependenciesMeta:
|
||||
'@typescript-eslint/parser':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.4.2)
|
||||
array-includes: 3.1.7
|
||||
array.prototype.findlastindex: 1.2.4
|
||||
array.prototype.flat: 1.3.2
|
||||
array.prototype.flatmap: 1.3.2
|
||||
debug: 3.2.7
|
||||
doctrine: 2.1.0
|
||||
eslint: 8.57.0
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.13.1
|
||||
is-glob: 4.0.3
|
||||
minimatch: 3.1.2
|
||||
object.fromentries: 2.0.7
|
||||
object.groupby: 1.0.2
|
||||
object.values: 1.1.7
|
||||
semver: 6.3.1
|
||||
tsconfig-paths: 3.15.0
|
||||
transitivePeerDependencies:
|
||||
- eslint-import-resolver-typescript
|
||||
- eslint-import-resolver-webpack
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0):
|
||||
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -10367,40 +10518,6 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/eslint-plugin-import@2.29.1(eslint@8.57.0):
|
||||
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
|
||||
engines: {node: '>=4'}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': '*'
|
||||
eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
|
||||
peerDependenciesMeta:
|
||||
'@typescript-eslint/parser':
|
||||
optional: true
|
||||
dependencies:
|
||||
array-includes: 3.1.7
|
||||
array.prototype.findlastindex: 1.2.4
|
||||
array.prototype.flat: 1.3.2
|
||||
array.prototype.flatmap: 1.3.2
|
||||
debug: 3.2.7
|
||||
doctrine: 2.1.0
|
||||
eslint: 8.57.0
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.13.1
|
||||
is-glob: 4.0.3
|
||||
minimatch: 3.1.2
|
||||
object.fromentries: 2.0.7
|
||||
object.groupby: 1.0.2
|
||||
object.values: 1.1.7
|
||||
semver: 6.3.1
|
||||
tsconfig-paths: 3.15.0
|
||||
transitivePeerDependencies:
|
||||
- eslint-import-resolver-typescript
|
||||
- eslint-import-resolver-webpack
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint@8.57.0)(typescript@5.4.2):
|
||||
resolution: {integrity: sha512-QIT7FH7fNmd9n4se7FFKHbsLKGQiw885Ds6Y/sxKgCZ6natwCsXdgPOADnYVxN2QrRweF0FZWbJ6S7Rsn7llug==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
@@ -11000,6 +11117,10 @@ packages:
|
||||
locate-path: 6.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
/flairup@1.0.0:
|
||||
resolution: {integrity: sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==}
|
||||
dev: false
|
||||
|
||||
/flat-cache@3.2.0:
|
||||
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
|
||||
engines: {node: ^10.12.0 || >=12.0.0}
|
||||
|
Reference in New Issue
Block a user