This commit is contained in:
Jordi Enric
2024-02-10 20:23:04 +01:00
parent 6630f0279c
commit 40026e8e26
14 changed files with 1436 additions and 207 deletions
+1
View File
@@ -9,5 +9,6 @@ const config = {
reactStrictMode: true,
appDir: true,
transpilePackages: [ "react-syntax-highlighter" ],
appRootSelector: "main"
};
export default config;
+2
View File
@@ -10,6 +10,7 @@
"supabase:typegen": "npx supabase gen types typescript --project-id ppfseefimhneysnokffx --schema public > src/types/supabase.ts"
},
"dependencies": {
"@builder.io/react-hydration-overlay": "^0.0.8",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@groundcontrolsh/groundcontrol": "^0.3.0",
@@ -35,6 +36,7 @@
"@tiptap/extension-horizontal-rule": "^2.1.12",
"@tiptap/extension-image": "^2.1.12",
"@tiptap/extension-link": "^2.2.2",
"@tiptap/extension-placeholder": "^2.2.2",
"@tiptap/pm": "^2.0.3",
"@tiptap/react": "^2.0.3",
"@tiptap/starter-kit": "^2.0.3",
@@ -9,6 +9,8 @@ import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { useMutation, useQuery } from "@tanstack/react-query";
import { HiTrash } from "react-icons/hi";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
export function Invitations({ blog }: { blog: Blog }) {
const router = useRouter();
const blogId = router.query.blogId as string;
@@ -61,7 +63,7 @@ export function Invitations({ blog }: { blog: Blog }) {
{invitations.data?.map((invitation) => (
<li
key={invitation.id}
className="flex justify-between gap-4 rounded-md border"
className="flex items-center justify-between gap-4 rounded-xl border px-1"
>
<ul className="flex flex-grow items-center gap-4">
<li className="w-3/4 p-2 font-semibold">{invitation.name}</li>
@@ -72,24 +74,25 @@ export function Invitations({ blog }: { blog: Blog }) {
</span>
</li>
</ul>
<button
className="btn btn-icon"
<Button
variant={"ghost"}
size={"icon"}
onClick={() => onDeleteClick(invitation.id)}
>
<HiTrash />
</button>
</Button>
</li>
))}
</ul>
<form className="flex flex-col gap-2" onSubmit={onSubmit}>
<h3 className="mt-4 font-medium">Invite people to {blog.title}</h3>
<div className="flex gap-4">
<input
<Input
type="text"
placeholder="Name"
{...invitationForm.register("name")}
/>
<input
<Input
type="text"
placeholder="Email"
{...invitationForm.register("email")}
@@ -97,9 +100,7 @@ export function Invitations({ blog }: { blog: Blog }) {
</div>
<div className="actions">
<button type="submit" className="btn btn-primary">
Invite team member
</button>
<Button type="submit">Invite team member</Button>
</div>
</form>
</div>
+27 -25
View File
@@ -45,12 +45,12 @@ function EditorMenuButton({
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
variant={"ghost"}
tabIndex={-1}
variant={"ghost"}
type="button"
className={cn(
"rounded-md p-2 text-zinc-400",
active ? "text-zinc-500" : "",
active ? "bg-zinc-100 text-zinc-800" : "text-zinc-400",
props.disabled ? "" : "hover:bg-zinc-100/80 hover:text-zinc-600"
)}
{...props}
@@ -67,16 +67,6 @@ function EditorMenuButton({
export function EditorMenu({ editor }: { editor: Editor | null }) {
const SIZE = 18;
function getOSMetaKey() {
const isMac = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
return isMac ? "Cmd" : "Ctrl";
}
function getMenuTooltip(command: string) {
const key = getOSMetaKey();
return command.replace("Meta", key);
}
const Separator = {
icon: <Dot size={SIZE} />,
disabled: true,
@@ -88,35 +78,41 @@ export function EditorMenu({ editor }: { editor: Editor | null }) {
command: () => void;
disabled?: boolean;
tooltip?: string;
active?: boolean;
}[] = [
{
tooltip: getMenuTooltip("Bold (Meta+B)"),
tooltip: "Bold (Cmd+B)",
icon: <BoldIcon size={SIZE} />,
command: () => editor?.chain().focus().toggleBold().run(),
active: editor?.isActive("bold"),
},
{
tooltip: getMenuTooltip("Italic (Meta+I)"),
tooltip: "Italic (Cmd+I)",
icon: <ItalicIcon size={SIZE} />,
command: () => editor?.chain().focus().toggleItalic().run(),
active: editor?.isActive("italic"),
},
{
tooltip: getMenuTooltip("Strikethrough"),
tooltip: "Strikethrough",
icon: <Strikethrough size={SIZE} />,
command: () => editor?.chain().focus().toggleStrike().run(),
active: editor?.isActive("strike"),
},
{
tooltip: getMenuTooltip("Code (Meta+E)"),
tooltip: "Code (Cmd+E)",
icon: <CodeIcon size={SIZE} />,
command: () => editor?.chain().focus().toggleCode().run(),
active: editor?.isActive("code"),
},
{
tooltip: getMenuTooltip("Code Block"),
tooltip: "Code Block",
icon: <PiCodeBlock size={SIZE} />,
command: () => editor?.chain().focus().toggleCodeBlock().run(),
active: editor?.isActive("codeBlock"),
},
Separator,
{
tooltip: getMenuTooltip("Link"),
tooltip: "Link",
icon: <Link size={SIZE} />,
command: () => {
const url = window.prompt("Enter the URL");
@@ -124,16 +120,19 @@ export function EditorMenu({ editor }: { editor: Editor | null }) {
editor?.chain().focus().setLink({ href: url }).run();
}
},
active: editor?.isActive("link"),
},
{
tooltip: getMenuTooltip("List"),
tooltip: "List",
icon: <ListIcon size={SIZE} />,
command: () => editor?.chain().focus().toggleBulletList().run(),
active: editor?.isActive("bulletList"),
},
{
tooltip: getMenuTooltip("Numbered List"),
tooltip: "Numbered List",
icon: <PiListNumbers size={SIZE} />,
command: () => editor?.chain().focus().toggleOrderedList().run(),
active: editor?.isActive("orderedList"),
},
];
@@ -171,15 +170,18 @@ export function EditorMenu({ editor }: { editor: Editor | null }) {
];
return (
<div className="flex rounded-lg border border-zinc-200 bg-white p-1 shadow-sm">
<div
tabIndex={-1}
className="inline-flex rounded-xl border border-zinc-200 bg-white p-1 shadow-sm"
>
<DropdownMenu>
<DropdownMenuTrigger>
<DropdownMenuTrigger tabIndex={-1} asChild>
<Button variant={"ghost"} className="w-24 text-zinc-500">
{editor?.isFocused ||
editor?.isActive("paragraph") ||
editor?.isActive("heading")
? editor.isActive("heading")
? "Heading"
? `Heading ${editor.getAttributes("heading").level}`
: "Paragraph"
: "Type"}
</Button>
@@ -197,10 +199,10 @@ export function EditorMenu({ editor }: { editor: Editor | null }) {
))}
</DropdownMenuContent>
</DropdownMenu>
{menuButtons.map(({ icon, command, disabled, tooltip }, i) => (
{menuButtons.map(({ icon, command, disabled, tooltip, active }, i) => (
<EditorMenuButton
disabled={disabled}
active={editor?.isActive(command) || false}
active={active || false}
key={i + "menu-btn"}
onClick={command}
tooltip={tooltip}
@@ -34,6 +34,7 @@ import { IoClose } from "react-icons/io5";
import { Sheet, SheetContent, SheetTrigger } from "../ui/sheet";
import EditorSettings from "./EditorSettings";
import TiptapLink from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
const formSchema = z.object({
title: z.string(),
@@ -63,6 +64,7 @@ type Props = {
published: boolean;
cover_image?: string;
content?: any;
metadata?: Record<string, any>;
};
};
@@ -122,6 +124,9 @@ export const ZendoEditor = (props: Props) => {
},
}),
TiptapLink,
Placeholder.configure({
placeholder: "Start writing...",
}),
TiptapImage.extend({
addProseMirrorPlugins() {
return [UploadImagesPlugin()];
@@ -132,6 +137,8 @@ export const ZendoEditor = (props: Props) => {
const formSubmit = handleSubmit(async (data) => {
const content = editor?.getJSON() || {};
const slugHasChanged = data.slug !== props.post?.slug;
props.onSave({
content,
title: data.title,
@@ -139,6 +146,10 @@ export const ZendoEditor = (props: Props) => {
cover_image: data.cover_image || "",
published: data.published,
});
if (slugHasChanged) {
router.push(`/blogs/${blogId}/post/${data.slug}`);
}
});
function onCoverImageSelect(image: string) {
@@ -147,10 +158,10 @@ export const ZendoEditor = (props: Props) => {
}
return (
<>
<div className="bg-zinc-50 pb-40">
<form
onSubmit={formSubmit}
className="sticky top-0 z-20 flex w-full items-center justify-between border-b bg-white px-3 py-1.5"
className="sticky top-0 z-20 flex w-full items-center justify-between border-b bg-zinc-50 px-3 py-1.5 text-zinc-800"
>
<div className="flex items-center gap-1 rounded-xl text-sm font-medium tracking-tight text-zinc-800">
{blogsQuery.isLoading ? null : (
@@ -202,10 +213,10 @@ export const ZendoEditor = (props: Props) => {
{postsQuery.isLoading ? null : (
<DropdownMenu>
<DropdownMenuTrigger>
<DropdownMenuTrigger asChild>
<Button variant={"ghost"}>
<div className="flex items-center gap-1.5 rounded-md p-1.5 hover:bg-zinc-100">
<span>{props.post?.title || title || ""}</span>
<span>{props.post?.title || title || "New post"}</span>
</div>
</Button>
</DropdownMenuTrigger>
@@ -242,7 +253,7 @@ export const ZendoEditor = (props: Props) => {
</Label>
<Sheet>
<SheetTrigger asChild>
<Button type="button" title="post settings" variant="secondary">
<Button type="button" title="post settings" variant="ghost">
<Settings2 size={16} /> Settings
</Button>
</SheetTrigger>
@@ -260,7 +271,7 @@ export const ZendoEditor = (props: Props) => {
</Button>
</div>
</form>
<div className="mx-auto mt-2 flex w-full max-w-2xl flex-col px-2">
<div className="mx-auto mt-2 flex w-full max-w-2xl flex-col px-2 pb-40">
<div className="relative mt-2 flex items-center justify-center bg-zinc-100">
{coverImgUrl && (
<button
@@ -283,7 +294,7 @@ export const ZendoEditor = (props: Props) => {
placeholder="a-great-title"
type="text"
{...register("slug")}
className="w-full rounded-lg p-2 font-mono text-xs outline-none hover:bg-zinc-50 focus-visible:bg-zinc-100"
className="w-full rounded-lg bg-transparent p-2 font-mono text-xs text-zinc-700 outline-none transition-all hover:bg-white focus-visible:bg-white focus-visible:shadow-sm"
autoComplete="off"
/>
</label>
@@ -296,36 +307,41 @@ export const ZendoEditor = (props: Props) => {
}}
onCancel={() => {}}
>
<Button asChild variant={"secondary"} className="h-8">
<span className="btn btn-text whitespace-nowrap !text-xs">
<BsFillImageFill className="h-4 w-4" />
<Button
variant={"ghost"}
className="text-zinc-600 hover:bg-white"
>
<span className="flex gap-1.5 whitespace-nowrap !text-xs">
<BsFillImageFill className="h-4 w-4 text-zinc-400" />
Cover image
</span>
</Button>
</ImagePicker>
</div>
<input
<textarea
placeholder="A great title"
type="text"
{...register("title")}
className="mt-1 w-full max-w-2xl whitespace-break-spaces rounded-xl border-none bg-transparent p-2
style={{ resize: "none" }}
className="mt-1 w-full max-w-2xl rounded-xl border-none bg-transparent p-2
text-4xl
font-medium outline-none transition-all hover:bg-zinc-50 focus-visible:bg-zinc-100"
font-medium text-zinc-800 outline-none transition-all hover:bg-white focus-visible:bg-white focus-visible:shadow-sm"
/>
</div>
<div className="prose prose-h2:font-medium group">
<div className="group">
<div className="sticky top-14 z-10">
<EditorMenu editor={editor} />
</div>
<div
onClick={() => editor?.chain().focus().toggleBold().run()}
className="prose prose-p:text-lg -mt-2 min-h-[700px] cursor-text rounded-lg transition-all focus-within:bg-zinc-50 hover:bg-zinc-50"
onClick={() => {
editor?.commands.focus();
}}
className="prose prose-p:text-lg prose-h2:font-semibold -mt-2 min-h-[700px] cursor-text rounded-lg py-1.5 font-light leading-10 tracking-tight transition-all"
>
<EditorContent className="" editor={editor} />
<EditorContent editor={editor} />
</div>
</div>
</div>
</>
</div>
);
};
@@ -50,7 +50,7 @@ export function ImagePicker({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger>{children}</DialogTrigger>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
+1 -1
View File
@@ -16,7 +16,7 @@ const buttonVariants = cva(
outline:
"border border-zinc-200 bg-white hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
secondary:
"bg-zinc-100 text-zinc-700 hover:bg-zinc-200/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80",
"bg-zinc-100 text-zinc-700 hover:bg-orange-100/70 hover:text-orange-600 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80",
ghost:
"hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
link: "text-zinc-800 underline-offset-4 hover:underline dark:text-zinc-50",
-3
View File
@@ -23,14 +23,11 @@ export function createAPIClient() {
const URL_START = "/api/v1";
const res = await fetch(URL_START + input, { ...init, headers });
console.log(`[🛫 Req ${init.method} ${input}] `, init.body);
if (!res.ok) {
console.error(`[🚨 ${init.method} ${input}] `, res.statusText);
throw new Error(res.statusText);
}
const data = await res.json();
console.log(`[✅ Res ${init.method} ${input}] `, data);
const result = await type.safeParseAsync(data);
-1
View File
@@ -2,7 +2,6 @@ import type { AppProps } from "next/app";
import { Inter, IBM_Plex_Mono } from "next/font/google";
import "@/styles/globals.css";
import { useRouter } from "next/router";
import { motion } from "framer-motion";
import PlausibleProvider from "next-plausible";
import {
Hydrate,
@@ -139,7 +139,7 @@ export default function Post() {
);
}
return (
<div className="flex min-h-screen w-full flex-col bg-white pb-32">
<div className="flex min-h-screen w-full flex-col">
<ZendoEditor
onSave={async (content) => {
try {
+1 -57
View File
@@ -1,16 +1,6 @@
import Head from "next/head";
import Link from "next/link";
import {
FaChartLine,
FaCode,
FaImage,
FaImages,
FaRegChartBar,
FaTwitter,
} from "react-icons/fa";
import { SiTypescript } from "react-icons/si";
import { Command } from "lucide-react";
import { CgTrees } from "react-icons/cg";
import { FaTwitter } from "react-icons/fa";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { getClientClient } from "@/lib/supabase";
@@ -22,52 +12,6 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
const Home = () => {
const features = [
{
title: "Type safety",
description:
"Fully typed API client that you can use in your app to fetch your content. No GraphQL.",
icon: <SiTypescript size="24" className="text-orange-500" />,
},
{
title: "Cmd + V Images",
description:
"Imagine having to open the system file picker just to upload an image.",
icon: <FaImages size="24" className="text-orange-500" />,
},
{
title: "SEO",
description: "Easily manage SEO metadata for your posts.",
icon: <FaChartLine size="24" className="text-orange-500" />,
},
{
title: "Open graph images",
description: "Automatic generation of open graph images for your posts.",
icon: <FaImage size="24" className="text-orange-500" />,
},
{
title: "Cmd + K",
description: "A modern dashboard that you can navigate easily.",
icon: <Command className="text-orange-500" />,
},
{
title: "Unlimited blogs",
description:
"It doesn't matter how many blogs you have. Simple usage based pricing.",
icon: <CgTrees size="24" className="text-orange-500" />,
},
{
title: "Analytics",
description: "Easily find out how many page views your blog posts have.",
icon: <FaRegChartBar size="24" className="text-orange-500" />,
},
{
title: "Open Source",
description: "Built with Next.js and Supabase.",
icon: <FaCode size="24" className="text-orange-500" />,
},
];
const user = useUser();
useEffect(() => {
+12 -3
View File
@@ -17,10 +17,11 @@
}
.ProseMirror p > code {
background-color: theme("colors.orange.50");
background-color: theme("colors.zinc.100");
border: 1px solid theme("colors.zinc.200");
border-radius: 4px;
padding: 0.2em 0.4em;
color: theme("colors.orange.500");
padding: 0.2em;
color: theme("colors.zinc.700");
letter-spacing: 0.4px;
}
.ProseMirror p > code::after,
@@ -59,3 +60,11 @@ textarea:active {
border: none;
outline: none;
}
.tiptap .is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
+1335 -85
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -1,13 +1,21 @@
# To Do
## IMPORTANT
- [] First time a user logs in, they should be prompted to add a password and reload the session.
- [] Make layout work on mobile
- [] Make inputs not zoom in on mobile
## Ideas
- [] Allow users to add default metadata for posts in a blog.
- [] Add PolyScale cache to Supabase for faster queries?
- [] Categories
- [] Tags
- [] Featured Images
- [] Excerpts
- [] Authors
- [] Analytics
## Blogs
@@ -59,8 +67,8 @@
## Docs
- [] Add documentation for API client
- [] Copypastable Zenblog Renderer component
- [] Add documentation for integrating with Zenblog
- [] Copypastable Zenblog Renderer component similar to shadcn
## User Feedback