diff --git a/src/interface/web/app/automations/automations.module.css b/src/interface/web/app/automations/automations.module.css
new file mode 100644
index 00000000..206d4c36
--- /dev/null
+++ b/src/interface/web/app/automations/automations.module.css
@@ -0,0 +1,11 @@
+div.automationsLayout {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+
+@media screen and (max-width: 768px) {
+ div.automationsLayout {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/interface/web/app/automations/automationsLayout.module.css b/src/interface/web/app/automations/automationsLayout.module.css
new file mode 100644
index 00000000..6e27ee7a
--- /dev/null
+++ b/src/interface/web/app/automations/automationsLayout.module.css
@@ -0,0 +1,11 @@
+.automationsLayout {
+ max-width: 70vw;
+ margin: auto;
+ margin-bottom: 2rem;
+}
+
+@media screen and (max-width: 700px) {
+ .automationsLayout {
+ max-width: 90vw;
+ }
+}
diff --git a/src/interface/web/app/automations/layout.tsx b/src/interface/web/app/automations/layout.tsx
new file mode 100644
index 00000000..23493c75
--- /dev/null
+++ b/src/interface/web/app/automations/layout.tsx
@@ -0,0 +1,28 @@
+
+import type { Metadata } from "next";
+import NavMenu from '../components/navMenu/navMenu';
+import styles from './automationsLayout.module.css';
+import { Toaster } from "@/components/ui/toaster";
+
+
+export const metadata: Metadata = {
+ title: "Khoj AI - Automations",
+ description: "Use Autoomations with Khoj to simplify the process of running repetitive tasks.",
+ icons: {
+ icon: '/static/favicon.ico',
+ },
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/interface/web/app/automations/page.tsx b/src/interface/web/app/automations/page.tsx
new file mode 100644
index 00000000..8c20918e
--- /dev/null
+++ b/src/interface/web/app/automations/page.tsx
@@ -0,0 +1,983 @@
+'use client'
+
+import useSWR from 'swr';
+import Loading, { InlineLoading } from '../components/loading/loading';
+import {
+ Card,
+ CardDescription,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle
+
+} from '@/components/ui/card';
+import { Button, buttonVariants } from '@/components/ui/button';
+
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+
+interface AutomationsData {
+ id: number;
+ subject: string;
+ query_to_run: string;
+ scheduling_request: string;
+ schedule: string;
+ crontime: string;
+ next: string;
+}
+
+import cronstrue from 'cronstrue';
+import { zodResolver } from "@hookform/resolvers/zod"
+import { UseFormReturn, useForm } from "react-hook-form"
+import { z } from "zod"
+import { Suspense, useEffect, useState } from 'react';
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog';
+import { DialogTitle } from '@radix-ui/react-dialog';
+import { Textarea } from '@/components/ui/textarea';
+import { LocationData, useIPLocationData } from '../common/utils';
+
+import styles from './automations.module.css';
+import ShareLink from '../components/shareLink/shareLink';
+import { useSearchParams } from 'next/navigation';
+import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
+import { Clock, DotsThreeVertical, Envelope, Info, MapPinSimple, Pencil, Play, Plus, Trash } from '@phosphor-icons/react';
+import { useAuthenticatedData } from '../common/auth';
+import LoginPrompt from '../components/loginPrompt/loginPrompt';
+import { useToast } from '@/components/ui/use-toast';
+import { ToastAction } from '@/components/ui/toast';
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+
+const automationsFetcher = () => window.fetch('/api/automations').then(res => res.json()).catch(err => console.log(err));
+
+// Standard cron format: minute hour dayOfMonth month dayOfWeek
+
+function getEveryBlahFromCron(cron: string) {
+ const cronParts = cron.split(' ');
+ const dayOfMonth = cronParts[2];
+ const dayOfWeek = cronParts[4];
+
+ // If both dayOfMonth and dayOfWeek are '*', it runs every day
+ if (dayOfMonth === '*' && dayOfWeek === '*') {
+ return 'Day';
+ }
+ // If dayOfWeek is not '*', it suggests a specific day of the week, implying a weekly schedule
+ else if (dayOfWeek !== '*') {
+ return 'Week';
+ }
+ // If dayOfMonth is not '*', it suggests a specific day of the month, implying a monthly schedule
+ else if (dayOfMonth !== '*') {
+ return 'Month';
+ }
+ // Default to 'Day' if none of the above conditions are met
+ else {
+ return 'Day';
+ }
+}
+
+function getDayOfWeekFromCron(cron: string) {
+ const cronParts = cron.split(' ');
+ if (cronParts[3] === '*' && cronParts[4] !== '*') {
+ return Number(cronParts[4]);
+ }
+
+ return undefined;
+}
+
+function getTimeRecurrenceFromCron(cron: string) {
+ const cronParts = cron.split(' ');
+ const hour = cronParts[1];
+ const minute = cronParts[0];
+ const period = Number(hour) >= 12 ? 'PM' : 'AM';
+
+ let friendlyHour = Number(hour) > 12 ? Number(hour) - 12 : hour;
+ if (friendlyHour === '00') {
+ friendlyHour = '12';
+ }
+
+ let friendlyMinute = minute;
+ if (Number(friendlyMinute) < 10 && friendlyMinute !== '00') {
+ friendlyMinute = `0${friendlyMinute}`;
+ }
+ return `${friendlyHour}:${friendlyMinute} ${period}`;
+}
+
+function getDayOfMonthFromCron(cron: string) {
+ const cronParts = cron.split(' ');
+
+ return String(cronParts[2]);
+}
+
+function cronToHumanReadableString(cron: string) {
+ return cronstrue.toString(cron);
+}
+
+const frequencies = ['Day', 'Week', 'Month'];
+
+const daysOfMonth = Array.from({ length: 31 }, (_, i) => String(i + 1));
+
+const weekDays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+
+const timeOptions: string[] = [];
+
+const timePeriods = ['AM', 'PM'];
+
+// Populate the time selector with options for each hour of the day
+for (var i = 0; i < timePeriods.length; i++) {
+ for (var hour = 0; hour < 12; hour++) {
+ for (var minute = 0; minute < 60; minute += 15) {
+ // Ensure all minutes are two digits
+ const paddedMinute = String(minute).padStart(2, '0');
+ const friendlyHour = hour === 0 ? 12 : hour;
+ timeOptions.push(`${friendlyHour}:${paddedMinute} ${timePeriods[i]}`);
+ }
+ }
+}
+
+const timestamp = Date.now();
+
+const suggestedAutomationsMetadata: AutomationsData[] = [
+ {
+ "subject": "Weekly Newsletter",
+ "query_to_run": "Compile a message including: 1. A recap of news from last week 2. An at-home workout I can do before work 3. A quote to inspire me for the week ahead",
+ "schedule": "9AM every Monday",
+ "next": "Next run at 9AM on Monday",
+ "crontime": "0 9 * * 1",
+ "id": timestamp,
+ "scheduling_request": "",
+ },
+ {
+ "subject": "Daily Bedtime Story",
+ "query_to_run": "Compose a bedtime story that a five-year-old might enjoy. It should not exceed five paragraphs. Appeal to the imagination, but weave in learnings.",
+ "schedule": "9PM every night",
+ "next": "Next run at 9PM today",
+ "crontime": "0 21 * * *",
+ "id": timestamp + 1,
+ "scheduling_request": "",
+ },
+ {
+ "subject": "Front Page of Hacker News",
+ "query_to_run": "Summarize the top 5 posts from https://news.ycombinator.com/best and share them with me, including links",
+ "schedule": "9PM on every Wednesday",
+ "next": "Next run at 9PM on Wednesday",
+ "crontime": "0 21 * * 3",
+ "id": timestamp + 2,
+ "scheduling_request": "",
+ },
+ {
+ "subject": "Market Summary",
+ "query_to_run": "Get the market summary for today and share it with me. Focus on tech stocks and the S&P 500.",
+ "schedule": "9AM on every weekday",
+ "next": "Next run at 9AM on Monday",
+ "crontime": "0 9 * * *",
+ "id": timestamp + 3,
+ "scheduling_request": "",
+ }
+];
+
+function createShareLink(automation: AutomationsData) {
+ const encodedSubject = encodeURIComponent(automation.subject);
+ const encodedQuery = encodeURIComponent(automation.query_to_run);
+ const encodedCrontime = encodeURIComponent(automation.crontime);
+
+ const shareLink = `${window.location.origin}/automations?subject=${encodedSubject}&query=${encodedQuery}&crontime=${encodedCrontime}`;
+
+ return shareLink;
+}
+
+function deleteAutomation(automationId: string, setIsDeleted: (isDeleted: boolean) => void) {
+ fetch(`/api/automation?automation_id=${automationId}`, { method: 'DELETE' }
+ ).then(response => response.json())
+ .then(data => {
+ setIsDeleted(true);
+ });
+}
+
+function sendAPreview(automationId: string, setToastMessage: (toastMessage: string) => void) {
+ fetch(`/api/trigger/automation?automation_id=${automationId}`, { method: 'POST' })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Network response was not ok');
+ }
+ return response;
+ })
+ .then(automations => {
+ setToastMessage("Automation triggered. Check your inbox in a few minutes!");
+ })
+ .catch(error => {
+ setToastMessage("Sorry, something went wrong. Try again later.");
+ })
+}
+
+interface AutomationsCardProps {
+ automation: AutomationsData;
+ locationData?: LocationData | null;
+ suggestedCard?: boolean;
+ setNewAutomationData?: (data: AutomationsData) => void;
+ isLoggedIn: boolean;
+ setShowLoginPrompt: (showLoginPrompt: boolean) => void;
+}
+
+
+function AutomationsCard(props: AutomationsCardProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [updatedAutomationData, setUpdatedAutomationData] = useState(null);
+ const [isDeleted, setIsDeleted] = useState(false);
+ const [toastMessage, setToastMessage] = useState('');
+ const { toast } = useToast();
+
+ const automation = props.automation;
+
+ useEffect(() => {
+ const toastTitle = `Automation: ${updatedAutomationData?.subject || automation.subject}`;
+ if (toastMessage) {
+ toast({
+ title: toastTitle,
+ description: toastMessage,
+ action: (
+ Ok
+ ),
+ })
+ setToastMessage('');
+ }
+ }, [toastMessage]);
+
+ if (isDeleted) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {updatedAutomationData?.subject || automation.subject}
+
+
+
+
+
+
+ {
+ !props.suggestedCard && (
+
+ )
+ }
+ {
+ !props.suggestedCard && (
+
+ )
+ }
+
+
+
+
+
+ {updatedAutomationData?.schedule || cronToHumanReadableString(automation.crontime)}
+
+
+
+ {updatedAutomationData?.query_to_run || automation.query_to_run}
+
+
+ {
+ props.suggestedCard && props.setNewAutomationData && (
+
+ )
+ }
+ {
+ navigator.clipboard.writeText(createShareLink(automation));
+ }} />
+
+
+
+ )
+}
+
+interface SharedAutomationCardProps {
+ locationData?: LocationData | null;
+ setNewAutomationData: (data: AutomationsData) => void;
+ isLoggedIn: boolean;
+ setShowLoginPrompt: (showLoginPrompt: boolean) => void;
+}
+
+function SharedAutomationCard(props: SharedAutomationCardProps) {
+ const searchParams = useSearchParams();
+ const [isCreating, setIsCreating] = useState(true);
+
+ const subject = searchParams.get('subject');
+ const query = searchParams.get('query');
+ const crontime = searchParams.get('crontime');
+
+ if (!subject || !query || !crontime) {
+ return null;
+ }
+
+ const automation: AutomationsData = {
+ id: 0,
+ subject: decodeURIComponent(subject),
+ query_to_run: decodeURIComponent(query),
+ scheduling_request: '',
+ schedule: cronToHumanReadableString(decodeURIComponent(crontime)),
+ crontime: decodeURIComponent(crontime),
+ next: '',
+ }
+
+ return (
+
+ )
+}
+
+const EditAutomationSchema = z.object({
+ subject: z.optional(z.string()),
+ everyBlah: z.string({ required_error: "Every is required" }),
+ dayOfWeek: z.optional(z.number()),
+ dayOfMonth: z.optional(z.string()),
+ timeRecurrence: z.string({ required_error: "Time Recurrence is required" }),
+ queryToRun: z.string({ required_error: "Query to Run is required" }),
+});
+
+interface EditCardProps {
+ automation?: AutomationsData;
+ setIsEditing: (completed: boolean) => void;
+ setUpdatedAutomationData: (data: AutomationsData) => void;
+ locationData?: LocationData | null;
+ createNew?: boolean;
+ isLoggedIn: boolean;
+ setShowLoginPrompt: (showLoginPrompt: boolean) => void;
+}
+
+function EditCard(props: EditCardProps) {
+ const automation = props.automation;
+
+ const form = useForm>({
+ resolver: zodResolver(EditAutomationSchema),
+ defaultValues: {
+ subject: automation?.subject,
+ everyBlah: (automation?.crontime ? getEveryBlahFromCron(automation.crontime) : 'Day'),
+ dayOfWeek: (automation?.crontime ? getDayOfWeekFromCron(automation.crontime) : undefined),
+ timeRecurrence: (automation?.crontime ? getTimeRecurrenceFromCron(automation.crontime) : '12:00 PM'),
+ dayOfMonth: (automation?.crontime ? getDayOfMonthFromCron(automation.crontime) : "1"),
+ queryToRun: automation?.query_to_run,
+ },
+ })
+
+ const onSubmit = (values: z.infer) => {
+ const cronFrequency = convertFrequencyToCron(values.everyBlah, values.timeRecurrence, values.dayOfWeek, values.dayOfMonth);
+
+ let updateQueryUrl = `/api/automation?`;
+
+ updateQueryUrl += `q=${values.queryToRun}`;
+
+ if (automation?.id && !props.createNew) {
+ updateQueryUrl += `&automation_id=${automation.id}`;
+ }
+
+ if (values.subject) {
+ updateQueryUrl += `&subject=${values.subject}`;
+ }
+
+ updateQueryUrl += `&crontime=${cronFrequency}`;
+
+ if (props.locationData) {
+ updateQueryUrl += `&city=${props.locationData.city}`;
+ updateQueryUrl += `®ion=${props.locationData.region}`;
+ updateQueryUrl += `&country=${props.locationData.country}`;
+ updateQueryUrl += `&timezone=${props.locationData.timezone}`;
+ }
+
+ let method = props.createNew ? 'POST' : 'PUT';
+
+ fetch(updateQueryUrl, { method: method })
+ .then(response => response.json())
+ .then
+ ((data: AutomationsData) => {
+ props.setIsEditing(false);
+ props.setUpdatedAutomationData({
+ id: data.id,
+ subject: data.subject || '',
+ query_to_run: data.query_to_run,
+ scheduling_request: data.scheduling_request,
+ schedule: cronToHumanReadableString(data.crontime),
+ crontime: data.crontime,
+ next: data.next,
+ });
+ });
+ }
+
+ function convertFrequencyToCron(frequency: string, timeRecurrence: string, dayOfWeek?: number, dayOfMonth?: string) {
+ let cronString = '';
+
+ const minutes = timeRecurrence.split(':')[1].split(' ')[0];
+ const period = timeRecurrence.split(':')[1].split(' ')[1];
+ const rawHourAsNumber = Number(timeRecurrence.split(':')[0]);
+ const hours = period === 'PM' && (rawHourAsNumber < 12) ? String(rawHourAsNumber + 12) : rawHourAsNumber;
+
+ const dayOfWeekNumber = dayOfWeek ? dayOfWeek : '*';
+
+ switch (frequency) {
+ case 'Day':
+ cronString = `${minutes} ${hours} * * *`;
+ break;
+ case 'Week':
+ cronString = `${minutes} ${hours} * * ${dayOfWeekNumber}`;
+ break;
+ case 'Month':
+ cronString = `${minutes} ${hours} ${dayOfMonth} * *`;
+ break;
+ }
+
+ return cronString;
+ }
+
+ return (
+
+ )
+
+}
+
+interface AutomationModificationFormProps {
+ form: UseFormReturn>;
+ onSubmit: (values: z.infer) => void;
+ create?: boolean;
+ isLoggedIn: boolean;
+ setShowLoginPrompt: (showLoginPrompt: boolean) => void;
+}
+
+function AutomationModificationForm(props: AutomationModificationFormProps) {
+
+ const [isSaving, setIsSaving] = useState(false);
+ const { errors } = props.form.formState;
+
+ console.log(errors);
+
+ function recommendationPill(recommendationText: string, onChange: (value: any, event: React.MouseEvent) => void) {
+ return (
+
+ )
+ }
+
+ const recommendationPills = [
+ "Make a picture of",
+ "Generate a summary of",
+ "Create a newsletter of",
+ "Notify me when"
+ ];
+
+ return (
+
+
+ )
+}
+
+
+export default function Automations() {
+ const authenticatedData = useAuthenticatedData();
+ const { data: personalAutomations, error, isLoading } = useSWR(authenticatedData ? 'automations' : null, automationsFetcher, { revalidateOnFocus: false });
+
+ const [isCreating, setIsCreating] = useState(false);
+ const [newAutomationData, setNewAutomationData] = useState(null);
+ const [allNewAutomations, setAllNewAutomations] = useState([]);
+ const [suggestedAutomations, setSuggestedAutomations] = useState([]);
+ const [showLoginPrompt, setShowLoginPrompt] = useState(false);
+
+ const ipLocationData = useIPLocationData();
+
+ useEffect(() => {
+ if (newAutomationData) {
+ setAllNewAutomations([...allNewAutomations, newAutomationData]);
+ setNewAutomationData(null);
+ }
+ }, [newAutomationData]);
+
+ useEffect(() => {
+
+ const allAutomations = personalAutomations ? personalAutomations.concat(allNewAutomations) : allNewAutomations;
+
+ if (allAutomations) {
+ setSuggestedAutomations(suggestedAutomationsMetadata.filter((suggestedAutomation) => {
+ return allAutomations.find(
+ (automation) => suggestedAutomation.subject === automation.subject) === undefined;
+ }));
+ }
+ }, [personalAutomations, allNewAutomations]);
+
+ if (error) return Failed to load
;
+
+ if (isLoading) return ;
+
+ return (
+
+
+ Automations
+
+ {
+ showLoginPrompt && (
+
+ )
+ }
+
+
+ How this works!
+
+ Automations help you structure your time by automating tasks you do regularly. Build your own, or try out our presets. Get results straight to your inbox.
+
+ {
+ authenticatedData ? (
+ {authenticatedData.email}
+ )
+ : (
+ Sign in to create your own automations.
+ )
+ }
+ {
+ ipLocationData && (
+ {ipLocationData ? `${ipLocationData.city}, ${ipLocationData.country}` : 'Unknown'}
+ )
+ }
+ {
+ ipLocationData && (
+ {ipLocationData ? `${ipLocationData.timezone}` : 'Unknown'}
+
+ )
+ }
+
+
+
+ Your Creations
+
+
+
+
+ {
+ authenticatedData ? (
+
+ )
+ : (
+
+ )
+ }
+ {
+ ((!personalAutomations || personalAutomations.length === 0) && (allNewAutomations.length == 0)) && (
+
+ So empty! Create your own automation to get started.
+
+ {
+ authenticatedData ? (
+
+ )
+ : (
+
+ )
+ }
+
+
+ )
+ }
+
+ {
+ personalAutomations && personalAutomations.map((automation) => (
+
+ ))}
+ {
+ allNewAutomations.map((automation) => (
+
+ ))
+ }
+
+
+ Try these out
+
+
+ {
+ suggestedAutomations.map((automation) => (
+
+ ))
+ }
+
+
+ );
+}
diff --git a/src/interface/web/app/common/utils.ts b/src/interface/web/app/common/utils.ts
index 5cc9d767..6dde9d4f 100644
--- a/src/interface/web/app/common/utils.ts
+++ b/src/interface/web/app/common/utils.ts
@@ -1,3 +1,18 @@
+import useSWR from "swr";
+
+export interface LocationData {
+ ip: string;
+ city: string;
+ region: string;
+ country: string;
+ postal: string;
+ latitude: number;
+ longitude: number;
+ timezone: string;
+}
+
+const locationFetcher = () => window.fetch("https://ipapi.co/json").then((res) => res.json()).catch((err) => console.log(err));
+
export function welcomeConsole() {
console.log(`%c %s`, "font-family:monospace", `
__ __ __ __ ______ __ _____ __
@@ -15,3 +30,12 @@ export function welcomeConsole() {
Read my operating manual at https://docs.khoj.dev
`);
}
+
+export function useIPLocationData() {
+ const {data: locationData, error: locationDataError } = useSWR("/api/ip", locationFetcher, { revalidateOnFocus: false });
+
+ if (locationDataError) return null;
+ if (!locationData) return null;
+
+ return locationData;
+}
diff --git a/src/interface/web/app/components/navMenu/navMenu.tsx b/src/interface/web/app/components/navMenu/navMenu.tsx
index 1c7d8b65..2409db3e 100644
--- a/src/interface/web/app/components/navMenu/navMenu.tsx
+++ b/src/interface/web/app/components/navMenu/navMenu.tsx
@@ -24,6 +24,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Toggle } from '@/components/ui/toggle';
import { Moon } from '@phosphor-icons/react';
+import Image from 'next/image';
interface NavMenuProps {
@@ -35,18 +36,25 @@ interface NavMenuProps {
export default function NavMenu(props: NavMenuProps) {
const userData = useAuthenticatedData();
- const [displayTitle, setDisplayTitle] = useState(props.title || props.selected.toUpperCase());
+ const [displayTitle, setDisplayTitle] = useState(props.title);
const [isMobileWidth, setIsMobileWidth] = useState(false);
const [darkMode, setDarkMode] = useState(false);
useEffect(() => {
setIsMobileWidth(window.innerWidth < 768);
- setDisplayTitle(props.title || props.selected.toUpperCase());
+ if (props.title) {
+ setDisplayTitle(props.title);
+ }
}, [props.title]);
useEffect(() => {
+
+ const mq = window.matchMedia(
+ "(prefers-color-scheme: dark)"
+ );
+
window.addEventListener('resize', () => {
setIsMobileWidth(window.innerWidth < 768);
});
@@ -54,6 +62,9 @@ export default function NavMenu(props: NavMenuProps) {
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.classList.add('dark');
setDarkMode(true);
+ } else if (mq.matches) {
+ document.documentElement.classList.add('dark');
+ setDarkMode(true);
}
}, []);
@@ -74,6 +85,16 @@ export default function NavMenu(props: NavMenuProps) {
{displayTitle &&
{displayTitle}
}
+ {
+ !displayTitle && props.showLogo &&
+
+
+
+ }
{
isMobileWidth ?
diff --git a/src/interface/web/app/components/shareLink/shareLink.tsx b/src/interface/web/app/components/shareLink/shareLink.tsx
index bb006479..ff92fada 100644
--- a/src/interface/web/app/components/shareLink/shareLink.tsx
+++ b/src/interface/web/app/components/shareLink/shareLink.tsx
@@ -7,9 +7,10 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
+import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { Share } from "@phosphor-icons/react";
interface ShareLinkProps {
buttonTitle: string;
@@ -17,6 +18,8 @@ interface ShareLinkProps {
description: string;
url: string;
onShare: () => void;
+ buttonVariant?: keyof typeof buttonVariants;
+ includeIcon?: boolean;
}
function copyToClipboard(text: string) {
@@ -31,32 +34,39 @@ export default function ShareLink(props: ShareLinkProps) {
return (