Migrate the existing automations page to use React (#849)

Migrates the Automations page to React, mostly keeping the overall design consistent with organization. Use component library, with some changes in color. Add easier management with straightforward form and editing experience.
Use system preference for determining dark mode if not explicitly set.
This commit is contained in:
sabaimran 2024-07-15 09:12:33 -07:00 committed by GitHub
parent c7764c7470
commit 1c6ed9bc6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1933 additions and 24 deletions

View file

@ -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;
}
}

View file

@ -0,0 +1,11 @@
.automationsLayout {
max-width: 70vw;
margin: auto;
margin-bottom: 2rem;
}
@media screen and (max-width: 700px) {
.automationsLayout {
max-width: 90vw;
}
}

View file

@ -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 (
<div className={`${styles.automationsLayout}`}>
<NavMenu selected="Automations" showLogo={true} />
{children}
<Toaster />
</div>
);
}

View file

@ -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<AutomationsData | null>(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: (
<ToastAction altText="Dismiss">Ok</ToastAction>
),
})
setToastMessage('');
}
}, [toastMessage]);
if (isDeleted) {
return null;
}
return (
<div className='p-2 rounded-lg bg-secondary hover:shadow-md'>
<Card className='bg-secondary h-full shadow-none border-l-4 border-t-0 border-r-0 border-b-0 border-l-green-400 dark:border-green-600 rounded-none'>
<CardHeader>
<CardTitle className='line-clamp-2 leading-normal flex justify-between'>
{updatedAutomationData?.subject || automation.subject}
<Popover>
<PopoverTrigger asChild>
<Button className='bg-background' variant={'ghost'}><DotsThreeVertical className='h-4 w-4' /></Button>
</PopoverTrigger>
<PopoverContent className='w-auto grid gap-2 text-left'>
<Button variant={'destructive'}
className='justify-start'
onClick={() => {
if (props.suggestedCard) {
setIsDeleted(true);
return;
}
deleteAutomation(automation.id.toString(), setIsDeleted);
}}>
<Trash className='h-4 w-4 mr-2' />Delete
</Button>
{
!props.suggestedCard && (
<Dialog
open={isEditing}
onOpenChange={(open) => {
setIsEditing(open);
}}
>
<DialogTrigger asChild>
<Button variant="outline" className="justify-start">
<Pencil className='h-4 w-4 mr-2' />Edit
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Edit Automation</DialogTitle>
<EditCard
automation={automation}
setIsEditing={setIsEditing}
isLoggedIn={props.isLoggedIn}
setShowLoginPrompt={props.setShowLoginPrompt}
setUpdatedAutomationData={setUpdatedAutomationData}
locationData={props.locationData} />
</DialogContent>
</Dialog>
)
}
{
!props.suggestedCard && (
<Button variant={'outline'}
className="justify-start"
onClick={() => {
sendAPreview(automation.id.toString(), setToastMessage);
}}>
<Play className='h-4 w-4 mr-2' />Run Now
</Button>
)
}
</PopoverContent>
</Popover>
</CardTitle>
<CardDescription className='mt-2'>
{updatedAutomationData?.schedule || cronToHumanReadableString(automation.crontime)}
</CardDescription>
</CardHeader>
<CardContent>
{updatedAutomationData?.query_to_run || automation.query_to_run}
</CardContent>
<CardFooter className="flex justify-end gap-2">
{
props.suggestedCard && props.setNewAutomationData && (
<Dialog
open={isEditing}
onOpenChange={(open) => {
setIsEditing(open);
}}
>
<DialogTrigger asChild>
<Button variant="outline">
<Plus className='h-4 w-4 mr-2' />
Add
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Add Automation</DialogTitle>
<EditCard
createNew={true}
automation={automation}
setIsEditing={setIsEditing}
isLoggedIn={props.isLoggedIn}
setShowLoginPrompt={props.setShowLoginPrompt}
setUpdatedAutomationData={props.setNewAutomationData}
locationData={props.locationData} />
</DialogContent>
</Dialog>
)
}
<ShareLink
buttonTitle="Share"
includeIcon={true}
buttonVariant={'outline' as keyof typeof buttonVariants}
title="Share Automation"
description="Copy the link below and share it with your coworkers or friends."
url={createShareLink(automation)}
onShare={() => {
navigator.clipboard.writeText(createShareLink(automation));
}} />
</CardFooter>
</Card>
</div>
)
}
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 (
<Dialog
open={isCreating}
onOpenChange={(open) => {
setIsCreating(open);
}}
>
<DialogTrigger>
</DialogTrigger>
<DialogContent>
<DialogTitle>Create Automation</DialogTitle>
<EditCard
createNew={true}
setIsEditing={setIsCreating}
setUpdatedAutomationData={props.setNewAutomationData}
isLoggedIn={props.isLoggedIn}
setShowLoginPrompt={props.setShowLoginPrompt}
automation={automation}
locationData={props.locationData} />
</DialogContent>
</Dialog>
)
}
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<z.infer<typeof EditAutomationSchema>>({
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<typeof EditAutomationSchema>) => {
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 += `&region=${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 (
<AutomationModificationForm form={form} onSubmit={onSubmit} create={props.createNew} isLoggedIn={props.isLoggedIn} setShowLoginPrompt={props.setShowLoginPrompt} />
)
}
interface AutomationModificationFormProps {
form: UseFormReturn<z.infer<typeof EditAutomationSchema>>;
onSubmit: (values: z.infer<typeof EditAutomationSchema>) => 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<HTMLButtonElement>) => void) {
return (
<Button
className='text-xs bg-slate-50 h-auto p-1.5 m-1 rounded-full'
variant="ghost"
key={recommendationText}
onClick={(event) => {
event.preventDefault();
onChange({ target: { value: recommendationText } }, event);
}}>
{recommendationText}...
</Button>
)
}
const recommendationPills = [
"Make a picture of",
"Generate a summary of",
"Create a newsletter of",
"Notify me when"
];
return (
<Form {...props.form}>
<form onSubmit={props.form.handleSubmit((values) => {
props.onSubmit(values);
setIsSaving(true);
})} className="space-y-8">
{
!props.create && (
<FormField
control={props.form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>Subject</FormLabel>
<FormDescription>
This is the subject of the email you will receive.
</FormDescription>
<FormControl>
<Input placeholder="Digest of Healthcare AI trends" {...field} />
</FormControl>
<FormMessage />
{errors.subject && <FormMessage>{errors.subject?.message}</FormMessage>}
</FormItem>
)}
/>)
}
<FormField
control={props.form.control}
name="everyBlah"
render={({ field }) => (
<FormItem
className='w-full'
>
<FormLabel>Frequency</FormLabel>
<FormDescription>
How frequently should this automation run?
</FormDescription>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className='w-[200px]'>
Every <SelectValue placeholder="" />
</SelectTrigger>
</FormControl>
<SelectContent>
{frequencies.map((frequency) => (
<SelectItem key={frequency} value={frequency}>
{frequency}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
{errors.subject && <FormMessage>{errors.everyBlah?.message}</FormMessage>}
</FormItem>
)}
/>
{
props.form.watch('everyBlah') === 'Week' && (
<FormField
control={props.form.control}
name="dayOfWeek"
render={({ field }) => (
<FormItem
className='w-full'>
<FormLabel>Day of Week</FormLabel>
<Select onValueChange={field.onChange} defaultValue={String(field.value)}>
<FormControl>
<SelectTrigger className='w-[200px]'>
On <SelectValue placeholder="" />
</SelectTrigger>
</FormControl>
<SelectContent>
{
weekDays.map((day, index) => (
<SelectItem key={day} value={String(index)}>
{day}
</SelectItem>
))
}
</SelectContent>
</Select>
<FormMessage />
{errors.subject && <FormMessage>{errors.dayOfWeek?.message}</FormMessage>}
</FormItem>
)}
/>
)
}
{
props.form.watch('everyBlah') === 'Month' && (
<FormField
control={props.form.control}
name="dayOfMonth"
render={({ field }) => (
<FormItem
className='w-full'>
<FormLabel>Day of Month</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className='w-[200px]'>
On the <SelectValue placeholder="" />
</SelectTrigger>
</FormControl>
<SelectContent>
{
daysOfMonth.map((day) => (
<SelectItem key={day} value={day}>
{day}
</SelectItem>
))
}
</SelectContent>
</Select>
<FormMessage />
{errors.subject && <FormMessage>{errors.dayOfMonth?.message}</FormMessage>}
</FormItem>
)}
/>
)
}
{
(
props.form.watch('everyBlah') === 'Day' ||
props.form.watch('everyBlah') == 'Week' ||
props.form.watch('everyBlah') == 'Month') && (
<FormField
control={props.form.control}
name="timeRecurrence"
render={({ field }) => (
<FormItem
className='w-full'>
<FormLabel>Time</FormLabel>
<FormDescription>
On the days this automation runs, at what time should it run?
</FormDescription>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className='w-[200px]'>
At <SelectValue placeholder="" />
</SelectTrigger>
</FormControl>
<SelectContent>
{
timeOptions.map((timeOption) => (
<SelectItem key={timeOption} value={timeOption}>
{timeOption}
</SelectItem>
))
}
</SelectContent>
</Select>
<FormMessage />
{errors.subject && <FormMessage>{errors.timeRecurrence?.message}</FormMessage>}
</FormItem>
)}
/>
)
}
<FormField
control={props.form.control}
name="queryToRun"
render={({ field }) => (
<FormItem>
<FormLabel>Instructions</FormLabel>
<FormDescription>
What do you want Khoj to do?
</FormDescription>
{
props.create && (
<div>
{
recommendationPills.map((recommendation) => recommendationPill(recommendation, field.onChange))
}
</div>
)
}
<FormControl>
<Textarea placeholder="Create a summary of the latest news about AI in healthcare." value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
{errors.subject && <FormMessage>{errors.queryToRun?.message}</FormMessage>}
</FormItem>
)}
/>
<fieldset disabled={isSaving}>
{
props.isLoggedIn ? (
isSaving ? (
<Button
type="submit"
disabled
>
Saving...
</Button>
) : (
<Button type="submit">Save</Button>
)
) : (
<Button
onClick={(event) => {
event.preventDefault();
props.setShowLoginPrompt(true);
}}
variant={'default'}>
Login to Save
</Button>
)
}
</fieldset>
</form>
</Form>
)
}
export default function Automations() {
const authenticatedData = useAuthenticatedData();
const { data: personalAutomations, error, isLoading } = useSWR<AutomationsData[]>(authenticatedData ? 'automations' : null, automationsFetcher, { revalidateOnFocus: false });
const [isCreating, setIsCreating] = useState(false);
const [newAutomationData, setNewAutomationData] = useState<AutomationsData | null>(null);
const [allNewAutomations, setAllNewAutomations] = useState<AutomationsData[]>([]);
const [suggestedAutomations, setSuggestedAutomations] = useState<AutomationsData[]>([]);
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 <div>Failed to load</div>;
if (isLoading) return <Loading />;
return (
<div>
<h3
className='text-xl py-4'>
Automations
</h3>
{
showLoginPrompt && (
<LoginPrompt
onOpenChange={setShowLoginPrompt}
loginRedirectMessage={"Create an account to make your own automation"} />
)
}
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>How this works!</AlertTitle>
<AlertDescription>
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.
<div className='mt-3' />
{
authenticatedData ? (
<span className='rounded-full text-sm bg-blue-200 dark:bg-blue-600 p-2 m-1' ><Envelope className='h-4 w-4 mr-2 inline' />{authenticatedData.email}</span>
)
: (
<span> Sign in to create your own automations.</span>
)
}
{
ipLocationData && (
<span className='rounded-full text-sm bg-purple-200 dark:bg-purple-600 p-2 m-1' ><MapPinSimple className='h-4 w-4 mr-2 inline' />{ipLocationData ? `${ipLocationData.city}, ${ipLocationData.country}` : 'Unknown'}</span>
)
}
{
ipLocationData && (
<span className='rounded-full text-sm bg-green-200 dark:bg-green-600 p-2 m-1' ><Clock className='h-4 w-4 mr-2 inline' />{ipLocationData ? `${ipLocationData.timezone}` : 'Unknown'}</span>
)
}
</AlertDescription>
</Alert>
<h3
className="text-xl py-4">
Your Creations
</h3>
<Suspense>
<SharedAutomationCard
locationData={ipLocationData}
isLoggedIn={authenticatedData ? true : false}
setShowLoginPrompt={setShowLoginPrompt}
setNewAutomationData={setNewAutomationData} />
</Suspense>
{
authenticatedData ? (
<Dialog
open={isCreating}
onOpenChange={(open) => {
setIsCreating(open);
}}
>
<DialogTrigger asChild className='fixed bottom-4 right-4'>
<Button variant="default">Create New</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Create Automation</DialogTitle>
<EditCard
createNew={true}
setIsEditing={setIsCreating}
isLoggedIn={authenticatedData ? true : false}
setShowLoginPrompt={setShowLoginPrompt}
setUpdatedAutomationData={setNewAutomationData}
locationData={ipLocationData} />
</DialogContent>
</Dialog>
)
: (
<Button
onClick={() => setShowLoginPrompt(true)}
className='fixed bottom-4 right-4' variant={'default'}>
Create New
</Button>
)
}
{
((!personalAutomations || personalAutomations.length === 0) && (allNewAutomations.length == 0)) && (
<div>
So empty! Create your own automation to get started.
<div className='mt-4'>
{
authenticatedData ? (
<Dialog
open={isCreating}
onOpenChange={(open) => {
setIsCreating(open);
}}
>
<DialogTrigger asChild>
<Button variant="default">Design</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Create Automation</DialogTitle>
<EditCard
createNew={true}
isLoggedIn={authenticatedData ? true : false}
setShowLoginPrompt={setShowLoginPrompt}
setIsEditing={setIsCreating}
setUpdatedAutomationData={setNewAutomationData}
locationData={ipLocationData} />
</DialogContent>
</Dialog>
)
: (
<Button
onClick={() => setShowLoginPrompt(true)}
variant={'default'}>
Design
</Button>
)
}
</div>
</div>
)
}
<div
className={`${styles.automationsLayout}`}>
{
personalAutomations && personalAutomations.map((automation) => (
<AutomationsCard
key={automation.id}
automation={automation}
locationData={ipLocationData}
isLoggedIn={authenticatedData ? true : false}
setShowLoginPrompt={setShowLoginPrompt} />
))}
{
allNewAutomations.map((automation) => (
<AutomationsCard key={automation.id}
automation={automation}
locationData={ipLocationData}
isLoggedIn={authenticatedData ? true : false}
setShowLoginPrompt={setShowLoginPrompt} />
))
}
</div>
<h3
className="text-xl py-4">
Try these out
</h3>
<div
className={`${styles.automationsLayout}`}>
{
suggestedAutomations.map((automation) => (
<AutomationsCard
setNewAutomationData={setNewAutomationData}
key={automation.id}
automation={automation}
locationData={ipLocationData}
isLoggedIn={authenticatedData ? true : false}
setShowLoginPrompt={setShowLoginPrompt}
suggestedCard={true} />
))
}
</div>
</div>
);
}

View file

@ -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<LocationData>("/api/ip", locationFetcher, { revalidateOnFocus: false });
if (locationDataError) return null;
if (!locationData) return null;
return locationData;
}

View file

@ -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<string>(props.title || props.selected.toUpperCase());
const [displayTitle, setDisplayTitle] = useState<string | undefined>(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) {
<div className={styles.titleBar}>
<div className={`text-nowrap text-ellipsis overflow-hidden max-w-screen-md grid items-top font-bold mr-8`}>
{displayTitle && <h2 className={`text-lg text-ellipsis whitespace-nowrap overflow-x-hidden`} >{displayTitle}</h2>}
{
!displayTitle && props.showLogo &&
<Link href='/'>
<Image
src="/khoj-logo.svg"
alt="Khoj"
width={52}
height={52} />
</Link>
}
</div>
{
isMobileWidth ?

View file

@ -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 (
<Dialog>
<DialogTrigger
className="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 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
asChild
onClick={props.onShare}>
<Button size="sm" className={`px-3`} variant={props.buttonVariant ?? 'default' as const}>
{
props.includeIcon && (
<Share className="w-4 h-4 mr-2" />
)
}
{props.buttonTitle}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
<DialogDescription>
{props.description}
</DialogDescription>
<DialogTitle>{props.title}</DialogTitle>
<DialogDescription>
{props.description}
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2">
<div className="grid flex-1 gap-2">
<Label htmlFor="link" className="sr-only">
Link
</Label>
<Input
id="link"
defaultValue={props.url}
readOnly
/>
<div className="flex items-center space-x-2">
<div className="grid flex-1 gap-2">
<Label htmlFor="link" className="sr-only">
Link
</Label>
<Input
id="link"
defaultValue={props.url}
readOnly
/>
</div>
<Button type="submit" size="sm" className="px-3" onClick={() => copyToClipboard(props.url)}>
<span>Copy</span>
</Button>
</div>
<Button type="submit" size="sm" className="px-3" onClick={() => copyToClipboard(props.url)}>
<span>Copy</span>
</Button>
</div>
</DialogContent>
</Dialog>
);

View file

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View file

@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View file

@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 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 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View file

@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View file

@ -0,0 +1,35 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View file

@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View file

@ -18,6 +18,7 @@
"prepare": "husky"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-avatar": "^1.1.0",
@ -30,7 +31,9 @@
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0",
"@tailwindcss/line-clamp": "^0.4.4",
"@types/dompurify": "^3.0.5",
@ -40,6 +43,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"cronstrue": "^2.50.0",
"dompurify": "^3.1.6",
"katex": "^0.16.10",
"lucide-react": "^0.397.0",
@ -49,12 +53,14 @@
"postcss": "^8.4.38",
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.52.1",
"shadcn-ui": "^0.8.0",
"swr": "^2.2.5",
"tailwind-merge": "^2.3.0",
"tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.1"
"vaul": "^0.9.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20",

View file

@ -345,6 +345,11 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.3.tgz#506fcc73f730affd093044cb2956c31ba6431545"
integrity sha512-XGndio0l5/Gvd6CLIABvsav9HHezgDFFhDfHk1bvLfr9ni8dojqLSvBbotJEjmIwNHL7vK4QzBJTdBRoB+c1ww==
"@hookform/resolvers@^3.9.0":
version "3.9.0"
resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.9.0.tgz#cf540ac21c6c0cd24a40cf53d8e6d64391fb753d"
integrity sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==
"@humanwhocodes/config-array@^0.11.14":
version "0.11.14"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b"
@ -913,6 +918,33 @@
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-select@^2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-2.1.1.tgz#df05cb0b29d3deaef83b505917c4042e0e418a9f"
integrity sha512-8iRDfyLtzxlprOo9IicnzvpsO1wNCkuwzzCM+Z5Rb5tNOpCdMvcc2AkzX0Fz+Tz9v6NJ5B/7EEgyZveo4FBRfQ==
dependencies:
"@radix-ui/number" "1.1.0"
"@radix-ui/primitive" "1.1.0"
"@radix-ui/react-collection" "1.1.0"
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-context" "1.1.0"
"@radix-ui/react-direction" "1.1.0"
"@radix-ui/react-dismissable-layer" "1.1.0"
"@radix-ui/react-focus-guards" "1.1.0"
"@radix-ui/react-focus-scope" "1.1.0"
"@radix-ui/react-id" "1.1.0"
"@radix-ui/react-popper" "1.2.0"
"@radix-ui/react-portal" "1.1.1"
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-slot" "1.1.0"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-controllable-state" "1.1.0"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-use-previous" "1.1.0"
"@radix-ui/react-visually-hidden" "1.1.0"
aria-hidden "^1.1.1"
react-remove-scroll "2.5.7"
"@radix-ui/react-slot@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
@ -928,6 +960,24 @@
dependencies:
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-toast@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.2.1.tgz#4bde231ed27d007dcd0455a446565ca619f92a2d"
integrity sha512-5trl7piMXcZiCq7MW6r8YYmu0bK5qDpTWz+FdEPdKyft2UixkspheYbjbrLXVN5NGKHFbOP7lm8eD0biiSqZqg==
dependencies:
"@radix-ui/primitive" "1.1.0"
"@radix-ui/react-collection" "1.1.0"
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-context" "1.1.0"
"@radix-ui/react-dismissable-layer" "1.1.0"
"@radix-ui/react-portal" "1.1.1"
"@radix-ui/react-presence" "1.1.0"
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-controllable-state" "1.1.0"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-visually-hidden" "1.1.0"
"@radix-ui/react-toggle@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz#1f7697b82917019330a16c6f96f649f46b4606cf"
@ -1691,6 +1741,11 @@ cosmiconfig@^8.1.3:
parse-json "^5.2.0"
path-type "^4.0.0"
cronstrue@^2.50.0:
version "2.50.0"
resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.50.0.tgz#eabba0f915f186765258b707b7a3950c663b5573"
integrity sha512-ULYhWIonJzlScCCQrPUG5uMXzXxSixty4djud9SS37DoNxDdkeRocxzHuAo4ImRBUK+mAuU5X9TSwEDccnnuPg==
cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@ -3761,6 +3816,11 @@ react-dom@^18:
loose-envify "^1.1.0"
scheduler "^0.23.2"
react-hook-form@^7.52.1:
version "7.52.1"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.52.1.tgz#ec2c96437b977f8b89ae2d541a70736c66284852"
integrity sha512-uNKIhaoICJ5KQALYZ4TOaOLElyM+xipord+Ha3crEFhTntdLvWZqVY49Wqd/0GiVCA/f9NjemLeiNPjG7Hpurg==
react-is@^16.13.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@ -4668,7 +4728,7 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zod@^3.20.2:
zod@^3.20.2, zod@^3.23.8:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==