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