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 } 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
114 <form
115 className={`
116 buttonBlockSettingsBorder
117 w-full bg-bg-page
118 text-tertiary hover:text-accent-contrast hover:cursor-pointer hover:p-0
119 flex flex-col gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg
120 ${isSelected ? "border-2 border-tertiary p-0" : "border border-border p-px"}
121 `}
122 onSubmit={(e) => {
123 e.preventDefault();
124 let rect = document
125 .getElementById("button-block-settings")
126 ?.getBoundingClientRect();
127 if (!textValue) {
128 smoker({
129 error: true,
130 text: "missing button text!",
131 position: {
132 y: rect ? rect.top : 0,
133 x: rect ? rect.left + 12 : 0,
134 },
135 });
136 return;
137 }
138 if (!urlValue) {
139 smoker({
140 error: true,
141 text: "missing url!",
142 position: {
143 y: rect ? rect.top : 0,
144 x: rect ? rect.left + 12 : 0,
145 },
146 });
147 return;
148 }
149 if (!isUrl(urlValue)) {
150 smoker({
151 error: true,
152 text: "invalid url!",
153 position: {
154 y: rect ? rect.top : 0,
155 x: rect ? rect.left + 12 : 0,
156 },
157 });
158 return;
159 }
160 submit();
161 }}
162 >
163 <div className="buttonBlockSettingsContent w-full flex flex-col sm:flex-row gap-2 text-secondary px-2 py-3 sm:pb-3 pb-1">
164 <div className="buttonBlockSettingsTitleInput flex gap-2 w-full sm:w-52">
165 <BlockButtonSmall
166 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
167 />
168 <Separator />
169 <Input
170 type="text"
171 autoFocus
172 className="w-full grow border-none outline-hidden bg-transparent"
173 placeholder="button text"
174 value={textValue}
175 disabled={isLocked}
176 onChange={(e) => setTextValue(e.target.value)}
177 onKeyDown={(e) => {
178 if (
179 e.key === "Backspace" &&
180 !e.currentTarget.value &&
181 urlValue !== ""
182 )
183 e.preventDefault();
184 }}
185 />
186 </div>
187 <div className="buttonBlockSettingsLinkInput grow flex gap-2 w-full">
188 <LinkSmall
189 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
190 />
191 <Separator />
192 <Input
193 type="text"
194 id="button-block-url-input"
195 className="w-full grow border-none outline-hidden bg-transparent"
196 placeholder="www.example.com"
197 value={urlValue}
198 disabled={isLocked}
199 onChange={(e) => setUrlValue(e.target.value)}
200 onKeyDown={(e) => {
201 if (e.key === "Backspace" && !e.currentTarget.value)
202 e.preventDefault();
203 }}
204 />
205 </div>
206 <button
207 id="button-block-settings"
208 type="submit"
209 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"}`}
210 >
211 <div className="sm:hidden block">Save</div>
212 <CheckTiny />
213 </button>
214 </div>
215 </form>
216 </div>
217 );
218};