a tool for shared writing and social publishing
1"use client";
2import { callRPC } from "app/api/rpc/client";
3import { createPublication } from "./createPublication";
4import { ButtonPrimary } from "components/Buttons";
5import { AddSmall } from "components/Icons/AddSmall";
6import { useIdentityData } from "components/IdentityProvider";
7import { Input, InputWithLabel } from "components/Input";
8import { useRouter } from "next/navigation";
9import { useState, useRef, useEffect } from "react";
10import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
11import { theme } from "tailwind.config";
12import { getBasePublicationURL, getPublicationURL } from "./getPublicationURL";
13import { string } from "zod";
14import { DotLoader } from "components/utils/DotLoader";
15import { Checkbox } from "components/Checkbox";
16
17type DomainState =
18 | { status: "empty" }
19 | { status: "valid" }
20 | { status: "invalid" }
21 | { status: "pending" }
22 | { status: "error"; message: string };
23
24export const CreatePubForm = () => {
25 let [formState, setFormState] = useState<"normal" | "loading">("normal");
26 let [nameValue, setNameValue] = useState("");
27 let [descriptionValue, setDescriptionValue] = useState("");
28 let [showInDiscover, setShowInDiscover] = useState(true);
29 let [logoFile, setLogoFile] = useState<File | null>(null);
30 let [logoPreview, setLogoPreview] = useState<string | null>(null);
31 let [domainValue, setDomainValue] = useState("");
32 let [domainState, setDomainState] = useState<DomainState>({
33 status: "empty",
34 });
35 let fileInputRef = useRef<HTMLInputElement>(null);
36
37 let router = useRouter();
38 return (
39 <form
40 className="flex flex-col gap-3"
41 onSubmit={async (e) => {
42 if (formState !== "normal") return;
43 e.preventDefault();
44 if (!subdomainValidator.safeParse(domainValue).success) return;
45 setFormState("loading");
46 let data = await createPublication({
47 name: nameValue,
48 description: descriptionValue,
49 iconFile: logoFile,
50 subdomain: domainValue,
51 preferences: { showInDiscover, showComments: true },
52 });
53 // Show a spinner while this is happening! Maybe a progress bar?
54 setTimeout(() => {
55 setFormState("normal");
56 if (data?.publication)
57 router.push(`${getBasePublicationURL(data.publication)}/dashboard`);
58 }, 500);
59 }}
60 >
61 <div className="flex flex-col items-center mb-4 gap-2">
62 <div className="text-center text-secondary flex flex-col ">
63 <h3 className="-mb-1">Logo</h3>
64 <p className="italic text-tertiary">(optional)</p>
65 </div>
66 <div
67 className="w-24 h-24 rounded-full border-2 border-dotted border-accent-1 flex items-center justify-center cursor-pointer hover:border-accent-contrast"
68 onClick={() => fileInputRef.current?.click()}
69 >
70 {logoPreview ? (
71 <img
72 src={logoPreview}
73 alt="Logo preview"
74 className="w-full h-full rounded-full object-cover"
75 />
76 ) : (
77 <AddSmall className="text-accent-1" />
78 )}
79 </div>
80 <input
81 type="file"
82 accept="image/*"
83 className="hidden"
84 ref={fileInputRef}
85 onChange={(e) => {
86 const file = e.target.files?.[0];
87 if (file) {
88 setLogoFile(file);
89 const reader = new FileReader();
90 reader.onload = (e) => {
91 setLogoPreview(e.target?.result as string);
92 };
93 reader.readAsDataURL(file);
94 }
95 }}
96 />
97 </div>
98 <InputWithLabel
99 type="text"
100 id="pubName"
101 label="Publication Name"
102 value={nameValue}
103 onChange={(e) => {
104 setNameValue(e.currentTarget.value);
105 }}
106 />
107
108 <InputWithLabel
109 label="Description (optional)"
110 textarea
111 rows={3}
112 id="pubDescription"
113 value={descriptionValue}
114 onChange={(e) => {
115 setDescriptionValue(e.currentTarget.value);
116 }}
117 />
118 <DomainInput
119 domain={domainValue}
120 setDomain={setDomainValue}
121 domainState={domainState}
122 setDomainState={setDomainState}
123 />
124 <hr className="border-border-light" />
125 <Checkbox
126 checked={showInDiscover}
127 onChange={(e) => setShowInDiscover(e.target.checked)}
128 >
129 <div className=" pt-0.5 flex flex-col text-sm text-tertiary ">
130 <p className="font-bold italic">Show In Discover</p>
131 <p className="text-sm text-tertiary font-normal">
132 Your posts will appear on our{" "}
133 <a href="/discover" target="_blank">
134 Discover
135 </a>{" "}
136 page. You can change this at any time!
137 </p>
138 </div>
139 </Checkbox>
140 <hr className="border-border-light" />
141
142 <div className="flex w-full justify-end">
143 <ButtonPrimary
144 type="submit"
145 disabled={
146 !nameValue || !domainValue || domainState.status !== "valid"
147 }
148 >
149 {formState === "loading" ? <DotLoader /> : "Create Publication!"}
150 </ButtonPrimary>
151 </div>
152 </form>
153 );
154};
155
156let subdomainValidator = string()
157 .min(3)
158 .max(63)
159 .regex(/^[a-z0-9-]+$/);
160function DomainInput(props: {
161 domain: string;
162 setDomain: (d: string) => void;
163 domainState: DomainState;
164 setDomainState: (s: DomainState) => void;
165}) {
166 useEffect(() => {
167 if (!props.domain) {
168 props.setDomainState({ status: "empty" });
169 } else {
170 let valid = subdomainValidator.safeParse(props.domain);
171 if (!valid.success) {
172 let reason = valid.error.errors[0].code;
173 props.setDomainState({
174 status: "error",
175 message:
176 reason === "too_small"
177 ? "Must be at least 3 characters long"
178 : reason === "invalid_string"
179 ? "Must contain only lowercase a-z, 0-9, and -"
180 : "",
181 });
182 return;
183 }
184 props.setDomainState({ status: "pending" });
185 }
186 }, [props.domain]);
187
188 useDebouncedEffect(
189 async () => {
190 if (!props.domain) return props.setDomainState({ status: "empty" });
191
192 let valid = subdomainValidator.safeParse(props.domain);
193 if (!valid.success) {
194 return;
195 }
196 let status = await callRPC("get_leaflet_subdomain_status", {
197 domain: props.domain,
198 });
199 if (status.error === "Not Found")
200 props.setDomainState({ status: "valid" });
201 else props.setDomainState({ status: "invalid" });
202 },
203 500,
204 [props.domain],
205 );
206
207 return (
208 <div className="flex flex-col gap-1">
209 <label className=" input-with-border flex flex-col text-sm text-tertiary font-bold italic leading-tight py-1! px-[6px]!">
210 <div>Choose your domain</div>
211 <div className="flex flex-row items-center">
212 <Input
213 minLength={3}
214 maxLength={63}
215 placeholder="domain"
216 className="appearance-none w-full font-normal bg-transparent text-base text-primary focus:outline-0 outline-hidden"
217 value={props.domain}
218 onChange={(e) => props.setDomain(e.currentTarget.value)}
219 />
220 .leaflet.pub
221 </div>
222 </label>
223 <div
224 className={"text-sm italic "}
225 style={{
226 fontWeight: props.domainState.status === "valid" ? "bold" : "normal",
227 color:
228 props.domainState.status === "valid"
229 ? theme.colors["accent-contrast"]
230 : theme.colors.tertiary,
231 }}
232 >
233 {props.domainState.status === "valid"
234 ? "Available!"
235 : props.domainState.status === "error"
236 ? props.domainState.message
237 : props.domainState.status === "invalid"
238 ? "Already Taken ):"
239 : props.domainState.status === "pending"
240 ? "Checking Availability..."
241 : "a-z, 0-9, and - only!"}
242 </div>
243 </div>
244 );
245}