diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index 1d3055e5e2..1473266ebc 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -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, @@ -57,6 +63,7 @@ import { HandleProject } from "./handle-project"; import { ProjectEnvironment } from "./project-environment"; export const ShowProjects = () => { + const [layout, setLayout] = useState(() => getDefaultLayout()); const utils = api.useUtils(); const router = useRouter(); const { data: isCloud } = api.settings.isCloud.useQuery(); @@ -279,6 +286,7 @@ export const ShowProjects = () => { + {filteredProjects?.length === 0 && (
@@ -288,7 +296,7 @@ export const ShowProjects = () => {
)} -
+ {filteredProjects?.map((project) => { const emptyServices = project?.environments .map( @@ -326,10 +334,7 @@ export const ShowProjects = () => { const hasNoEnvironments = !accessibleEnvironment; return ( -
+
{ } }} > - + @@ -507,7 +512,7 @@ export const ShowProjects = () => {
); })} -
+
)} diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index c173be620e..01360e6a7a 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -959,18 +959,31 @@ export default function Page({ children }: Props) { {item.icon && ( )} - {item.title} + + {item.title} + ) : ( diff --git a/apps/dokploy/components/ui/cards-layout.tsx b/apps/dokploy/components/ui/cards-layout.tsx new file mode 100644 index 0000000000..84182fc3c7 --- /dev/null +++ b/apps/dokploy/components/ui/cards-layout.tsx @@ -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 & { + 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(() => getDefaultLayout()); + * @returns JSX.Element + */ +const LayoutSwitcher = ({ + layout, + setLayout, +}: { + layout: Layout; + setLayout: (layout: Layout) => void; +}) => { + const iconClass = "w-5 h-5"; + return ( + + + + setLayout(pressed ? Layout.GRID : Layout.LIST) + } + > + {layout === Layout.GRID ? ( + + ) : ( + + )} + + + +

Switch to {layout === Layout.GRID ? "list" : "grid"} view

+
+
+ ); +}; + + +/** + * 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(() => getDefaultLayout()); + * + * + * {children} + * + * + * 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. + * + * + * + * 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( + ({ layout, className, children, ...props }, ref) => { + useEffect(() => { + localStorage.setItem("servicesLayout", layout); + }, [layout]); + return ( +
+
+ {children} +
+
+ ); + }, +); + +CardsLayout.displayName = "CardsLayout"; +LayoutSwitcher.displayName = "LayoutSwitcher"; + +export { CardsLayout, LayoutSwitcher }; diff --git a/apps/dokploy/components/ui/color-theme-picker.tsx b/apps/dokploy/components/ui/color-theme-picker.tsx new file mode 100644 index 0000000000..b0ce069bc4 --- /dev/null +++ b/apps/dokploy/components/ui/color-theme-picker.tsx @@ -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 = { + 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 ( +
+ {COLOR_THEMES.map((theme) => { + const isActive = colorTheme === theme; + const { label, hex } = THEME_META[theme]; + return ( + + ); + })} +
+ ); +} diff --git a/apps/dokploy/components/ui/color-theme-provider.tsx b/apps/dokploy/components/ui/color-theme-provider.tsx new file mode 100644 index 0000000000..9cccdc4805 --- /dev/null +++ b/apps/dokploy/components/ui/color-theme-provider.tsx @@ -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({ + colorTheme: DEFAULT_THEME, + setColorTheme: () => {}, +}); + +export function ColorThemeProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [colorTheme, setColorThemeState] = useState(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 ( + + {children} + + ); +} + +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); +} diff --git a/apps/dokploy/components/ui/tabs.tsx b/apps/dokploy/components/ui/tabs.tsx index ffbbaed192..ca0b1164a4 100644 --- a/apps/dokploy/components/ui/tabs.tsx +++ b/apps/dokploy/components/ui/tabs.tsx @@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef< - - - - - {getLayout()} + + + + + + {getLayout()} + ); diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx index 44be4cae77..e003c69e85 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx @@ -60,6 +60,12 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { + CardsLayout, + getDefaultLayout, + type Layout, + LayoutSwitcher, +} from "@/components/ui/cards-layout"; import { Checkbox } from "@/components/ui/checkbox"; import { Command, @@ -285,6 +291,7 @@ const EnvironmentPage = ( ) => { const utils = api.useUtils(); const [isBulkActionLoading, setIsBulkActionLoading] = useState(false); + const [layout, setLayout] = useState(() => getDefaultLayout()); const { projectId, environmentId } = props; const { data: auth } = api.user.get.useQuery(); const { data: permissions } = api.user.getPermissions.useQuery(); @@ -1445,6 +1452,7 @@ const EnvironmentPage = ( )} +
@@ -1468,14 +1476,14 @@ const EnvironmentPage = ( ) : (
-
+ {filteredServices?.map((service) => ( - + {service.serverId && (
@@ -1567,7 +1575,7 @@ const EnvironmentPage = ( ))} -
+
)}
diff --git a/apps/dokploy/pages/dashboard/settings/profile.tsx b/apps/dokploy/pages/dashboard/settings/profile.tsx index b02d59c0e3..ab0697415d 100644 --- a/apps/dokploy/pages/dashboard/settings/profile.tsx +++ b/apps/dokploy/pages/dashboard/settings/profile.tsx @@ -7,6 +7,15 @@ import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys"; import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account"; import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ColorThemePicker } from "@/components/ui/color-theme-picker"; +import { ModeToggle } from "@/components/ui/modeToggle"; import { appRouter } from "@/server/api/root"; import { api } from "@/utils/api"; @@ -18,6 +27,24 @@ const Page = () => {
+ + + Appearance + + Customize the look and feel of the interface. + + + +
+ Color theme + +
+
+ Mode + +
+
+
{isCloud && } {permissions?.api.read && }
diff --git a/apps/dokploy/styles/globals.css b/apps/dokploy/styles/globals.css index 8bd768e1ab..c0bee63ed4 100644 --- a/apps/dokploy/styles/globals.css +++ b/apps/dokploy/styles/globals.css @@ -175,6 +175,156 @@ @apply min-h-[25rem]; } +/* ── Color themes ───────────────────────────────────────────────────────── */ +/* Each theme overrides --primary, --primary-foreground, --ring, */ +/* --sidebar-primary and --sidebar-ring on the element. */ + +/* zinc (default — no override needed, matches the base :root values) */ + +/* pink */ +[data-color-theme="pink"] { + --primary: 330.4 81.2% 60.4%; + --primary-foreground: 355.7 100% 97.3%; + --ring: 330.4 81.2% 60.4%; + --sidebar-primary: 330.4 81.2% 60.4%; + --sidebar-ring: 330.4 81.2% 60.4%; +} +.dark[data-color-theme="pink"] { + --primary: 330.4 81.2% 65%; + --primary-foreground: 355.7 100% 97.3%; + --ring: 330.4 81.2% 65%; + --sidebar-primary: 330.4 81.2% 65%; + --sidebar-ring: 330.4 81.2% 65%; +} + +/* teal */ +[data-color-theme="teal"] { + --primary: 173.4 80.4% 36%; + --primary-foreground: 0 0% 98%; + --ring: 173.4 80.4% 36%; + --sidebar-primary: 173.4 80.4% 36%; + --sidebar-ring: 173.4 80.4% 36%; +} +.dark[data-color-theme="teal"] { + --primary: 172 66% 50.4%; + --primary-foreground: 172 40% 10%; + --ring: 172 66% 50.4%; + --sidebar-primary: 172 66% 50.4%; + --sidebar-ring: 172 66% 50.4%; +} + +/* blue */ +[data-color-theme="blue"] { + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --ring: 221.2 83.2% 53.3%; + --sidebar-primary: 221.2 83.2% 53.3%; + --sidebar-ring: 221.2 83.2% 53.3%; +} +.dark[data-color-theme="blue"] { + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --ring: 217.2 91.2% 59.8%; + --sidebar-primary: 217.2 91.2% 59.8%; + --sidebar-ring: 217.2 91.2% 59.8%; +} + +/* violet */ +[data-color-theme="violet"] { + --primary: 262.1 83.3% 57.8%; + --primary-foreground: 210 20% 98%; + --ring: 262.1 83.3% 57.8%; + --sidebar-primary: 262.1 83.3% 57.8%; + --sidebar-ring: 262.1 83.3% 57.8%; +} +.dark[data-color-theme="violet"] { + --primary: 263.4 70% 50.4%; + --primary-foreground: 210 20% 98%; + --ring: 263.4 70% 50.4%; + --sidebar-primary: 263.4 70% 50.4%; + --sidebar-ring: 263.4 70% 50.4%; +} + +/* rose */ +[data-color-theme="rose"] { + --primary: 346.8 77.2% 49.8%; + --primary-foreground: 355.7 100% 97.3%; + --ring: 346.8 77.2% 49.8%; + --sidebar-primary: 346.8 77.2% 49.8%; + --sidebar-ring: 346.8 77.2% 49.8%; +} +.dark[data-color-theme="rose"] { + --primary: 349.7 89.2% 60.2%; + --primary-foreground: 355.7 100% 97.3%; + --ring: 349.7 89.2% 60.2%; + --sidebar-primary: 349.7 89.2% 60.2%; + --sidebar-ring: 349.7 89.2% 60.2%; +} + +/* red */ +[data-color-theme="red"] { + --primary: 0 72.2% 50.6%; + --primary-foreground: 0 85.7% 97.3%; + --ring: 0 72.2% 50.6%; + --sidebar-primary: 0 72.2% 50.6%; + --sidebar-ring: 0 72.2% 50.6%; +} +.dark[data-color-theme="red"] { + --primary: 0 72.2% 50.6%; + --primary-foreground: 0 85.7% 97.3%; + --ring: 0 72.2% 50.6%; + --sidebar-primary: 0 72.2% 50.6%; + --sidebar-ring: 0 72.2% 50.6%; +} + +/* orange */ +[data-color-theme="orange"] { + --primary: 24.6 95% 53.1%; + --primary-foreground: 60 9.1% 97.8%; + --ring: 24.6 95% 53.1%; + --sidebar-primary: 24.6 95% 53.1%; + --sidebar-ring: 24.6 95% 53.1%; +} +.dark[data-color-theme="orange"] { + --primary: 20.5 90.2% 48.2%; + --primary-foreground: 60 9.1% 97.8%; + --ring: 20.5 90.2% 48.2%; + --sidebar-primary: 20.5 90.2% 48.2%; + --sidebar-ring: 20.5 90.2% 48.2%; +} + +/* amber */ +[data-color-theme="amber"] { + --primary: 37.7 92.1% 50.2%; + --primary-foreground: 26 83.3% 14.1%; + --ring: 37.7 92.1% 50.2%; + --sidebar-primary: 37.7 92.1% 50.2%; + --sidebar-ring: 37.7 92.1% 50.2%; +} +.dark[data-color-theme="amber"] { + --primary: 43.3 96.4% 56.3%; + --primary-foreground: 26 83.3% 14.1%; + --ring: 43.3 96.4% 56.3%; + --sidebar-primary: 43.3 96.4% 56.3%; + --sidebar-ring: 43.3 96.4% 56.3%; +} + +/* green */ +[data-color-theme="green"] { + --primary: 142.1 76.2% 36.3%; + --primary-foreground: 355.7 100% 97.3%; + --ring: 142.1 76.2% 36.3%; + --sidebar-primary: 142.1 76.2% 36.3%; + --sidebar-ring: 142.1 76.2% 36.3%; +} +.dark[data-color-theme="green"] { + --primary: 142.1 70.6% 45.3%; + --primary-foreground: 144.9 80.4% 10%; + --ring: 142.1 70.6% 45.3%; + --sidebar-primary: 142.1 70.6% 45.3%; + --sidebar-ring: 142.1 70.6% 45.3%; +} + @keyframes heartbeat { 0% { transform: scale(1);