mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
Merge pull request #2936 from Bima42/feat/2931-template-bookmarking
feat: be able to bookmark templates
This commit is contained in:
@@ -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<string[]>([]);
|
||||
const [showBookmarksOnly, setShowBookmarksOnly] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
|
||||
// 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<string | undefined>(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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger className="w-full">
|
||||
@@ -243,6 +292,20 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
variant={showBookmarksOnly ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => setShowBookmarksOnly(!showBookmarksOnly)}
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
disabled={isLoadingBookmarks}
|
||||
>
|
||||
<Bookmark
|
||||
className={cn(
|
||||
"size-4",
|
||||
showBookmarksOnly && "fill-current",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
@@ -299,11 +362,19 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
|
||||
<div className="flex flex-col justify-center items-center w-full gap-2 min-h-[50vh]">
|
||||
<SearchIcon className="text-muted-foreground size-6" />
|
||||
<div className="text-xl font-medium text-muted-foreground">
|
||||
No templates found
|
||||
{showBookmarksOnly
|
||||
? "No bookmarked templates found"
|
||||
: "No templates found"}
|
||||
</div>
|
||||
{showBookmarksOnly && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click the bookmark icon on templates to add them to
|
||||
bookmarks
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -323,9 +394,25 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
viewMode === "detailed" && "h-[400px]",
|
||||
)}
|
||||
>
|
||||
<Badge className="absolute top-2 right-2" variant="blue">
|
||||
{template?.version}
|
||||
</Badge>
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-background/80 backdrop-blur-sm hover:bg-background"
|
||||
onClick={(e) => handleToggleBookmark(e, template.id)}
|
||||
>
|
||||
<Bookmark
|
||||
className={cn(
|
||||
"size-4",
|
||||
bookmarkIds.includes(template.id) &&
|
||||
"fill-yellow-400 text-yellow-400",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant="blue">{template?.version}</Badge>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
|
||||
|
||||
1
apps/dokploy/drizzle/0159_polite_puppet_master.sql
Normal file
1
apps/dokploy/drizzle/0159_polite_puppet_master.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD COLUMN "bookmarkedTemplates" text[] DEFAULT ARRAY[]::text[];
|
||||
8277
apps/dokploy/drizzle/meta/0159_snapshot.json
Normal file
8277
apps/dokploy/drizzle/meta/0159_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1114,6 +1114,13 @@
|
||||
"when": 1775270343231,
|
||||
"tag": "0158_amused_synch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 159,
|
||||
"version": "7",
|
||||
"when": 1775274158009,
|
||||
"tag": "0159_polite_puppet_master",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -75,7 +75,7 @@ import {
|
||||
} from "@/server/queues/queueSetup";
|
||||
import { cancelDeployment, deploy } from "@/server/utils/deploy";
|
||||
import { generatePassword } from "@/templates/utils";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { audit } from "../utils/audit";
|
||||
|
||||
export const composeRouter = createTRPCRouter({
|
||||
@@ -630,7 +630,7 @@ export const composeRouter = createTRPCRouter({
|
||||
return compose;
|
||||
}),
|
||||
|
||||
templates: publicProcedure
|
||||
templates: protectedProcedure
|
||||
.input(z.object({ baseUrl: z.string().optional() }))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, number> = {
|
||||
B: 1,
|
||||
KB: 1024,
|
||||
|
||||
Reference in New Issue
Block a user