rename folder

This commit is contained in:
ppc
2024-08-21 21:24:01 +01:00
parent 78bdac74fb
commit e59527c206
58 changed files with 8 additions and 37 deletions
+29
View File
@@ -0,0 +1,29 @@
import React from "react";
import TimeAgo from "react-timeago";
import {CreditCard, History} from "lucide-react";
import {AimeCard} from "@/lib/database";
import {useMediaQuery} from "@/hooks/use-media-query.tsx";
import {AMCard, AMCardActionProps, AMCardDetail} from "@/components/am-card.tsx";
export function AimeCardEntry(props: AMCardActionProps & { card: AimeCard, children?: Readonly<React.ReactNode> }) {
const isDesktop = useMediaQuery("(min-width: 512px)");
return (
<AMCard title={props.card.name} actionIcon={props.actionIcon} actionClicked={props.actionClicked} actionDisabled={props.actionDisabled}>
{props.card.lastUsed > 0 && (
<AMCardDetail>
<History className="h-5 w-5"/>
<TimeAgo date={props.card.lastUsed}/>
</AMCardDetail>
)}
<AMCardDetail>
<CreditCard className="h-5 w-5 cursor-pointer" onClick={() => navigator.clipboard.writeText(props.card.id)}/>
<code>{isDesktop
? props.card.id.match(/.{1,4}/g)?.join(" ")
: `•••• ${props.card.id.substring(props.card.id.length - 4)}`}
</code>
</AMCardDetail>
</AMCard>
);
}
+41
View File
@@ -0,0 +1,41 @@
import React from "react";
import {ChevronRight} from "lucide-react";
import {Card} from "@/components/ui/card.tsx";
import {Button} from "@/components/ui/button.tsx";
export interface AMCardActionProps {
actionIcon?: Readonly<React.ReactNode>,
actionClicked?: () => void,
actionDisabled?: boolean,
}
export function AMCard(props: AMCardActionProps & { title: string, titlePerformsAction?: boolean, children: Readonly<React.ReactNode> }) {
return (
<Card>
<div className={`${props.actionClicked ? 'grid grid-cols-[1fr_40px] items-center gap-1' : ''} p-4 pr-2 sm:p-6`}>
<div className="max-w-full overflow-hidden space-y-3">
<h4 className="text-2xl font-semibold select-none text-ellipsis whitespace-nowrap overflow-hidden"
onClick={() => props.titlePerformsAction && props.actionClicked ? props.actionClicked() : null}>
{props.title}
</h4>
<div className="text-sm lg:text-md flex flex-auto flex-wrap gap-3">
{props.children}
</div>
</div>
{props.actionClicked && (
<Button className="flex-shrink-0" variant="ghost" size="icon" disabled={props.actionDisabled} onClick={props.actionClicked}>
{props.actionIcon ?? <ChevronRight className="h-4 w-4"/>}
</Button>
)}
</div>
</Card>
)
}
export function AMCardDetail(props: { children?: Readonly<React.ReactNode> }) {
return (
<div className="inline-flex items-center max-w-full gap-2 text-gray-500 select-none md:select-text">
{props.children}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
export function AMFooter(props: {children?: Readonly<React.ReactNode>, className?: string}) {
return (<footer className={props.className ?? "flex sm:justify-end justify-center"}>
<span className="text-gray-500 text-sm text-center">&copy; ppc {new Date().getUTCFullYear()}</span>
{props.children}
</footer>);
}
+14
View File
@@ -0,0 +1,14 @@
import React from "react";
import {Separator} from "@/components/ui/separator.tsx";
export function AMHeader(props: { title: string, children?: Readonly<React.ReactNode> }) {
return (
<>
<div className="flex items-center gap-1">
<h2 className="text-4xl font-bold select-none flex-grow">{props.title}</h2>
{props.children}
</div>
<Separator/>
</>
);
}
@@ -0,0 +1,182 @@
import {z} from "zod";
import React, {useEffect} from "react";
import {Check, Plus, X} from "lucide-react";
import {FormProvider, useForm} from "react-hook-form";
import {useMediaQuery} from "@/hooks/use-media-query";
import {zodResolver} from "@hookform/resolvers/zod";
import {AimeCard, CARD_LIMIT, db} from "@/lib/database";
import {generateAimeCardId, validateAimeCardId} from "@/lib/aime-card";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger
} from "@/components/ui/drawer";
import {useLiveQuery} from "dexie-react-hooks";
import {cn} from "@/lib/utils.ts";
import {useLocation} from "react-router-dom";
const schema = z.object({
name: z.string().min(1, {
message: "A name/description must be provided"
}),
id: z.string().refine(validateAimeCardId, {
message: "Card id must be 20 digits and not start with the number 3"
})
});
function AimeCardCreationForm(props: { callback?: () => void }) {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
id: '',
name: ''
}
});
async function onSubmit(values: z.infer<typeof schema>) {
const card: AimeCard = {
lastUsed: 0,
name: values.name,
id: values.id.replaceAll(" ", "")
};
const isDuplicate = await db.cards.where("id").equals(card.id).limit(1).count();
if (!isDuplicate) {
await db.cards.add(card);
} else {
// todo toast
}
if (props.callback) {
props.callback();
}
}
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField name="name" control={form.control}
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Card #1" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}/>
<FormField name="id" control={form.control}
render={({field}) => (
<FormItem>
<FormLabel>Card Number</FormLabel>
<FormControl>
<Input placeholder="0000 0000 0000 0000 0000" {...field} />
</FormControl>
<FormDescription>
If you&apos;ve got a card, enter the matrix code here. If not, you can
<span className="font-bold hover:cursor-pointer ml-1"
onClick={() => form.setValue("id", generateAimeCardId())}>
generate a new one.
</span>
</FormDescription>
<FormMessage/>
</FormItem>
)}/>
<Button type="submit" className="w-full">
<Check className="w-4 h-4 mr-2"/> Submit
</Button>
</form>
</FormProvider>
);
}
export function CardCreationDialog(props: { className?: string }) {
const location = useLocation();
const [open, setOpen] = React.useState(false);
const cardCount = useLiveQuery(() => db.cards.count());
const isDesktop = useMediaQuery("(min-width: 768px)");
useEffect(() => {
if (location.pathname === "/cards/add") {
setOpen(true);
}
}, [location]);
const disableCreation = (cardCount !== undefined && cardCount >= CARD_LIMIT) ?? false;
const creationComponent = (
<Button className={cn('bg-blue-500', props.className)}>
<Plus className="mr-2 h-4 w-4"/> <span>{disableCreation ? "Card limit reached" : "Add Card"}</span>
</Button>
);
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild disabled={disableCreation}>
{creationComponent}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add new card</DialogTitle>
</DialogHeader>
<AimeCardCreationForm callback={() => setOpen(false)}/>
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild disabled={disableCreation}>
{creationComponent}
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>Add card</DrawerTitle>
<DrawerDescription>
Enter the card details below, and press submit to add it to your profile.
</DrawerDescription>
</DrawerHeader>
<div className="px-4">
<AimeCardCreationForm callback={() => setOpen(false)}/>
</div>
<DrawerFooter className="pt-2">
<DrawerClose asChild>
<Button variant="outline">
<X className="w-4 h-4 mr-2"/> Cancel
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
@@ -0,0 +1,22 @@
import {useEffect, useState} from "react";
import {ShieldEllipsis} from "lucide-react";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
export function HttpsMixedModeRequestWarning() {
const [showWarning, setShowWarning] = useState(false);
useEffect(() => {
setShowWarning(location.protocol === "https:");
}, []);
return (showWarning && (
<Alert variant="destructive">
<ShieldEllipsis className="h-4 w-4"/>
<AlertTitle>HTTPS Session Detected</AlertTitle>
<AlertDescription>
Using HTTPS will disable any servers using HTTP due to mixed-mode requests being blocked on most browsers by default.
Consider using the HTTP version of the site or enabling insecure/mixed-mode requests on this site.
</AlertDescription>
</Alert>
));
}
@@ -0,0 +1,32 @@
import {SaveOff} from "lucide-react";
import {useEffect, useState} from "react";
import detectIncognito from "detectincognitojs";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
export function IncognitoStorageWarning() {
const [showWarning, setShowWarning] = useState(false);
useEffect(() => {
const check = async () => {
try {
const {isPrivate} = await detectIncognito();
setShowWarning(isPrivate);
} catch {
setShowWarning(false);
}
};
check().catch(console.error);
}, []);
return (showWarning && (
<Alert variant="destructive">
<SaveOff className="h-4 w-4"/>
<AlertTitle>Private Session Detected</AlertTitle>
<AlertDescription>
You&apos;re currently using a private browsing session. When you close the tab, all data (including
cards) will be lost. Switch to a regular browsing session to ensure your data is saved.
</AlertDescription>
</Alert>
));
}
@@ -0,0 +1,10 @@
import {Loader} from "lucide-react";
export function LoadingIndicator() {
return (
<div className="flex flex-grow gap-2 justify-center items-center text-gray-500 py-5">
<Loader className="h-8 w-8"/>
<span className="text-center">Loading...</span>
</div>
);
}
@@ -0,0 +1,8 @@
import {CreditCard} from "lucide-react";
export function NoCardsNotification() {
return (<div className="flex flex-col gap-2 items-center text-gray-500 pt-6 pb-5">
<CreditCard className="h-8 w-8"/>
<span className="text-center">No cards found</span>
</div>);
}
@@ -0,0 +1,73 @@
import {createContext, useContext, useEffect, useState} from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}
@@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+59
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",
blank: "[&>svg]:text-inherit"
},
},
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 }
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,198 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 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",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 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",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex 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
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex 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">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+116
View File
@@ -0,0 +1,116 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+176
View File
@@ -0,0 +1,176 @@
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,
}
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+127
View File
@@ -0,0 +1,127 @@
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,
}
+33
View File
@@ -0,0 +1,33 @@
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>
)
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+192
View File
@@ -0,0 +1,192 @@
// 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 }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
export function useMediaQuery(query: string) {
const [value, setValue] = React.useState(false)
React.useEffect(() => {
function onChange(event: MediaQueryListEvent) {
setValue(event.matches)
}
const result = matchMedia(query)
result.addEventListener("change", onChange)
setValue(result.matches)
return () => result.removeEventListener("change", onChange)
}, [query])
return value
}
+69
View File
@@ -0,0 +1,69 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+32
View File
@@ -0,0 +1,32 @@
export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i;
/**
* Generates a random Aime card id.
*/
export function generateAimeCardId(): string {
let cardId = '';
while (true) {
// don't start with a 3
const firstDigit = Math.floor(Math.random() * 10);
if (firstDigit !== 3) {
cardId = firstDigit.toString();
break;
}
}
for (let i = 0; i < 19; i++) {
cardId += Math.floor(Math.random() * 10).toString();
}
return cardId;
}
/**
* Validates the provided card id by known parameters
* @param id The card id to validate
*/
export function validateAimeCardId(id: string): boolean {
return id[0] !== '3' && CARD_ID_REGEX.test(id);
}
+32
View File
@@ -0,0 +1,32 @@
export const AMNET_API_VERSION = 1;
export interface AimeServerInfo {
serverName: string;
apiVersion: number;
}
export interface AimeCardinRequest {
cardId: string;
}
export async function getServerInfo(address: string) {
const response = await fetch(address, {
signal: AbortSignal.timeout(10000)
});
const json: AimeServerInfo = await response.json();
return json;
}
export function serverCardIn(address: string, id: string) {
return fetch(`${address}/card`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
cardId: id
} satisfies AimeCardinRequest),
signal: AbortSignal.timeout(5000)
});
}
+30
View File
@@ -0,0 +1,30 @@
import Dexie, { type EntityTable } from 'dexie';
import {AimeServerInfo} from "@/lib/aime-net.ts";
export const CARD_LIMIT = 15;
interface AimeServer extends AimeServerInfo {
id: number;
address: string;
lastConnected: number;
}
interface AimeCard {
id: string;
name: string;
lastUsed: number;
}
const db = new Dexie('aimedb') as Dexie & {
servers: EntityTable<AimeServer, 'id'>;
cards: EntityTable<AimeCard, 'id'>;
};
db.version(1).stores({
servers: '++id, address',
cards: 'id'
});
export type {AimeServer, AimeCard};
export {db};
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+42
View File
@@ -0,0 +1,42 @@
import './index.css'
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import {createHashRouter, RouterProvider} from "react-router-dom"
import {Toaster} from "@/components/ui/toaster.tsx";
import {ThemeProvider} from "@/components/theme-provider.tsx";
import CardManager from "@/pages/card-manager.tsx"
import ServerCardIn from "@/pages/card-in-screen.tsx"
import ServerListing from "@/pages/server-listing.tsx"
// iOS PWAs show the navigation bar when changing routes
// HashRouter doesn't have this issue as the URL never changes
const router = createHashRouter([
...['/', '/servers', '/servers/add'].map(x => {
return {
path: x,
element: <ServerListing/>
};
}),
...['/cards', '/cards/add'].map(x => {
return {
path: x,
element: <CardManager/>
};
}),
{
path: '/servers/:serverId',
element: <ServerCardIn/>
}
])
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
<RouterProvider router={router}/>
<Toaster/>
</ThemeProvider>
</StrictMode>,
)
+218
View File
@@ -0,0 +1,218 @@
import {useEffect, useState} from "react";
import {useNavigate, useParams} from "react-router-dom";
import {useLiveQuery} from "dexie-react-hooks";
import {CircleDot, CircleCheckBig, Home, Nfc, Wallet, WifiOff} from "lucide-react";
import {AimeCard, AimeServer, db} from "@/lib/database";
import {getServerInfo, serverCardIn} from "@/lib/aime-net";
import {Button} from "@/components/ui/button";
import {useToast} from "@/components/ui/use-toast";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from "@/components/ui/alert-dialog.tsx";
import {AMHeader} from "@/components/am-header.tsx";
import {AMFooter} from "@/components/am-footer.tsx";
import {AimeCardEntry} from "@/components/aime-card";
import {LoadingIndicator} from "@/components/loading-indicator";
import {CardCreationDialog} from "@/components/card-creation-form";
import {NoCardsNotification} from "@/components/no-cards-notification";
import {IncognitoStorageWarning} from "@/components/incognito-warning.tsx";
/* eslint-disable @typescript-eslint/no-explicit-any */
async function performCardInAction(card: AimeCard, server: AimeServer, setCardInStatus: (status: boolean) => void, toaster: any) {
setCardInStatus(true);
try {
await serverCardIn(server.address, card.id);
await db.cards.update(card.id, {lastUsed: Date.now()});
toaster({
title: "Success",
description: "Card in request has been sent successfully.",
duration: 5000
});
setTimeout(() => setCardInStatus(false), 1000);
} catch {
toaster({
variant: "destructive",
title: "Card in failed",
description: "Failed to perform card in action. Please try again.",
duration: 5000
});
setTimeout(() => setCardInStatus(false), 1000);
}
}
/* eslint-enable @typescript-eslint/no-explicit-any */
function CardList(props: { cards: ReadonlyArray<AimeCard> | null, cardSelected: (card: AimeCard) => void }) {
const navigate = useNavigate();
return (<>
<h4 className="text-2xl font-bold">{props.cards?.length ? "Cards" : "Additional Cards"}</h4>
<div className="flex gap-2">
<CardCreationDialog className="flex-grow"/>
<Button onClick={() => navigate('/cards')} variant="outline" size="icon">
<Wallet className="h-4 w-4"/>
</Button>
</div>
{!props.cards?.length
? <NoCardsNotification/>
: props.cards.map(x => <AimeCardEntry key={x.id} card={x}
actionIcon={<CircleDot className="w-5 h-5"/>}
actionClicked={() => props.cardSelected(x)}/>)}
</>);
}
function ServerCardInPage() {
const {toast} = useToast();
const {serverId} = useParams<{ serverId: string }>();
const navigate = useNavigate();
const cards = useLiveQuery(() => db.cards.toArray());
const [server, setServer] = useState<AimeServer | null>(null);
const [cardInStatus, setCardInStatus] = useState(false);
const [connectionError, setConnectionError] = useState(false);
const [selectedCard, setSelectedCard] = useState<AimeCard | null>(null);
const [serverReloadCounter, setServerReloadCounter] = useState(0);
// select newest card
useEffect(() => setSelectedCard(cards?.sort((a, b) => b.lastUsed - a.lastUsed)[0] ?? null), [cards]);
useEffect(() => {
const loadServer = async () => {
if (!serverId) {
navigate('/');
return;
}
const sid = parseInt(serverId);
if (isNaN(sid)) {
navigate('/');
return;
}
const server = await db.servers.get(sid);
if (!server) {
navigate('/');
return;
}
try {
const info = await getServerInfo(server.address);
await db.servers.update(server.id, info);
Object.assign(server, info);
setConnectionError(false);
} catch {
setConnectionError(true);
}
setServer(server);
};
loadServer().catch(console.error);
}, [serverId, serverReloadCounter]);
return (
<>
<main className="flex flex-col gap-5 container min-h-screen py-10">
<AMHeader title={server?.serverName ?? "Server"}>
<Button variant="ghost" onClick={() => navigate('/')}>
<Home className="h-4 w-4 sm:mr-2"/>
<span className="hidden sm:block">Servers</span>
</Button>
</AMHeader>
{!server && (<LoadingIndicator/>)}
{(server && connectionError)
? <Alert variant="destructive">
<WifiOff className="h-4 w-4"/>
<AlertTitle>Connection Issue</AlertTitle>
<AlertDescription>
There was an issue connecting to <code
className="max-w-full text-wrap">{server.address}</code>.
Please check the game is running and <span className="underline cursor-pointer" onClick={() => setServerReloadCounter(serverReloadCounter + 1)}>try again</span>.
</AlertDescription>
</Alert>
: <IncognitoStorageWarning/>}
{server && (
<div className="flex flex-col gap-4 w-full flex-grow">
{selectedCard && (<>
<h4 className="text-2xl font-bold">Selected Card</h4>
<AimeCardEntry card={selectedCard}>
<Button size="icon" variant="ghost" disabled>
<CircleCheckBig className="w-5 h-5 text-green-500"/>
</Button>
</AimeCardEntry>
<Button className="flex-grow-0 bg-green-600 mb-5"
disabled={(cardInStatus || connectionError || !server)}
onClick={() => performCardInAction(selectedCard, server as AimeServer, setCardInStatus, toast)}>
{cardInStatus ? "Carding in..." : <>Card In <Nfc className="ml-2 h-4 w-4"/></>}
</Button>
</>)}
<CardList cards={cards?.filter(x => x !== selectedCard).sort(x => x.lastUsed) ?? null}
cardSelected={card => {
toast({
duration: 2500,
title: "Card Selected",
description: `${card.name} has been selected.`
});
setSelectedCard(card);
}}/>
</div>
)}
<AMFooter className="flex flex-col-reverse sm:flex-row sm:flex-wrap justify-center sm:justify-between gap-2">
{server?.id && (
<AlertDialog>
<AlertDialogTrigger>
<p className="text-sm text-red-700">Delete {server.serverName}</p>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Deletion</AlertDialogTitle>
<AlertDialogDescription>
You'll need to re-enter the server information if you want to use it in the
future...
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={async () => {
await db.servers.delete(server.id);
navigate('/');
}}>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</AMFooter>
</main>
</>
);
}
export default ServerCardInPage;
+66
View File
@@ -0,0 +1,66 @@
import {Server, Trash} from "lucide-react";
import {useNavigate} from "react-router-dom";
import {useLiveQuery} from "dexie-react-hooks";
import {AimeCard, db} from "@/lib/database";
import {Button} from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger
} from "@/components/ui/context-menu";
import {AMHeader} from "@/components/am-header.tsx";
import {AMFooter} from "@/components/am-footer.tsx";
import {AimeCardEntry} from "@/components/aime-card";
import {LoadingIndicator} from "@/components/loading-indicator";
import {CardCreationDialog} from "@/components/card-creation-form";
import {IncognitoStorageWarning} from "@/components/incognito-warning";
import {NoCardsNotification} from "@/components/no-cards-notification";
function CardList(props: { cards: AimeCard[] | undefined }) {
return (<>
<IncognitoStorageWarning/>
<CardCreationDialog/>
{!props.cards?.length
? <NoCardsNotification/>
: props.cards.map(s =>
<ContextMenu key={s.id}>
<ContextMenuTrigger>
<AimeCardEntry card={s}/>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem className="text-red-500" onClick={() => db.cards.delete(s.id)}>
<Trash className="mr-2 h-4 w-4"/> Remove
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>)}
</>);
}
function CardManagerPage() {
const navigate = useNavigate();
const cards = useLiveQuery(() => db.cards.toArray());
return (
<main className="flex flex-col gap-5 container min-h-screen py-10">
<AMHeader title="Cards">
<Button variant="ghost" size={"default"} onClick={() => navigate('/')}>
<Server className="h-4 w-4 sm:mr-2"/>
<span className="hidden sm:block">Manage Servers</span>
</Button>
</AMHeader>
<div className="flex flex-col gap-4 w-full flex-grow">
{cards ? <CardList cards={cards}/> : <LoadingIndicator/>}
</div>
<AMFooter/>
</main>
);
}
export default CardManagerPage;
+249
View File
@@ -0,0 +1,249 @@
import {z} from "zod";
import TimeAgo from "react-timeago";
import React, {useEffect, useState} from "react";
import {useForm} from "react-hook-form";
import {useLocation, useNavigate} from 'react-router-dom';
import {useLiveQuery} from "dexie-react-hooks";
import {zodResolver} from "@hookform/resolvers/zod";
import {
Earth,
History,
Plus,
ShieldAlert,
SquareArrowOutUpRight,
Trash,
WalletCards
} from "lucide-react";
import {AimeServer, db} from "@/lib/database";
import {Input} from "@/components/ui/input";
import {Button} from "@/components/ui/button";
import {LoadingIndicator} from "@/components/loading-indicator";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
import {Form, FormControl, FormField, FormItem, FormMessage} from "@/components/ui/form";
import {HttpsMixedModeRequestWarning} from "@/components/https-mixed-mode-request-warning";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from "@/components/ui/context-menu";
import {AMFooter} from "@/components/am-footer";
import {AMHeader} from "@/components/am-header";
import {AMNET_API_VERSION} from "@/lib/aime-net";
import {AMCard, AMCardDetail} from "@/components/am-card";
const serverAdditionSchema = z.object({
address: z.string().startsWith("http")
});
function ServerList(props: { servers: AimeServer[] | undefined }) {
const navigate = useNavigate();
if (!props.servers) {
return (<LoadingIndicator/>);
}
if (!props.servers.length) {
return (
<>
<HttpsMixedModeRequestWarning/>
<ServerAddForm/>
<div className="flex flex-col gap-2 items-center text-gray-500 pt-6 pb-5">
<Earth className="h-8 w-8"/>
<span className="text-center">No servers found. Add one above!</span>
</div>
</>
);
}
return (<>
<HttpsMixedModeRequestWarning/>
<ServerAddForm/>
{props.servers.map(s =>
<ContextMenu key={s.id}>
<ContextMenuTrigger>
<AMCard title={s.serverName} titlePerformsAction actionClicked={() => navigate(`/servers/${s.id}`)}>
{s.lastConnected > 0 && (
<AMCardDetail>
<History className="h-5 w-5"/>
<TimeAgo date={s.lastConnected}/>
</AMCardDetail>
)}
<AMCardDetail>
<Earth className="h-5 w-5"/>
<code className="text-ellipsis whitespace-nowrap overflow-hidden">{s.address}</code>
</AMCardDetail>
</AMCard>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem inset onClick={() => navigate(`/servers/${s.id}`)}>
Select Server
</ContextMenuItem>
<ContextMenuSeparator/>
<ContextMenuItem className="text-red-500" onClick={() => db.servers.delete(s.id)}>
<Trash className="mr-2 h-4 w-4"/> Remove Server
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>)}
</>);
}
function ServerAddForm() {
const navigate = useNavigate();
const location = useLocation();
const form = useForm<z.infer<typeof serverAdditionSchema>>({
resolver: zodResolver(serverAdditionSchema),
defaultValues: {
address: ''
}
});
const [showHttpsWarning, setHttpsWarning] = useState(false);
const [blockSubmit, setBlockSubmit] = useState(true);
useEffect(() => {
if (location.pathname != "/servers/add") {
return;
}
(async () => {
const searchParams = new URLSearchParams(location.search);
let address = searchParams.get("address");
if (!address) {
return;
}
address = decodeURIComponent(address);
const server = await db.servers.filter(x => x.address === address).first();
if (server) {
navigate(`/servers/${server.id}`);
} else {
validateAddress(address);
form.setValue("address", address);
form.setFocus("address");
}
})();
}, [location, navigate, form]);
async function onSubmit(values: z.infer<typeof serverAdditionSchema>) {
const existingServer = await db.servers
.where("address")
.equalsIgnoreCase(values.address)
.limit(1)
.first();
// test for duplicate
if (!existingServer) {
const newId = await db.servers.add({
lastConnected: 0,
serverName: "Server",
address: values.address,
apiVersion: AMNET_API_VERSION
});
// set server name using id
await db.servers.update(newId, {
serverName: `Server #${newId}`
});
navigate(`/servers/${newId}`);
} else {
navigate(`/servers/${existingServer.id}`);
}
}
function handleAddressChange(event: React.ChangeEvent<HTMLInputElement>, callback: (value: React.ChangeEvent<HTMLInputElement>) => void) {
validateAddress(event.target.value);
callback(event);
}
function validateAddress(address: string) {
try {
const url = new URL(address);
setBlockSubmit(false);
setHttpsWarning(url.protocol !== "http:");
} catch {
setBlockSubmit(true);
setHttpsWarning(false);
}
}
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex gap-3 items-start">
<FormField
control={form.control}
name="address"
render={({field}) => (
<FormItem className="flex-grow">
<FormControl>
<Input {...field}
placeholder="Server Address (http://192.168.1.1:6070)"
onChange={e => handleAddressChange(e, field.onChange)}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<Button type="submit" disabled={blockSubmit} variant="outline" size="icon"
className="flex-shrink-0">
<Plus className="h-4 w-4"/>
</Button>
</form>
</Form>
{showHttpsWarning && (<>
<Alert variant="blank" className="text-orange-500 border-orange-500">
<ShieldAlert className="h-5 w-5 text-orange-500"/>
<AlertTitle>HTTPS Warning</AlertTitle>
<AlertDescription>
AMNet doesn&apos;t support HTTPS out-the-box.
If you&apos;re using HTTPS, ensure a reverse-proxy or
<a target="_blank"
className="underline ml-1"
href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/httpsys">
HTTP.SYS <SquareArrowOutUpRight className="inline h-3 w-3"/>
</a> has been configured and a valid, trusted SSL certificate has been configured.
</AlertDescription>
</Alert>
</>
)}
</>
);
}
function ServerListing() {
const navigate = useNavigate();
const servers = useLiveQuery(() => db.servers.toArray());
return (
<main className="flex flex-col gap-5 container min-h-screen py-10">
<AMHeader title="Servers">
<Button variant="ghost" size={"default"} onClick={() => navigate('/cards')}>
<WalletCards className="h-4 w-4 sm:mr-2"/>
<span className="hidden sm:block">Manage Cards</span>
</Button>
</AMHeader>
<div className="flex flex-col gap-4 w-full flex-grow">
<ServerList servers={servers}/>
</div>
<AMFooter/>
</main>
);
}
export default ServerListing;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />