mirror of
https://github.com/ClipFusion-org/clipfusion.git
synced 2025-08-03 16:55:08 +00:00
ui: implemented project renaming and deleting
This commit is contained in:
parent
7be0181034
commit
5c6aebb5c5
49
package-lock.json
generated
49
package-lock.json
generated
@ -15,12 +15,14 @@
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@react-hook/hotkey": "^3.1.0",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.0.11",
|
||||
@ -1434,6 +1436,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz",
|
||||
"integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.10",
|
||||
"@radix-ui/react-focus-guards": "1.1.2",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.7",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.4",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
|
||||
@ -1912,6 +1951,15 @@
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/line-clamp": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz",
|
||||
"integrity": "sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
|
||||
@ -6820,7 +6868,6 @@
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
|
||||
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
|
@ -16,12 +16,14 @@
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@react-hook/hotkey": "^3.1.0",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.0.11",
|
||||
|
@ -45,8 +45,8 @@
|
||||
|
||||
body {
|
||||
font-family: var(--font-geist), sans-serif;
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
|
236
src/app/page.tsx
236
src/app/page.tsx
@ -5,7 +5,7 @@ import { useLiveQuery } from "dexie-react-hooks";
|
||||
import { db } from "@/lib/db";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyIcon, EllipsisIcon, InfoIcon, LetterTextIcon, ListCheckIcon, PencilIcon, PlusIcon, TextIcon, TextQuoteIcon, TrashIcon } from "lucide-react";
|
||||
import { CopyIcon, EditIcon, EllipsisIcon, InfoIcon, ListCheckIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import Search from "@/components/search";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
@ -15,59 +15,202 @@ import Project from "@/types/Project";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, UseFormReturn } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { generateUUID } from "@/lib/uuid";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
|
||||
const NewProjectFormSchema = z.object({
|
||||
const ProjectInfoFormSchema = z.object({
|
||||
title: z.string().nonempty("Title cannot be empty"),
|
||||
description: z.string().or(z.literal(""))
|
||||
});
|
||||
|
||||
const ProjectInfoForm = ({ form }: { form: UseFormReturn<z.infer<typeof ProjectInfoFormSchema>>}) => (
|
||||
<>
|
||||
<FormField control={form.control} name="title" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="description" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea autoComplete="off" placeholder="Tell something about your project" className="resize-y" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
</>
|
||||
);
|
||||
|
||||
const RenameProjectDialog = ({ project }: { project: Project }) => {
|
||||
const renameForm = useForm<z.infer<typeof ProjectInfoFormSchema>>({
|
||||
resolver: zodResolver(ProjectInfoFormSchema),
|
||||
defaultValues: {
|
||||
title: project.title,
|
||||
description: project.description
|
||||
}
|
||||
});
|
||||
|
||||
const handleRenameSubmit = async (data: z.infer<typeof ProjectInfoFormSchema>) => {
|
||||
await db.projects.update(project.uuid, {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
editDate: Date.now()
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Project</DialogTitle>
|
||||
<DialogDescription>Change the name of your project.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...renameForm}>
|
||||
<form onSubmit={renameForm.handleSubmit(handleRenameSubmit)} className="grid gap-3">
|
||||
<ProjectInfoForm form={renameForm}/>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button type="submit">Rename</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
const DeleteProjectDialog = ({ project }: { project: Project }) => {
|
||||
const handleDelete = async () => {
|
||||
await db.projects.delete(project.uuid);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Project</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the project "{project.title}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} asChild>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
const ProjectDropdown = ({ project }: { project: Project }): ReactNode => {
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [deleteAlertOpen, setDeleteAlertOpen] = useState(false);
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
const newProject = {
|
||||
...project,
|
||||
uuid: generateUUID(),
|
||||
creationDate: Date.now(),
|
||||
editDate: Date.now(),
|
||||
title: project.title.includes("Copy of") ? project.title : `Copy of ${project.title}`,
|
||||
origin: project.origin
|
||||
};
|
||||
await db.projects.add(newProject);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<EllipsisIcon/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-48">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<DropdownMenuLabel className="font-semibold">{project.title}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setRenameDialogOpen(true)}>
|
||||
<PencilIcon/> <span className="sr-only">Rename</span>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => console.log("Edit Project")}>
|
||||
<EditIcon className="mr-2"/> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDuplicate}>
|
||||
<CopyIcon className="mr-2"/> Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={() => setDeleteAlertOpen(true)}>
|
||||
<TrashIcon className="mr-2"/> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuItem onClick={() => console.log("Project Info")}>
|
||||
<InfoIcon className="mr-2"/> Info
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
|
||||
<RenameProjectDialog project={project}/>
|
||||
</Dialog>
|
||||
<AlertDialog open={deleteAlertOpen} onOpenChange={setDeleteAlertOpen}>
|
||||
<DeleteProjectDialog project={project}/>
|
||||
</AlertDialog>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectDescription = ({ project }: { project: Project }): ReactNode => {
|
||||
if (!project.description) return <></>;
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<InfoIcon/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<h3 className="font-semibold text-lg">Project Description</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.description}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const ProjectContainer = ({
|
||||
project
|
||||
}: {
|
||||
project: Project
|
||||
}): ReactNode => {
|
||||
const date = new Date(project.editDate);
|
||||
return (
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Card className="relative rounded-lg shadow-md w-full h-full overflow-hidden hover:scale-[101%] hover:drop-shadow-xl duration-100">
|
||||
<div className="absolute bottom-0 left-0 h-16 w-full bg-gradient-to-t from-black to-transparent opacity-10"/>
|
||||
<div className="absolute bottom-0 left-0 w-full h-full bg-gradient-to-t from-white dark:from-black to-transparent opacity-50"/>
|
||||
<div className="absolute bottom-0 left-0 p-2 w-full flex flex-row justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{project.title}</h3>
|
||||
{project.creationDate && <p className="text-sm text-secondary-foreground">{new Date(project.creationDate).toLocaleDateString()}</p>}
|
||||
<h3 className="text-sm sm:text-sm md:text-md lg:text-lg font-semibold line-clamp-1">{project.title}</h3>
|
||||
{project.description && <p className="text-sm text-secondary-foreground line-clamp-1">{project.description}</p>}
|
||||
{project.editDate && <p className="text-sm text-secondary-foreground">Last edit date: {date.toLocaleDateString()}, {date.toLocaleTimeString()}</p>}
|
||||
</div>
|
||||
<div className="cursor-pointer m-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisIcon/> <span className="sr-only">{project.title} additional options</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>{project.title}</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<TextIcon/> Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<PencilIcon/> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CopyIcon/> Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<TrashIcon/> Delete
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuItem>
|
||||
<InfoIcon/> Properties
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex flex-col lg:xl:flex-row items-center gap-1">
|
||||
<ProjectDescription project={project}/>
|
||||
<ProjectDropdown project={project}/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@ -83,15 +226,15 @@ export default function Home(): ReactNode {
|
||||
return db.projects.filter((project) => project.title.includes(search)).toArray();
|
||||
});
|
||||
|
||||
const newProjectForm = useForm<z.infer<typeof NewProjectFormSchema>>({
|
||||
resolver: zodResolver(NewProjectFormSchema),
|
||||
const newProjectForm = useForm<z.infer<typeof ProjectInfoFormSchema>>({
|
||||
resolver: zodResolver(ProjectInfoFormSchema),
|
||||
defaultValues: {
|
||||
title: "New ClipFusion Project",
|
||||
description: ""
|
||||
}
|
||||
});
|
||||
|
||||
const newProjectSubmit = async (data: z.infer<typeof NewProjectFormSchema>) => {
|
||||
const newProjectSubmit = async (data: z.infer<typeof ProjectInfoFormSchema>) => {
|
||||
const date = Date.now();
|
||||
await db.projects.add({
|
||||
uuid: generateUUID(),
|
||||
@ -105,7 +248,7 @@ export default function Home(): ReactNode {
|
||||
|
||||
return (
|
||||
<div className="p-5 w-full h-full">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="flex flex-row items-center gap-2 overscroll-none">
|
||||
<SidebarTrigger size="lg"/>
|
||||
<h2 className="font-bold break-keep text-xl sm:text-2xl md:text-3xl lg:text-4xl leading-none">Project Library</h2>
|
||||
{projects && <Label className="text-muted-foreground text-sm">(Found {projects.length} projects)</Label>}
|
||||
@ -129,24 +272,7 @@ export default function Home(): ReactNode {
|
||||
</DialogHeader>
|
||||
<Form {...newProjectForm}>
|
||||
<form onSubmit={newProjectForm.handleSubmit(newProjectSubmit)} className="grid gap-3">
|
||||
<FormField control={newProjectForm.control} name="title" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}/>
|
||||
<FormField control={newProjectForm.control} name="description" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Tell something about your project" className="resize-y" {...field}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}/>
|
||||
<ProjectInfoForm form={newProjectForm}/>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
|
@ -87,7 +87,7 @@ export const Dashboard = (): ReactNode => {
|
||||
<SidebarFooter>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
Link
|
||||
Links
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-3 pl-2">
|
||||
|
48
src/components/ui/popover.tsx
Normal file
48
src/components/ui/popover.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
3
src/lib/apple.ts
Normal file
3
src/lib/apple.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function isIOS(): boolean {
|
||||
return typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
}
|
5
tailwind.config.js
Normal file
5
tailwind.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require("@tailwindcss/line-clamp"),
|
||||
]
|
||||
};
|
Loading…
Reference in New Issue
Block a user