mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
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:
parent
c0a2d2c399
commit
c9308aebc2
@ -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>
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user