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">
131 Show In{" "}
132 <a href="/discover" target="_blank">
133 Discover
134 </a>
135 </p>
136 <p className="text-sm text-tertiary font-normal">
137 You'll be able to change this later!
138 </p>
139 </div>
140 </Checkbox>
141 <hr className="border-border-light" />
142
143 <div className="flex w-full justify-center">
144 <ButtonPrimary
145 type="submit"
146 disabled={
147 !nameValue || !domainValue || domainState.status !== "valid"
148 }
149 >
150 {formState === "loading" ? <DotLoader /> : "Create Publication!"}
151 </ButtonPrimary>
152 </div>
153 </form>
154 );
155};
156
157let subdomainValidator = string()
158 .min(3)
159 .max(63)
160 .regex(/^[a-z0-9-]+$/);
161function DomainInput(props: {
162 domain: string;
163 setDomain: (d: string) => void;
164 domainState: DomainState;
165 setDomainState: (s: DomainState) => void;
166}) {
167 useEffect(() => {
168 if (!props.domain) {
169 props.setDomainState({ status: "empty" });
170 } else {
171 let valid = subdomainValidator.safeParse(props.domain);
172 if (!valid.success) {
173 let reason = valid.error.errors[0].code;
174 props.setDomainState({
175 status: "error",
176 message:
177 reason === "too_small"
178 ? "Must be at least 3 characters long"
179 : reason === "invalid_string"
180 ? "Must contain only lowercase a-z, 0-9, and -"
181 : "",
182 });
183 return;
184 }
185 props.setDomainState({ status: "pending" });
186 }
187 }, [props.domain]);
188
189 useDebouncedEffect(
190 async () => {
191 if (!props.domain) return props.setDomainState({ status: "empty" });
192
193 let valid = subdomainValidator.safeParse(props.domain);
194 if (!valid.success) {
195 return;
196 }
197 let status = await callRPC("get_leaflet_subdomain_status", {
198 domain: props.domain,
199 });
200 if (status.error === "Not Found")
201 props.setDomainState({ status: "valid" });
202 else props.setDomainState({ status: "invalid" });
203 },
204 500,
205 [props.domain],
206 );
207
208 return (
209 <div className="flex flex-col gap-1">
210 <label className=" input-with-border flex flex-col text-sm text-tertiary font-bold italic leading-tight py-1! px-[6px]!">
211 <div>Choose your domain</div>
212 <div className="flex flex-row items-center">
213 <Input
214 minLength={3}
215 maxLength={63}
216 placeholder="domain"
217 className="appearance-none w-full font-normal bg-transparent text-base text-primary focus:outline-0 outline-hidden"
218 value={props.domain}
219 onChange={(e) => props.setDomain(e.currentTarget.value)}
220 />
221 .leaflet.pub
222 </div>
223 </label>
224 <div
225 className={"text-sm italic "}
226 style={{
227 fontWeight: props.domainState.status === "valid" ? "bold" : "normal",
228 color:
229 props.domainState.status === "valid"
230 ? theme.colors["accent-contrast"]
231 : theme.colors.tertiary,
232 }}
233 >
234 {props.domainState.status === "valid"
235 ? "Available!"
236 : props.domainState.status === "error"
237 ? props.domainState.message
238 : props.domainState.status === "invalid"
239 ? "Already Taken ):"
240 : props.domainState.status === "pending"
241 ? "Checking Availability..."
242 : "a-z, 0-9, and - only!"}
243 </div>
244 </div>
245 );
246}