style: enhance template selection UI and add view modes toggle (#1094)

* feat: enhance template selection UI and add view modes toggle

* fix: show template tags only in detailed view mode

* refactor: set detailed

---------

Co-authored-by: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
This commit is contained in:
Vishal kadam 2025-01-13 02:04:28 +05:30 committed by GitHub
parent c0a2d2c399
commit c9308aebc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -35,6 +35,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -52,7 +53,6 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { ScrollArea } from "@radix-ui/react-scroll-area";
import { import {
BookText, BookText,
CheckIcon, CheckIcon,
@ -61,12 +61,15 @@ import {
Github, Github,
Globe, Globe,
HelpCircle, HelpCircle,
LayoutGrid,
List,
PuzzleIcon, PuzzleIcon,
SearchIcon, SearchIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
interface Props { interface Props {
projectId: string; projectId: string;
} }
@ -74,8 +77,9 @@ interface Props {
export const AddTemplate = ({ projectId }: Props) => { export const AddTemplate = ({ projectId }: Props) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { data } = api.compose.templates.useQuery(); const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const { data } = api.compose.templates.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery();
const { data: tags, isLoading: isLoadingTags } = const { data: tags, isLoading: isLoadingTags } =
api.compose.getTags.useQuery(); api.compose.getTags.useQuery();
@ -108,29 +112,28 @@ export const AddTemplate = ({ projectId }: Props) => {
<span>Template</span> <span>Template</span>
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl p-0"> <DialogContent className="max-h-screen sm:max-w-[90vw] p-0">
<div className="sticky top-0 z-10 flex flex-col gap-4 bg-background p-6 border-b"> <DialogHeader className="sticky top-0 z-10 bg-background p-6 border-b">
<DialogHeader> <div className="flex flex-col space-y-4">
<div className="flex items-center justify-between">
<div>
<DialogTitle>Create from Template</DialogTitle> <DialogTitle>Create from Template</DialogTitle>
<DialogDescription> <DialogDescription>
Create an open source application from a template Create an open source application from a template
</DialogDescription> </DialogDescription>
</DialogHeader> </div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} <div className="flex items-center gap-4">
<div className="flex flex-col md:flex-row gap-2">
<Input <Input
placeholder="Search Template" placeholder="Search Template"
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="w-full" className="w-[200px]"
value={query} value={query}
/> />
<Popover modal={true}> <Popover modal={true}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn("w-[200px] justify-between !bg-input")}
"md:max-w-[15rem] w-full justify-between !bg-input",
)}
> >
{isLoadingTags {isLoadingTags
? "Loading...." ? "Loading...."
@ -143,17 +146,19 @@ export const AddTemplate = ({ projectId }: Props) => {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent className="p-0" align="start">
<Command> <Command>
<CommandInput placeholder="Search tag..." className="h-9" /> <CommandInput
placeholder="Search tag..."
className="h-9"
/>
{isLoadingTags && ( {isLoadingTags && (
<span className="py-6 text-center text-sm"> <span className="py-6 text-center text-sm">
Loading Tags.... Loading Tags....
</span> </span>
)} )}
<CommandEmpty>No tags found.</CommandEmpty> <CommandEmpty>No tags found.</CommandEmpty>
<ScrollArea className="h-96 overflow-y-auto"> <ScrollArea className="h-96">
<CommandGroup> <CommandGroup>
{tags?.map((tag) => { {tags?.map((tag) => (
return (
<CommandItem <CommandItem
value={tag} value={tag}
key={tag} key={tag}
@ -177,16 +182,50 @@ export const AddTemplate = ({ projectId }: Props) => {
)} )}
/> />
</CommandItem> </CommandItem>
); ))}
})}
</CommandGroup> </CommandGroup>
</ScrollArea> </ScrollArea>
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Button
size="icon"
onClick={() =>
setViewMode(viewMode === "detailed" ? "icon" : "detailed")
}
className="h-9 w-9"
>
{viewMode === "detailed" ? (
<LayoutGrid className="size-4" />
) : (
<List className="size-4" />
)}
</Button>
</div> </div>
</div> </div>
<div className="p-6 w-full"> {selectedTags.length > 0 && (
<div className="flex flex-wrap justify-end gap-2">
{selectedTags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="cursor-pointer"
onClick={() =>
setSelectedTags(selectedTags.filter((t) => t !== tag))
}
>
{tag} ×
</Badge>
))}
</div>
)}
</div>
</DialogHeader>
<ScrollArea className="h-[calc(98vh-8rem)]">
<div className="p-6">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{templates.length === 0 ? ( {templates.length === 0 ? (
<div className="flex justify-center items-center w-full gap-2 min-h-[50vh]"> <div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
<SearchIcon className="text-muted-foreground size-6" /> <SearchIcon className="text-muted-foreground size-6" />
@ -195,86 +234,118 @@ export const AddTemplate = ({ projectId }: Props) => {
</div> </div>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 w-full gap-4">
{templates?.map((template, index) => (
<div key={`template-${index}`}>
<div <div
key={template.id} className={cn(
className="flex flex-col gap-4 border p-6 rounded-lg h-full" "grid gap-6",
viewMode === "detailed"
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-6"
: "grid-cols-2 sm:grid-cols-4 lg:grid-cols-8",
)}
>
{templates?.map((template, index) => (
<div
key={`template-${index}`}
className={cn(
"flex flex-col border rounded-lg overflow-hidden relative",
viewMode === "icon" && "h-[200px]",
viewMode === "detailed" && "h-[400px]",
)}
>
<Badge className="absolute top-2 right-2" variant="blue">
{template.version}
</Badge>
{/* Template Header */}
<div
className={cn(
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
viewMode === "detailed" && "border-b",
)}
> >
<div className="flex flex-col gap-4">
<div className="flex flex-col items-center gap-2">
<img <img
src={`/templates/${template.logo}`} src={`/templates/${template.logo}`}
className="size-28 object-contain" className={cn(
alt="" "object-contain",
viewMode === "detailed" ? "size-24" : "size-16",
)}
alt={template.name}
/> />
</div> <div className="flex flex-col items-center gap-2">
<span className="text-sm font-medium line-clamp-1">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 justify-center items-center">
<div className="flex flex-col gap-2 items-center justify-center">
<div className="flex flex-row gap-2 flex-wrap">
<span className="text-sm font-medium">
{template.name} {template.name}
</span> </span>
<Badge>{template.version}</Badge> {viewMode === "detailed" &&
template.tags.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5">
{template.tags.map((tag) => (
<Badge
key={tag}
variant="green"
className="text-[10px] px-2 py-0"
>
{tag}
</Badge>
))}
</div>
)}
</div>
</div> </div>
<div className="flex flex-row gap-0"> {/* Template Content */}
{viewMode === "detailed" && (
<ScrollArea className="flex-1 p-6">
<div className="text-sm text-muted-foreground">
{template.description}
</div>
</ScrollArea>
)}
{/* Create Button */}
<div
className={cn(
"flex-none px-6 pb-6 pt-3 mt-auto",
viewMode === "detailed"
? "flex items-center justify-between bg-muted/30 border-t"
: "flex justify-center",
)}
>
{viewMode === "detailed" && (
<div className="flex gap-2">
<Link <Link
href={template.links.github} href={template.links.github}
target="_blank" target="_blank"
className={ className="text-muted-foreground hover:text-foreground transition-colors"
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
}
> >
<Github className="size-4 text-muted-foreground" /> <Github className="size-5" />
</Link> </Link>
{template.links.website && ( {template.links.website && (
<Link <Link
href={template.links.website} href={template.links.website}
target="_blank" target="_blank"
className={ className="text-muted-foreground hover:text-foreground transition-colors"
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
}
> >
<Globe className="size-4 text-muted-foreground" /> <Globe className="size-5" />
</Link> </Link>
)} )}
{template.links.docs && ( {template.links.docs && (
<Link <Link
href={template.links.docs} href={template.links.docs}
target="_blank" target="_blank"
className={ className="text-muted-foreground hover:text-foreground transition-colors"
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
}
> >
<BookText className="size-4 text-muted-foreground" /> <BookText className="size-5" />
</Link> </Link>
)} )}
<Link
href={`https://github.com/Dokploy/dokploy/tree/canary/apps/dokploy/templates/${template.id}`}
target="_blank"
className={
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
}
>
<Code className="size-4 text-muted-foreground" />
</Link>
</div> </div>
<div className="flex flex-row gap-2 flex-wrap justify-center"> )}
{template.tags.map((tag) => (
<Badge variant="secondary" key={tag}>
{tag}
</Badge>
))}
</div>
</div>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button onSelect={(e) => e.preventDefault()}> <Button
size="sm"
className={cn(
"w-auto",
viewMode === "detailed" && "w-auto",
)}
>
Create Create
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
@ -304,9 +375,9 @@ export const AddTemplate = ({ projectId }: Props) => {
side="top" side="top"
> >
<span> <span>
If ot server is selected, the If ot server is selected, the application
application will be deployed on the will be deployed on the server where the
server where the user is logged in. user is logged in.
</span> </span>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -369,18 +440,12 @@ export const AddTemplate = ({ projectId }: Props) => {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
<p className="text-sm text-muted-foreground line-clamp-3">
{template.description}
</p>
</div>
</div>
</div>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
</ScrollArea>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );