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