Skip to content

Commit dd9893d

Browse files
committed
metadata wip
1 parent 5f32f66 commit dd9893d

File tree

13 files changed

+445
-169
lines changed

13 files changed

+445
-169
lines changed

apps/web/lib/is-dev.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const IS_DEV = process.env.NODE_ENV === "development";
11.4 KB
Loading
27.3 KB
Loading
18.8 KB
Loading

apps/web/src/components/Editor/ZendoEditor.tsx

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,19 @@ import { Skeleton } from "../ui/skeleton";
6666
import { CreateAuthorDialog } from "@/pages/blogs/[blogId]/authors";
6767
import { useSubscriptionQuery } from "@/queries/subscription";
6868
import Head from "next/head";
69+
import { PostMetadataEditor } from "../post-metadata-editor";
70+
import {
71+
Dialog,
72+
DialogContent,
73+
DialogDescription,
74+
DialogHeader,
75+
DialogTitle,
76+
DialogTrigger,
77+
} from "../ui/dialog";
78+
import { useQueryClient } from "@tanstack/react-query";
79+
import { createSupabaseBrowserClient } from "@/lib/supabase";
80+
import { IsDevMode } from "../is-dev-mode";
81+
import { IS_DEV } from "@/lib/constants";
6982

7083
const formSchema = z.object({
7184
title: z.string(),
@@ -111,6 +124,8 @@ type Props = {
111124

112125
export const ZendoEditor = (props: Props) => {
113126
const editorLoading = props.loading || false;
127+
const queryClient = useQueryClient();
128+
const supa = createSupabaseBrowserClient();
114129
const { register, handleSubmit, setValue, watch, getValues } =
115130
useForm<FormData>({
116131
defaultValues: {
@@ -138,7 +153,9 @@ export const ZendoEditor = (props: Props) => {
138153
props.authors?.map((a) => a.id) || []
139154
);
140155

141-
const [metadata, setMetadata] = React.useState(props.post?.metadata || []);
156+
const [metadata, setMetadata] = React.useState<Record<string, string>>(
157+
(props.post?.meta as any) || {}
158+
);
142159
const today = new Date().toISOString();
143160
const [publishedAt, setPublishedAt] = React.useState<string | undefined>(
144161
props.post?.published_at || today
@@ -434,6 +451,10 @@ export const ZendoEditor = (props: Props) => {
434451
});
435452
});
436453

454+
// METADATA STATE
455+
const [isMetadataDialogOpen, setMetadataDialogOpen] = useState(false);
456+
457+
//
437458
useEffect(() => {
438459
// on cmd + enter or cmd + s save the post
439460
const handleSave = (e: KeyboardEvent) => {
@@ -659,7 +680,7 @@ export const ZendoEditor = (props: Props) => {
659680
animate={{ height: "auto", opacity: 1 }}
660681
exit={{ height: 0, opacity: 0 }}
661682
transition={{ duration: 0.2, ease: "easeInOut" }}
662-
className="mt-6 grid grid-cols-4 gap-2 gap-y-2 overflow-hidden text-zinc-500"
683+
className="mt-6 grid grid-cols-4 gap-y-2 overflow-hidden text-zinc-500"
663684
>
664685
<EditorPropLabel
665686
className="items-center"
@@ -807,6 +828,54 @@ export const ZendoEditor = (props: Props) => {
807828
</div>
808829
</div>
809830
</EditorPropValue>
831+
{IS_DEV && (
832+
<>
833+
<EditorPropLabel tooltip="Custom metadata for your post.">
834+
Metadata
835+
</EditorPropLabel>
836+
<EditorPropValue className="mx-0 flex flex-col gap-2 px-0">
837+
<Dialog
838+
open={isMetadataDialogOpen}
839+
onOpenChange={setMetadataDialogOpen}
840+
>
841+
<DialogTrigger className="flex h-full w-full items-center px-3 text-left text-xs font-semibold">
842+
{metadata && Object.keys(metadata).length > 0 ? (
843+
<div className="font-semibol flex h-full w-full flex-col py-1 text-left text-xs">
844+
{Object.keys(metadata).map((key) => (
845+
<div className="flex gap-2" key={key}>
846+
<span className="w-1/4 truncate text-zinc-500">
847+
{key}
848+
</span>
849+
<span className="truncate text-zinc-500">
850+
{metadata[key]}
851+
</span>
852+
</div>
853+
))}
854+
</div>
855+
) : (
856+
"Edit"
857+
)}
858+
</DialogTrigger>
859+
<DialogContent>
860+
<DialogHeader>
861+
<DialogTitle>Custom Metadata</DialogTitle>
862+
<DialogDescription>
863+
Add any custom metadata you need to your post.
864+
This will be received in the post payload.
865+
</DialogDescription>
866+
</DialogHeader>
867+
<PostMetadataEditor
868+
metadata={metadata}
869+
onSubmit={(newMetadata) => {
870+
setMetadata(newMetadata);
871+
setMetadataDialogOpen(false);
872+
}}
873+
/>
874+
</DialogContent>
875+
</Dialog>
876+
</EditorPropValue>
877+
</>
878+
)}
810879
</motion.div>
811880
)}
812881
</AnimatePresence>

apps/web/src/components/is-dev-mode.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,7 @@ export const IsDevMode = ({ children }: PropsWithChildren) => {
88
}
99

1010
return (
11-
<div className="rounded-md border-2 border-dashed border-yellow-300 p-2">
12-
<span className="text-xs font-medium text-yellow-600">
13-
Development Tip:
14-
</span>
11+
<div className="w-full rounded-md border-2 border-dashed border-yellow-300 p-2">
1512
{children}
1613
</div>
1714
);
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { Button } from "@/components/ui/button";
2+
import { Input } from "@/components/ui/input";
3+
import { cn } from "@/lib/utils";
4+
import { GET } from "app/api/public/[...route]/route";
5+
import { PlusIcon, XIcon } from "lucide-react";
6+
import { useEffect, useRef, useState } from "react";
7+
import { z } from "zod";
8+
9+
const fieldsSchema = z
10+
.array(
11+
z.object({
12+
id: z.string(),
13+
key: z.string().regex(/^[a-zA-Z]*$/, {
14+
message: "Key must only contain letters (a-z, A-Z)",
15+
}),
16+
value: z.string(),
17+
})
18+
)
19+
.superRefine((fields, ctx) => {
20+
const keyMap = new Map<string, number[]>();
21+
fields.forEach((field, index) => {
22+
if (field.key) {
23+
const lowerCaseKey = field.key.toLowerCase();
24+
if (!keyMap.has(lowerCaseKey)) {
25+
keyMap.set(lowerCaseKey, []);
26+
}
27+
keyMap.get(lowerCaseKey)!.push(index);
28+
}
29+
});
30+
31+
for (const [key, indices] of keyMap.entries()) {
32+
if (indices.length > 1) {
33+
indices.forEach((index) => {
34+
ctx.addIssue({
35+
code: "custom",
36+
path: [index, "key"],
37+
message: `Duplicate key "${key}"`,
38+
});
39+
});
40+
}
41+
}
42+
});
43+
44+
export function PostMetadataEditor({
45+
metadata,
46+
onSubmit,
47+
}: {
48+
metadata: Record<string, string> | null;
49+
onSubmit: (metadata: Record<string, string>) => void;
50+
}) {
51+
const [fields, setFields] = useState<
52+
{ id: string; key: string; value: string }[]
53+
>([]);
54+
55+
const [errors, setErrors] = useState<z.ZodFormattedError<
56+
{ id: string; key: string; value: string }[],
57+
string
58+
> | null>(null);
59+
60+
const keyInputRef = useRef<HTMLInputElement>(null);
61+
62+
// initial fields
63+
useEffect(() => {
64+
console.log("metadata", metadata);
65+
if (metadata && Object.keys(metadata).length > 0) {
66+
setFields(
67+
Object.entries(metadata).map(([key, value]) => ({
68+
id: `id_${Math.random().toString(36).substr(2, 9)}`,
69+
key,
70+
value,
71+
}))
72+
);
73+
} else {
74+
setFields([{ id: "1", key: "", value: "" }]);
75+
}
76+
}, [metadata]);
77+
78+
const handleAddField = () => {
79+
setFields([
80+
...fields,
81+
{
82+
id: `id_${Math.random().toString(36).substr(2, 9)}`,
83+
key: "",
84+
value: "",
85+
},
86+
]);
87+
// Focus the new key input after state update
88+
setTimeout(() => {
89+
keyInputRef.current?.focus();
90+
}, 0);
91+
};
92+
93+
const handleRemoveField = (id: string) => {
94+
setFields(fields.filter((field) => field.id !== id));
95+
};
96+
97+
const handleFieldChange = (
98+
id: string,
99+
part: "key" | "value",
100+
value: string
101+
) => {
102+
setFields(
103+
fields.map((field) =>
104+
field.id === id ? { ...field, [part]: value } : field
105+
)
106+
);
107+
};
108+
109+
function fieldsToMetadata(
110+
fields: { id: string; key: string; value: string }[]
111+
) {
112+
let metadata: Record<string, string> = {};
113+
114+
fields.forEach((field) => {
115+
if (field.key) {
116+
metadata[field.key] = field.value;
117+
}
118+
});
119+
120+
return metadata;
121+
}
122+
123+
function getLastField() {
124+
if (fields.length === 0) return null;
125+
return fields[fields.length - 1];
126+
}
127+
const LAST_FIELD_KEY = getLastField()?.key || "";
128+
const LAST_FIELD_VALUE = getLastField()?.value || "";
129+
130+
return (
131+
<div className="space-y-4">
132+
<div className="flex items-center space-x-2">
133+
<h3 className="w-full max-w-44 text-sm font-medium">Key</h3>
134+
<h3 className="w-full text-sm font-medium">Value</h3>
135+
</div>
136+
{fields.map((field, index) => (
137+
<div key={field.id} className="flex items-start space-x-2">
138+
<div className="w-full max-w-44">
139+
<Input
140+
ref={index === fields.length - 1 ? keyInputRef : undefined}
141+
placeholder="Key"
142+
value={field.key}
143+
onChange={(e) =>
144+
handleFieldChange(field.id, "key", e.target.value)
145+
}
146+
className="font-mono"
147+
/>
148+
{errors?.[index]?.key?._errors[0] && (
149+
<p className="mt-1 text-xs text-red-500">
150+
{errors[index]?.key?._errors[0]}
151+
</p>
152+
)}
153+
</div>
154+
155+
<div className="flex-1">
156+
<Input
157+
placeholder="Value"
158+
value={field.value}
159+
onChange={(e) =>
160+
handleFieldChange(field.id, "value", e.target.value)
161+
}
162+
/>
163+
{errors?.[index]?.value?._errors[0] && (
164+
<p className="mt-1 text-xs text-red-500">
165+
{errors[index]?.value?._errors[0]}
166+
</p>
167+
)}
168+
</div>
169+
<Button
170+
variant="ghost"
171+
size="icon"
172+
className="size-6 shrink-0 px-1.5"
173+
onClick={() => handleRemoveField(field.id)}
174+
>
175+
<XIcon />
176+
</Button>
177+
</div>
178+
))}
179+
<div className="space-y-1">
180+
<div className="flex items-center">
181+
<Button
182+
variant="outline"
183+
onClick={handleAddField}
184+
disabled={!LAST_FIELD_KEY || !LAST_FIELD_VALUE}
185+
>
186+
<PlusIcon size={14} />
187+
Add Metadata
188+
</Button>
189+
</div>
190+
191+
<p
192+
className={cn("text-xs text-gray-500", {
193+
"opacity-0": LAST_FIELD_KEY && LAST_FIELD_VALUE,
194+
})}
195+
>
196+
Fill in the existing fields before adding more
197+
</p>
198+
</div>
199+
{fields.length > 0 ? (
200+
<div>
201+
<h3 className="text-sm font-medium">Preview</h3>
202+
<pre className="mt-2 min-h-[120px] rounded-md bg-slate-100 p-4 font-mono text-xs dark:bg-slate-800">
203+
<code>{JSON.stringify(fieldsToMetadata(fields), null, 2)}</code>
204+
</pre>
205+
</div>
206+
) : null}
207+
<div className="flex items-center justify-end space-x-2 p-2">
208+
<Button
209+
onClick={() => {
210+
const result = fieldsSchema.safeParse(fields);
211+
if (!result.success) {
212+
setErrors(result.error.format());
213+
return;
214+
}
215+
216+
setErrors(null);
217+
onSubmit(fieldsToMetadata(fields));
218+
}}
219+
>
220+
Save Metadata
221+
</Button>
222+
</div>
223+
</div>
224+
);
225+
}

apps/web/src/layouts/AppLayout.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ import UserButton from "@/components/UserButton";
22
import { ZendoLogo } from "@/components/ZendoLogo";
33
import Link from "next/link";
44
import { motion } from "framer-motion";
5-
import Notifications from "@/components/Notifications";
65
import Feedback from "@/components/Feedback";
76
import Footer from "@/components/Footer";
87
import AppChecks from "@/components/LoggedInUserChecks";
9-
import { Loader, Loader2 } from "lucide-react";
8+
import { Loader2 } from "lucide-react";
109
import { useUser } from "@/utils/supabase/browser";
1110
import { useRouter } from "next/router";
1211
import { useEffect } from "react";

apps/web/src/pages/_app.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { AppProps } from "next/app";
2-
import { Inter, IBM_Plex_Mono } from "next/font/google";
2+
import { Inter } from "next/font/google";
33
import "@/styles/globals.css";
44
import { useRouter } from "next/router";
55
import PlausibleProvider from "next-plausible";
@@ -20,13 +20,6 @@ const inter = Inter({
2020
variable: "--font-sans",
2121
});
2222

23-
const ibmPlexMono = IBM_Plex_Mono({
24-
subsets: ["latin"],
25-
weight: ["400", "500", "600"],
26-
display: "swap",
27-
variable: "--font-mono",
28-
});
29-
3023
// Main Component
3124
function MyApp({ Component, pageProps }: AppProps) {
3225
const { pathname, isReady } = useRouter();
@@ -42,7 +35,7 @@ function MyApp({ Component, pageProps }: AppProps) {
4235
);
4336

4437
return (
45-
<div className={`${ibmPlexMono.variable} ${inter.variable} font-sans`}>
38+
<div className={`${inter.variable}`}>
4639
<UserProvider>
4740
<PlausibleProvider domain="zenblog.com">
4841
<QueryClientProvider client={queryClient}>

0 commit comments

Comments
 (0)