mirror of
https://github.com/khoj-ai/khoj.git
synced 2025-02-17 08:04:21 +00:00
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:
parent
c7764c7470
commit
1c6ed9bc6d
15 changed files with 1933 additions and 24 deletions
11
src/interface/web/app/automations/automations.module.css
Normal file
11
src/interface/web/app/automations/automations.module.css
Normal 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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
.automationsLayout {
|
||||
max-width: 70vw;
|
||||
margin: auto;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
.automationsLayout {
|
||||
max-width: 90vw;
|
||||
}
|
||||
}
|
28
src/interface/web/app/automations/layout.tsx
Normal file
28
src/interface/web/app/automations/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
983
src/interface/web/app/automations/page.tsx
Normal file
983
src/interface/web/app/automations/page.tsx
Normal 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 += `®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 (
|
||||
<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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 ?
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
59
src/interface/web/components/ui/alert.tsx
Normal file
59
src/interface/web/components/ui/alert.tsx
Normal 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 }
|
178
src/interface/web/components/ui/form.tsx
Normal file
178
src/interface/web/components/ui/form.tsx
Normal 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,
|
||||
}
|
160
src/interface/web/components/ui/select.tsx
Normal file
160
src/interface/web/components/ui/select.tsx
Normal 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,
|
||||
}
|
129
src/interface/web/components/ui/toast.tsx
Normal file
129
src/interface/web/components/ui/toast.tsx
Normal 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,
|
||||
}
|
35
src/interface/web/components/ui/toaster.tsx
Normal file
35
src/interface/web/components/ui/toaster.tsx
Normal 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>
|
||||
)
|
||||
}
|
194
src/interface/web/components/ui/use-toast.ts
Normal file
194
src/interface/web/components/ui/use-toast.ts
Normal 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 }
|
|
@ -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",
|
||||
|
|
|
@ -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==
|
||||
|
|
Loading…
Add table
Reference in a new issue