Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8b9f6c0
feat: add custom components cms feature
tembo[bot] Sep 5, 2025
8b71f64
fix(formatting): fix code style issues across codebase
tembo[bot] Sep 5, 2025
0317a19
fix(typescript): resolve compilation errors in custom components feature
tembo[bot] Sep 5, 2025
05d2c7f
Revert "fix(typescript): resolve compilation errors in custom compone…
mezotv Sep 5, 2025
f9a029a
Revert "fix(formatting): fix code style issues across codebase"
mezotv Sep 5, 2025
b472c38
fix: make code actually runnable
mezotv Sep 5, 2025
386fd30
refactor: data fetching and ui
mezotv Sep 6, 2025
7138370
style: format code
mezotv Sep 6, 2025
adf486c
feat: clean up api and add proper validation
mezotv Sep 6, 2025
4e396c8
fix: clean up loading ui
mezotv Sep 7, 2025
7766eee
fix: use correct imports
mezotv Sep 7, 2025
a8cff2e
feat: edit modal + properly set props
mezotv Sep 7, 2025
8c7099c
feat(any): replaced all the any with actual type (#163)
Notoriousbrain Sep 7, 2025
fe692b6
Tembo/custom component (#167)
Notoriousbrain Sep 8, 2025
ddd0d69
UI/UX changes of the custom component section (#171)
Notoriousbrain Sep 9, 2025
a19000a
Merge branch 'main' of https://github.com/usemarble/marble into tembo…
mezotv Sep 10, 2025
41846bc
fix: update cursor styling
mezotv Sep 10, 2025
60b3056
fix: remove unused files and props
mezotv Sep 10, 2025
dc59efa
feat: allow select to have options
mezotv Sep 10, 2025
df3003a
Merge branch 'main' into tembo/create-custom-components-cms-feature
mezotv Sep 12, 2025
1fb5f5f
Merge branch 'main' into tembo/create-custom-components-cms-feature
mezotv Sep 15, 2025
0af8462
Merge branch 'main' of https://github.com/usemarble/marble into tembo…
mezotv Oct 4, 2025
5a9fa95
fix: move components to dev tab
mezotv Oct 4, 2025
351d92b
style: format code
mezotv Oct 4, 2025
7705ff3
feat: add technical component name and format schema
mezotv Oct 4, 2025
26e7a6a
Merge branch 'main' of https://github.com/usemarble/marble into tembo…
mezotv Oct 8, 2025
719c80c
feat: store data in db instead of editor
mezotv Oct 8, 2025
98ff070
Merge branch 'main' of https://github.com/usemarble/marble into tembo…
mezotv Oct 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"use client";

import { Button } from "@marble/ui/components/button";
import { PlusIcon, PuzzlePieceIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import { useState } from "react";
import { type CustomComponent, columns } from "@/components/components/columns";
import { ComponentsDataTable } from "@/components/components/data-table";
import { WorkspacePageWrapper } from "@/components/layout/wrapper";
import PageLoader from "@/components/shared/page-loader";
import { useWorkspaceId } from "@/hooks/use-workspace-id";
import { QUERY_KEYS } from "@/lib/queries/keys";

const ComponentModal = dynamic(() =>
import("@/components/components/component-modals").then(
(mod) => mod.ComponentModal
)
);

export default function PageClient() {
const workspaceId = useWorkspaceId();
const [showCreateModal, setShowCreateModal] = useState(false);

const { data: components, isLoading } = useQuery({
// biome-ignore lint/style/noNonNullAssertion: <>
queryKey: QUERY_KEYS.CUSTOM_COMPONENTS(workspaceId!),
staleTime: 1000 * 60 * 60,
queryFn: async () => {
const res = await fetch("/api/custom-components");
if (!res.ok) {
throw new Error("Failed to fetch components");
}
const data: CustomComponent[] = await res.json();
return data;
},
enabled: !!workspaceId,
});

if (isLoading) {
return <PageLoader />;
}

return (
<>
{components && components.length > 0 ? (
<WorkspacePageWrapper className="flex flex-col gap-8 pt-10 pb-16">
<ComponentsDataTable columns={columns} data={components} />
</WorkspacePageWrapper>
) : (
<WorkspacePageWrapper className="grid h-full place-content-center">
<div className="flex max-w-80 flex-col items-center gap-4">
<div className="p-2">
<PuzzlePieceIcon className="size-16" />
</div>
<div className="flex flex-col items-center gap-4 text-center">
<p className="text-muted-foreground text-sm">
Custom components help you build reusable content blocks. Create
your first component to get started.
</p>
<Button
className="cursor-pointer"
onClick={() => setShowCreateModal(true)}
>
<PlusIcon size={16} />
<span>Create Component</span>
</Button>
</div>
</div>
</WorkspacePageWrapper>
)}
<ComponentModal
mode="create"
open={showCreateModal}
setOpen={setShowCreateModal}
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import PageClient from "./page-client";

export const metadata = {
title: "Components",
description: "Manage your custom components",
};

function Page() {
return <PageClient />;
}

export default Page;
181 changes: 181 additions & 0 deletions apps/cms/src/app/api/custom-components/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { db } from "@marble/db";
import { type NextRequest, NextResponse } from "next/server";
import { getServerSession } from "@/lib/auth/session";
import {
type ComponentPropertyValues,
componentUpdateSchema,
} from "@/lib/validations/components";

type PropertyType =
| "string"
| "number"
| "boolean"
| "date"
| "email"
| "url"
| "textarea"
| "select";

export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const sessionData = await getServerSession();

if (!sessionData || !sessionData.session.activeOrganizationId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { id } = await params;

try {
const json = await req.json();
const validatedData = componentUpdateSchema.parse(json);
const { name, technicalName, description, properties } = validatedData;
const existingComponent = await db.customComponent.findUnique({
where: { id },
include: { properties: true },
});

if (!existingComponent) {
return NextResponse.json(
{ error: "Custom component not found" },
{ status: 404 }
);
}

const updatedComponent = await db.$transaction(async (tx) => {
await tx.componentProperty.deleteMany({
where: { customComponentId: id },
});

return await tx.customComponent.update({
where: { id },
data: {
name,
technicalName,
description,
properties: {
create:
properties?.map((prop: ComponentPropertyValues) => ({
name: prop.name,
type: prop.type as PropertyType,
required: prop.required || false,
defaultValue: prop.defaultValue,
options: prop.options,
})) || [],
},
},
include: {
properties: true,
},
});
});

return NextResponse.json(updatedComponent);
} catch (error) {
console.error("Error updating custom component:", error);

// Handle Zod validation errors
if (error instanceof Error && error.name === "ZodError") {
const zodError = error as unknown as {
errors: { path: string[]; message: string }[];
};
return NextResponse.json(
{
error: "Validation failed",
details: zodError.errors?.map((e) => ({
field: e.path.join("."),
message: e.message,
})),
},
{ status: 400 }
);
}

return NextResponse.json(
{ error: "Failed to update custom component" },
{ status: 500 }
);
}
}

export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const sessionData = await getServerSession();

if (!sessionData || !sessionData.session.activeOrganizationId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { id } = await params;

const existingComponent = await db.customComponent.findUnique({
where: { id, workspaceId: sessionData.session.activeOrganizationId },
});

if (!existingComponent) {
return NextResponse.json(
{ error: "Custom component not found" },
{ status: 404 }
);
}

try {
await db.customComponent.delete({
where: {
id,
workspaceId: sessionData.session.activeOrganizationId,
},
});

return NextResponse.json({
message: "Custom component deleted successfully",
});
} catch (error) {
console.error("Error deleting custom component:", error);
return NextResponse.json(
{ error: "Failed to delete custom component" },
{ status: 500 }
);
}
}

export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const sessionData = await getServerSession();

if (!sessionData || !sessionData.session.activeOrganizationId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { id } = await params;

try {
const customComponent = await db.customComponent.findUnique({
where: { id, workspaceId: sessionData.session.activeOrganizationId },
include: {
properties: true,
},
});

if (!customComponent) {
return NextResponse.json(
{ error: "Custom component not found" },
{ status: 404 }
);
}

return NextResponse.json(customComponent);
} catch (error) {
console.error("Error fetching custom component:", error);
return NextResponse.json(
{ error: "Failed to fetch custom component" },
{ status: 500 }
);
}
}
Loading