Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 39 additions & 0 deletions app/components/email-builder/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { EmailTemplate } from "../../lib/email-schema";

type Props = {
template: EmailTemplate;
selectedBlockId: string | null;
onSelect: (id: string) => void;
};

export function Canvas({ template, selectedBlockId, onSelect }: Props) {
return (
<div className="max-w-[600px] mx-auto bg-white p-4">
{template.sections.map((s) =>
s.columns.map((c) =>
c.blocks.map((b) => (
<div
key={b.id}
onClick={() => onSelect(b.id)}
className={`border p-2 mb-2 cursor-pointer ${
b.id === selectedBlockId ? "border-blue-500" : "border-transparent"
}`}
>
<div
contentEditable
suppressContentEditableWarning
style={{
fontSize: b.props.fontSize,
color: b.props.color,
textAlign: b.props.align,
}}
>
{b.props.content}
</div>
</div>
))
)
)}
</div>
);
}
33 changes: 33 additions & 0 deletions app/components/email-builder/EmailBuilder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { useState } from "react";
import { EmailTemplate } from "../../lib/email-schema";
import { Canvas } from "./Canvas";
import { Inspector } from "./Inspector";
import { renderMJML } from "../../lib/render-mjml";

type Props = {
value: EmailTemplate;
onChange: (t: EmailTemplate) => void;
};

export function EmailBuilder({ value, onChange }: Props) {
const [selected, setSelected] = useState<string | null>(null);

const html = renderMJML(value);

return (
<div className="grid grid-cols-[1fr_400px] h-screen">
<div className="p-6 space-y-6 overflow-auto">
<Canvas template={value} selectedBlockId={selected} onSelect={setSelected} />

<h3 className="font-semibold">Email Preview</h3>
<iframe className="w-full h-[400px] border" srcDoc={html} />
</div>

<aside className="border-l p-4">
<Inspector template={value} selectedBlockId={selected} onChange={onChange} />
</aside>
</div>
);
}
54 changes: 54 additions & 0 deletions app/components/email-builder/Inspector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { EmailTemplate } from "../../lib/email-schema";
import { updateBlockInTemplate } from "../../lib/update-block";

type Props = {
template: EmailTemplate;
selectedBlockId: string | null;
onChange: (t: EmailTemplate) => void;
};

export function Inspector({ template, selectedBlockId, onChange }: Props) {
if (!selectedBlockId) return <p>Select a block</p>;

const block = template.sections
.flatMap((s) => s.columns)
.flatMap((c) => c.blocks)
.find((b) => b.id === selectedBlockId);

if (!block) return null;

return (
<div className="space-y-4">
<select
value={block.props.fontSize}
onChange={(e) =>
onChange(updateBlockInTemplate(template, block.id, { fontSize: e.target.value }))
}
>
<option>14px</option>
<option>16px</option>
<option>18px</option>
<option>20px</option>
</select>

<input
type="color"
value={block.props.color}
onChange={(e) =>
onChange(updateBlockInTemplate(template, block.id, { color: e.target.value }))
}
/>

<select
value={block.props.align}
onChange={(e) =>
onChange(updateBlockInTemplate(template, block.id, { align: e.target.value }))
}
>
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
);
}
Binary file modified app/favicon.ico
Binary file not shown.
11 changes: 5 additions & 6 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,18 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-basier: var(--font-basier);
}

@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--background: #ededed;
--foreground: #0a0a0a;
}
}

body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
font-family: var(--font-basier);
}
22 changes: 9 additions & 13 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import localfont from "next/font/local";

const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});

const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const Basier = localfont({
src: "/../public/fonts/Basier.ttf",
variable: "--font-basier"
})

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Emails | Pocketsflow",
description: "Email Builder for Pocketsflow",
};

export default function RootLayout({
Expand All @@ -25,10 +21,10 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={Basier.className}
>
{children}
</body>
</html>
);
}
}
27 changes: 27 additions & 0 deletions app/lib/default-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { EmailTemplate } from "./email-schema";
import { v4 as uuid } from "uuid";

export const defaultTemplate: EmailTemplate = {
sections: [
{
id: uuid(),
columns: [
{
id: uuid(),
blocks: [
{
id: uuid(),
type: "text",
props: {
content: "Welcome to our newsletter 👋",
fontSize: "18px",
color: "#111111",
align: "center",
},
},
],
},
],
},
],
};
21 changes: 21 additions & 0 deletions app/lib/email-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type BlockType = "text" | "image" | "button";

export type Block = {
id: string;
type: BlockType;
props: Record<string, any>;
};

export type Column = {
id: string;
blocks: Block[];
};

export type Section = {
id: string;
columns: Column[];
};

export type EmailTemplate = {
sections: Section[];
};
43 changes: 43 additions & 0 deletions app/lib/render-mjml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { EmailTemplate } from "./email-schema";
// @ts-ignore
import mjml2html from "mjml";

export function renderMJML(template: EmailTemplate) {
const mjml = `
<mjml>
<mj-body>
${template.sections
.map(
(s) => `
<mj-section>
${s.columns
.map(
(c) => `
<mj-column>
${c.blocks
.map(
(b) => `
<mj-text
font-size="${b.props.fontSize}"
color="${b.props.color}"
align="${b.props.align}"
>
${b.props.content}
</mj-text>
`
)
.join("")}
</mj-column>
`
)
.join("")}
</mj-section>
`
)
.join("")}
</mj-body>
</mjml>
`;

return mjml2html(mjml).html;
}
28 changes: 28 additions & 0 deletions app/lib/update-block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { EmailTemplate } from "./email-schema";

export function updateBlockInTemplate(
template: EmailTemplate,
blockId: string,
newProps: Record<string, any>
): EmailTemplate {
return {
...template,
sections: template.sections.map((section) => ({
...section,
columns: section.columns.map((column) => ({
...column,
blocks: column.blocks.map((block) =>
block.id === blockId
? {
...block,
props: {
...block.props,
...newProps,
},
}
: block
),
})),
})),
};
}
72 changes: 9 additions & 63 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,65 +1,11 @@
import Image from "next/image";
"use client";

export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
import { useState } from "react";
import { EmailBuilder } from "./components/email-builder/EmailBuilder";
import { defaultTemplate } from "./lib/default-template";

export default function Page() {
const [template, setTemplate] = useState(defaultTemplate);

return <EmailBuilder value={template} onChange={setTemplate} />;
}
Loading