a tool for shared writing and social publishing
1import { useEntitySetContext } from "components/EntitySetProvider";
2import { generateKeyBetween } from "fractional-indexing";
3import { useCallback, useEffect, useState } from "react";
4import { useEntity, useReplicache } from "src/replicache";
5import { useUIState } from "src/useUIState";
6import { BlockProps, BlockLayout } from "./Block";
7import { v7 } from "uuid";
8import { useSmoker } from "components/Toast";
9
10import { Separator } from "components/Layout";
11import { Input } from "components/Input";
12import { isUrl } from "src/utils/isURL";
13import { ButtonPrimary } from "components/Buttons";
14import { BlockButtonSmall } from "components/Icons/BlockButtonSmall";
15import { CheckTiny } from "components/Icons/CheckTiny";
16import { LinkSmall } from "components/Icons/LinkSmall";
17
18export const ButtonBlock = (props: BlockProps & { preview?: boolean }) => {
19 let { permissions } = useEntitySetContext();
20
21 let text = useEntity(props.entityID, "button/text");
22 let url = useEntity(props.entityID, "button/url");
23
24 let isSelected = useUIState((s) =>
25 s.selectedBlocks.find((b) => b.value === props.entityID),
26 );
27 let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value;
28
29 if (!url) {
30 if (!permissions.write) return null;
31 return <ButtonBlockSettings {...props} />;
32 }
33
34 return (
35 <BlockLayout
36 isSelected={!!isSelected}
37 borderOnHover
38 hasAlignment={alignment !== "justify"}
39 className={`p-0! rounded-md! border-none!`}
40 >
41 <a
42 href={url?.data.value}
43 target="_blank"
44 className={` ${alignment === "justify" ? "w-full" : "w-fit"}`}
45 >
46 <ButtonPrimary
47 role="link"
48 type="submit"
49 fullWidth={alignment === "justify"}
50 >
51 {text?.data.value}
52 </ButtonPrimary>
53 </a>
54 </BlockLayout>
55 );
56};
57
58const ButtonBlockSettings = (props: BlockProps) => {
59 let { rep } = useReplicache();
60 let smoker = useSmoker();
61 let entity_set = useEntitySetContext();
62
63 let isSelected = useUIState((s) =>
64 s.selectedBlocks.find((b) => b.value === props.entityID),
65 );
66
67 let [textValue, setTextValue] = useState("");
68 let [urlValue, setUrlValue] = useState("");
69 let text = textValue;
70 let url = urlValue;
71 let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value;
72
73 let submit = async () => {
74 let entity = props.entityID;
75 if (!entity) {
76 entity = v7();
77 await rep?.mutate.addBlock({
78 permission_set: entity_set.set,
79 factID: v7(),
80 parent: props.parent,
81 type: "card",
82 position: generateKeyBetween(props.position, props.nextPosition),
83 newEntityID: entity,
84 });
85 }
86
87 // if no valid url prefix, default to https
88 if (
89 !urlValue.startsWith("http") &&
90 !urlValue.startsWith("mailto") &&
91 !urlValue.startsWith("tel:")
92 )
93 url = `https://${urlValue}`;
94
95 // these mutations = simpler subset of addLinkBlock
96 if (!rep) return;
97 await rep.mutate.assertFact({
98 entity: entity,
99 attribute: "block/type",
100 data: { type: "block-type-union", value: "button" },
101 });
102 await rep?.mutate.assertFact({
103 entity: entity,
104 attribute: "button/text",
105 data: {
106 type: "string",
107 value: text,
108 },
109 });
110 await rep?.mutate.assertFact({
111 entity: entity,
112 attribute: "button/url",
113 data: {
114 type: "string",
115 value: url,
116 },
117 });
118 };
119
120 return (
121 <div
122 className={`buttonBlockSettingsWrapper flex flex-col gap-2 w-full
123 `}
124 >
125 <ButtonPrimary
126 className={`relative ${
127 alignment === "center"
128 ? "place-self-center"
129 : alignment === "left"
130 ? "place-self-start"
131 : alignment === "right"
132 ? "place-self-end"
133 : "place-self-center"
134 }`}
135 fullWidth={alignment === "justify"}
136 >
137 {text !== "" ? text : "Button"}
138 </ButtonPrimary>
139 <BlockLayout
140 isSelected={!!isSelected}
141 borderOnHover
142 hasBackground="accent"
143 className="buttonBlockSettings text-tertiar hover:cursor-pointer border-dashed! p-0!"
144 >
145 <form
146 className={`w-full`}
147 onSubmit={(e) => {
148 e.preventDefault();
149 let rect = document
150 .getElementById("button-block-settings")
151 ?.getBoundingClientRect();
152 if (!textValue) {
153 smoker({
154 error: true,
155 text: "missing button text!",
156 position: {
157 y: rect ? rect.top : 0,
158 x: rect ? rect.left + 12 : 0,
159 },
160 });
161 return;
162 }
163 if (!urlValue) {
164 smoker({
165 error: true,
166 text: "missing url!",
167 position: {
168 y: rect ? rect.top : 0,
169 x: rect ? rect.left + 12 : 0,
170 },
171 });
172 return;
173 }
174 if (!isUrl(urlValue)) {
175 smoker({
176 error: true,
177 text: "invalid url!",
178 position: {
179 y: rect ? rect.top : 0,
180 x: rect ? rect.left + 12 : 0,
181 },
182 });
183 return;
184 }
185 submit();
186 }}
187 >
188 <div className="buttonBlockSettingsContent w-full flex flex-col sm:flex-row gap-2 text-secondary px-2 py-3 sm:pb-3 pb-1">
189 <div className="buttonBlockSettingsTitleInput flex gap-2 w-full sm:w-52">
190 <BlockButtonSmall
191 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
192 />
193 <Separator />
194 <Input
195 type="text"
196 className="w-full grow border-none outline-hidden bg-transparent"
197 placeholder="button text"
198 value={textValue}
199 onChange={(e) => setTextValue(e.target.value)}
200 onKeyDown={(e) => {
201 if (
202 e.key === "Backspace" &&
203 !e.currentTarget.value &&
204 urlValue !== ""
205 )
206 e.preventDefault();
207 }}
208 />
209 </div>
210 <div className="buttonBlockSettingsLinkInput grow flex gap-2 w-full">
211 <LinkSmall
212 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
213 />
214 <Separator />
215 <Input
216 type="text"
217 id="button-block-url-input"
218 className="w-full grow border-none outline-hidden bg-transparent"
219 placeholder="www.example.com"
220 value={urlValue}
221 onChange={(e) => setUrlValue(e.target.value)}
222 onKeyDown={(e) => {
223 if (e.key === "Backspace" && !e.currentTarget.value)
224 e.preventDefault();
225 }}
226 />
227 </div>
228 <button
229 id="button-block-settings"
230 type="submit"
231 className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`}
232 >
233 <div className="sm:hidden block">Save</div>
234 <CheckTiny />
235 </button>
236 </div>
237 </form>
238 </BlockLayout>
239 </div>
240 );
241};