Skip to content
19 changes: 12 additions & 7 deletions apps/dokploy/components/dashboard/projects/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
CardsLayout,
getDefaultLayout,
LayoutSwitcher,
type Layout,
} from "@/components/ui/cards-layout";
import {
DropdownMenu,
DropdownMenuContent,
Expand All @@ -57,6 +63,7 @@ import { HandleProject } from "./handle-project";
import { ProjectEnvironment } from "./project-environment";

export const ShowProjects = () => {
const [layout, setLayout] = useState<Layout>(() => getDefaultLayout());
const utils = api.useUtils();
const router = useRouter();
const { data: isCloud } = api.settings.isCloud.useQuery();
Expand Down Expand Up @@ -279,6 +286,7 @@ export const ShowProjects = () => {
</Select>
</div>
</div>
<LayoutSwitcher layout={layout} setLayout={setLayout} />
</div>
{filteredProjects?.length === 0 && (
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
Expand All @@ -288,7 +296,7 @@ export const ShowProjects = () => {
</span>
</div>
)}
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
<CardsLayout layout={layout}>
{filteredProjects?.map((project) => {
const emptyServices = project?.environments
.map(
Expand Down Expand Up @@ -326,10 +334,7 @@ export const ShowProjects = () => {
const hasNoEnvironments = !accessibleEnvironment;

return (
<div
key={project.projectId}
className="w-full lg:max-w-md"
>
<div key={project.projectId} className="w-full">
<Link
href={
hasNoEnvironments
Expand All @@ -342,7 +347,7 @@ export const ShowProjects = () => {
}
}}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-primary/10">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
<span className="flex flex-col gap-1.5 ">
Expand Down Expand Up @@ -507,7 +512,7 @@ export const ShowProjects = () => {
</div>
);
})}
</div>
</CardsLayout>
</>
)}
</CardContent>
Expand Down
21 changes: 17 additions & 4 deletions apps/dokploy/components/layouts/side.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -959,18 +959,31 @@ export default function Page({ children }: Props) {
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(isActive && "bg-border")}
className={cn(
isActive && "bg-primary/10",
"hover:bg-primary/10 transition-colors group/item",
)}
>
<Link
href={item.url}
className="flex w-full items-center gap-2"
className="flex w-full items-center gap-2 "
>
{item.icon && (
<item.icon
className={cn(isActive && "text-primary")}
className={cn(
isActive && "text-primary",
"group-hover/item:text-primary transition-colors",
)}
/>
)}
<span>{item.title}</span>
<span
className={cn(
isActive && "text-primary",
"group-hover/item:text-primary transition-colors",
)}
>
{item.title}
</span>
</Link>
</SidebarMenuButton>
) : (
Expand Down
121 changes: 121 additions & 0 deletions apps/dokploy/components/ui/cards-layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { LayoutGridIcon, LayoutListIcon } from "lucide-react";
import React, { useEffect } from "react";
import { Toggle } from "@/components/ui/toggle";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";

export enum Layout {
GRID = "grid",
LIST = "list",
}

export type CardsLayoutProps = React.HTMLAttributes<HTMLDivElement> & {
layout: Layout;
};

/**
* Retrieves the default layout for the cards, either from localStorage or defaults to "grid".
* @returns The default layout, either "grid" or "list".
*/
export function getDefaultLayout(): Layout {
if (typeof window !== "undefined") {
const savedLayout = localStorage.getItem("servicesLayout") as Layout;
if (savedLayout === Layout.GRID || savedLayout === Layout.LIST) {
return savedLayout;
}
}
return Layout.GRID; // Default layout
}

/**
* LayoutSwitcher component that allows users to toggle between grid and list layouts for displaying cards. It uses a Toggle button to switch layouts and a Tooltip to provide context on the action. The selected layout is saved in localStorage to persist user preference across sessions.
* @param layout The layout to use for the cards, either "grid" or "list".
* @param setLayout Function to update the layout state in the parent component.
* @example
* const [layout, setLayout] = useState<Layout>(() => getDefaultLayout());
* @returns JSX.Element
*/
const LayoutSwitcher = ({
layout,
setLayout,
}: {
layout: Layout;
setLayout: (layout: Layout) => void;
}) => {
const iconClass = "w-5 h-5";
return (
<Tooltip>
<TooltipTrigger asChild>
<Toggle
aria-label="Toggle layout"
variant="outline"
pressed={layout === getDefaultLayout()}
onPressedChange={(pressed) =>
setLayout(pressed ? Layout.GRID : Layout.LIST)
}
>
{layout === Layout.GRID ? (
<LayoutGridIcon className={iconClass} />
) : (
<LayoutListIcon className={iconClass} />
)}
</Toggle>
Comment thread
orochibraru marked this conversation as resolved.
</TooltipTrigger>
<TooltipContent>
<p>Switch to {layout === Layout.GRID ? "list" : "grid"} view</p>
</TooltipContent>
</Tooltip>
);
};


/**
* CardsLayout component that wraps its children in a grid or list layout based on the provided layout prop. It also saves the user's layout preference in localStorage.
*
* @param layout The layout to use for the cards, either "grid" or "list".
* @example Layout state should be managed this way in the parent component:
* const [layout, setLayout] = useState<Layout>(() => getDefaultLayout());
*
* <CardsLayout layout={layout}>
* {children}
* </CardsLayout>
*
* The LayoutSwitcher component can be used to toggle between grid and list layouts, and it will update the layout state in the parent component accordingly.
*
* <LayoutSwitcher layout={layout} setLayout={setLayout} />
*
* This implementation ensures that the user's layout preference is persisted across sessions and provides an easy way to switch between different layouts.
* @returns JSX.Element
*/
const CardsLayout = React.forwardRef<HTMLDivElement, CardsLayoutProps>(
({ layout, className, children, ...props }, ref) => {
useEffect(() => {
localStorage.setItem("servicesLayout", layout);
}, [layout]);
return (
<section className="flex flex-col gap-4">
<div
className={cn(
layout === Layout.GRID
? "gap-5 pb-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5"
: "flex flex-col gap-4 w-full",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
</section>
);
},
);

CardsLayout.displayName = "CardsLayout";
LayoutSwitcher.displayName = "LayoutSwitcher";

export { CardsLayout, LayoutSwitcher };
72 changes: 72 additions & 0 deletions apps/dokploy/components/ui/color-theme-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import {
COLOR_THEMES,
type ColorTheme,
useColorTheme,
} from "./color-theme-provider";

const THEME_META: Record<ColorTheme, { label: string; hex: string }> = {
zinc: { label: "Zinc", hex: "#71717a" },
blue: { label: "Blue", hex: "#3b82f6" },
violet: { label: "Violet", hex: "#8b5cf6" },
pink: { label: "Pink", hex: "#ec4899" },
rose: { label: "Rose", hex: "#f43f5e" },
red: { label: "Red", hex: "#ef4444" },
orange: { label: "Orange", hex: "#f97316" },
amber: { label: "Amber", hex: "#f59e0b" },
green: { label: "Green", hex: "#22c55e" },
teal: { label: "Teal", hex: "#14b8a6" },
};

export function ColorThemePicker() {
const { colorTheme, setColorTheme } = useColorTheme();

return (
<div className="flex flex-wrap gap-4">
{COLOR_THEMES.map((theme) => {
const isActive = colorTheme === theme;
const { label, hex } = THEME_META[theme];
return (
<button
key={theme}
type="button"
onClick={() => setColorTheme(theme)}
className="flex flex-col items-center gap-1.5 focus-visible:outline-none group"
>
<span
className={cn(
"relative flex h-8 w-8 items-center justify-center rounded-full transition-transform duration-150",
isActive ? "scale-110" : "group-hover:scale-105",
)}
style={{
backgroundColor: hex,
outline: isActive
? `2px solid ${hex}`
: "2px solid transparent",
outlineOffset: "2px",
}}
>
{isActive && (
<Check
className="h-4 w-4 text-white drop-shadow-sm"
strokeWidth={2.5}
/>
)}
</span>
<span
className={cn(
"text-xs",
isActive
? "font-medium text-foreground"
: "text-muted-foreground group-hover:text-foreground",
)}
>
{label}
</span>
</button>
);
})}
</div>
);
}
71 changes: 71 additions & 0 deletions apps/dokploy/components/ui/color-theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createContext, useContext, useLayoutEffect, useState } from "react";

export const COLOR_THEMES = [
"zinc",
"blue",
"violet",
"pink",
"rose",
"red",
"orange",
"amber",
"green",
"teal",
] as const;

export type ColorTheme = (typeof COLOR_THEMES)[number];

const STORAGE_KEY = "dokploy-color-theme";
const DEFAULT_THEME: ColorTheme = "zinc";

interface ColorThemeContextValue {
colorTheme: ColorTheme;
setColorTheme: (theme: ColorTheme) => void;
}

const ColorThemeContext = createContext<ColorThemeContextValue>({
colorTheme: DEFAULT_THEME,
setColorTheme: () => {},
});

export function ColorThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
const [colorTheme, setColorThemeState] = useState<ColorTheme>(DEFAULT_THEME);

useLayoutEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY) as ColorTheme | null;
const theme =
stored && (COLOR_THEMES as readonly string[]).includes(stored)
? stored
: DEFAULT_THEME;
setColorThemeState(theme);
applyTheme(theme);
}, []);

function setColorTheme(theme: ColorTheme) {
setColorThemeState(theme);
localStorage.setItem(STORAGE_KEY, theme);
applyTheme(theme);
}

return (
<ColorThemeContext.Provider value={{ colorTheme, setColorTheme }}>
{children}
</ColorThemeContext.Provider>
);
}

function applyTheme(theme: ColorTheme) {
if (theme === DEFAULT_THEME) {
document.documentElement.removeAttribute("data-color-theme");
} else {
document.documentElement.setAttribute("data-color-theme", theme);
}
}

export function useColorTheme() {
return useContext(ColorThemeContext);
}
2 changes: 1 addition & 1 deletion apps/dokploy/components/ui/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm",
className,
)}
{...props}
Expand Down
Loading