diff --git a/apps/dokploy/components/dashboard/project/add-template.tsx b/apps/dokploy/components/dashboard/project/add-template.tsx index fd37e6a0c..959afe905 100644 --- a/apps/dokploy/components/dashboard/project/add-template.tsx +++ b/apps/dokploy/components/dashboard/project/add-template.tsx @@ -1,5 +1,6 @@ import { BookText, + Bookmark, CheckIcon, ChevronsUpDown, Globe, @@ -82,6 +83,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { const [open, setOpen] = useState(false); const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed"); const [selectedTags, setSelectedTags] = useState([]); + const [showBookmarksOnly, setShowBookmarksOnly] = useState(false); const [customBaseUrl, setCustomBaseUrl] = useState(() => { // Try to get from props first, then localStorage if (baseUrl) return baseUrl; @@ -122,8 +124,45 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { enabled: open, }, ); + + const { data: bookmarkIds = [], isLoading: isLoadingBookmarks } = + api.user.getBookmarkedTemplates.useQuery(undefined, { + enabled: open, + }); + const utils = api.useUtils(); + const { mutateAsync: toggleBookmark } = + api.user.toggleTemplateBookmark.useMutation({ + onMutate: async ({ templateId }) => { + await utils.user.getBookmarkedTemplates.cancel(); + const previousBookmarks = utils.user.getBookmarkedTemplates.getData(); + + utils.user.getBookmarkedTemplates.setData(undefined, (old = []) => { + if (old.includes(templateId)) { + return old.filter((id) => id !== templateId); + } + return [...old, templateId]; + }); + + return { previousBookmarks }; + }, + onError: (err, variables, context) => { + if (context?.previousBookmarks) { + utils.user.getBookmarkedTemplates.setData( + undefined, + context.previousBookmarks, + ); + } + toast.error("Failed to update bookmark"); + }, + onSuccess: (data) => { + toast.success( + data.isBookmarked ? "Added to bookmarks" : "Removed from bookmarks", + ); + }, + }); + const [serverId, setServerId] = useState(undefined); const { mutateAsync, isPending, error, isError } = api.compose.deployTemplate.useMutation(); @@ -137,7 +176,9 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { query === "" || template.name.toLowerCase().includes(query.toLowerCase()) || template.description.toLowerCase().includes(query.toLowerCase()); - return matchesTags && matchesQuery; + const matchesBookmarks = + !showBookmarksOnly || bookmarkIds.includes(template.id); + return matchesTags && matchesQuery && matchesBookmarks; }) || []; const hasServers = servers && servers.length > 0; @@ -146,6 +187,14 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { // Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers) const shouldShowServerDropdown = hasServers; + const handleToggleBookmark = async ( + e: React.MouseEvent, + templateId: string, + ) => { + e.stopPropagation(); + await toggleBookmark({ templateId }); + }; + return ( @@ -243,6 +292,20 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { + + +
+ {template?.version} +
{ try { diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index bf6450fff..c500081e3 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -22,6 +22,7 @@ import { apiUpdateUser, invitation, member, + user, } from "@dokploy/server/db/schema"; import { hasPermission, @@ -639,4 +640,40 @@ export const userRouter = createTRPCRouter({ }); return inviteLink; }), + + getBookmarkedTemplates: protectedProcedure.query(async ({ ctx }) => { + const result = await db.query.user.findFirst({ + where: eq(user.id, ctx.user.id), + columns: { bookmarkedTemplates: true }, + }); + + return result?.bookmarkedTemplates ?? []; + }), + + toggleTemplateBookmark: protectedProcedure + .input( + z.object({ + templateId: z.string().min(1), + }), + ) + .mutation(async ({ input, ctx }) => { + const result = await db.query.user.findFirst({ + where: eq(user.id, ctx.user.id), + columns: { bookmarkedTemplates: true }, + }); + + const current = result?.bookmarkedTemplates ?? []; + const isBookmarked = current.includes(input.templateId); + + const updated = isBookmarked + ? current.filter((id) => id !== input.templateId) + : [...current, input.templateId]; + + await db + .update(user) + .set({ bookmarkedTemplates: updated }) + .where(eq(user.id, ctx.user.id)); + + return { isBookmarked: !isBookmarked }; + }), }); diff --git a/packages/server/src/db/schema/user.ts b/packages/server/src/db/schema/user.ts index 5d6e42858..b9dd0de01 100644 --- a/packages/server/src/db/schema/user.ts +++ b/packages/server/src/db/schema/user.ts @@ -1,5 +1,5 @@ import { paths } from "@dokploy/server/constants"; -import { relations } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { boolean, integer, @@ -66,6 +66,9 @@ export const user = pgTable("user", { stripeSubscriptionId: text("stripeSubscriptionId"), serversQuantity: integer("serversQuantity").notNull().default(0), trustedOrigins: text("trustedOrigins").array(), + bookmarkedTemplates: text("bookmarkedTemplates") + .array() + .default(sql`ARRAY[]::text[]`), }); export const usersRelations = relations(user, ({ one, many }) => ({ @@ -87,6 +90,7 @@ const createSchema = createInsertSchema(user, { }).omit({ role: true, trustedOrigins: true, + bookmarkedTemplates: true, isValidEnterpriseLicense: true, }); diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index cfe9b95ac..75068696e 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -272,7 +272,7 @@ const parseSizeToBytes = (size: string): number => { const match = size.match(/^([\d.]+)\s*([KMGT]?B)$/i); if (!match) return 0; const value = Number.parseFloat(match[1] as string); - const unit = match[2].toUpperCase(); + const unit = (match[2] as string).toUpperCase(); const multipliers: Record = { B: 1, KB: 1024,