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