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