creates video voice memos from audio clips; with bluesky integration.
trill.ptr.pet
1import { createSignal, Signal } from "solid-js";
2
3import { CaptionsIcon, SendIcon, XIcon } from "lucide-solid";
4import { HStack, Stack, VStack } from "styled-system/jsx";
5import { IconButton } from "~/components/ui/icon-button";
6import { Spinner } from "~/components/ui/spinner";
7import { Text } from "~/components/ui/text";
8import { Link } from "~/components/ui/link";
9
10import { parseCanonicalResourceUri } from "@atcute/lexicons/syntax";
11import { css } from "styled-system/css";
12import { sendPost, UploadStatus } from "~/lib/at";
13import { toaster } from "~/components/Toaster";
14import { Dialog } from "~/components/ui/dialog";
15import { Textarea } from "~/components/ui/textarea";
16import { Account } from "~/lib/accounts";
17import { Popover } from "./ui/popover";
18import { Progress } from "./ui/progress";
19
20const PostDialog = (props: {
21 result: Blob;
22 account: Account | undefined;
23 openSignal: Signal<boolean>;
24 initialAltText?: string;
25}) => {
26 const [postContent, setPostContent] = createSignal<string>("");
27 const [altText, setAltText] = createSignal<string>(
28 props.initialAltText ?? "",
29 );
30 const [posting, setPosting] = createSignal(false);
31 const [uploadStatus, setUploadStatus] = createSignal<UploadStatus | null>(
32 null,
33 );
34 const [open, setOpen] = props.openSignal;
35
36 const getStatusMessage = () => {
37 const status = uploadStatus();
38 if (!status) return "";
39
40 switch (status.stage) {
41 case "auth":
42 return "authenticating...";
43 case "uploading":
44 return "uploading video...";
45 case "processing":
46 return status.progress
47 ? `processing video... ${Math.round(status.progress)}%`
48 : "processing video...";
49 case "posting":
50 return "creating post...";
51 case "complete":
52 return "complete!";
53 default:
54 return "";
55 }
56 };
57
58 const getProgressValue = () => {
59 const status = uploadStatus();
60 if (!status) return 0;
61
62 switch (status.stage) {
63 case "auth":
64 return 5;
65 case "uploading":
66 return 10;
67 case "processing":
68 return status.progress ? 10 + status.progress * 0.6 : 40;
69 case "posting":
70 return 90;
71 case "complete":
72 return 100;
73 default:
74 return 0;
75 }
76 };
77
78 return (
79 <Dialog.Root open={open()} onOpenChange={(e) => setOpen(e.open)}>
80 <Dialog.Backdrop />
81 <Dialog.Positioner>
82 <Dialog.Content>
83 <Stack>
84 <Stack gap="0">
85 <video
86 class={css({ maxW: "sm", roundedTop: "xs" })}
87 controls
88 src={URL.createObjectURL(props.result)}
89 ></video>
90 <Textarea
91 placeholder="enter text content..."
92 id="post-content"
93 value={postContent()}
94 onChange={(e) => setPostContent(e.target.value)}
95 rows={2}
96 resize="none"
97 border="none"
98 borderTop="1px solid var(--colors-border-muted)"
99 boxShadow={{ base: "none", _focus: "none" }}
100 disabled={posting()}
101 />
102 </Stack>
103 <Stack
104 borderTop="1px solid var(--colors-border-muted)"
105 gap="2"
106 p="3"
107 direction="row"
108 align="center"
109 >
110 <Stack direction="row" align="center">
111 <Dialog.Title>
112 post to {props.account?.handle ?? props.account?.did}
113 </Dialog.Title>
114 </Stack>
115 <div class={css({ flexGrow: 1 })} />
116 {posting() ? (
117 <Spinner
118 borderLeftColor="bg.emphasized"
119 borderBottomColor="bg.emphasized"
120 borderWidth="4px"
121 size="sm"
122 />
123 ) : (
124 <Dialog.CloseTrigger
125 asChild={(closeTriggerProps) => (
126 <IconButton
127 {...closeTriggerProps()}
128 aria-label="Close Dialog"
129 variant="ghost"
130 size="sm"
131 >
132 <XIcon />
133 </IconButton>
134 )}
135 />
136 )}
137 <Popover.Root>
138 <Popover.Trigger
139 asChild={(triggerProps) => (
140 <IconButton
141 {...triggerProps()}
142 variant={altText() ? "solid" : "ghost"}
143 size="sm"
144 disabled={posting()}
145 >
146 <CaptionsIcon />
147 </IconButton>
148 )}
149 />
150 <Popover.Positioner>
151 <Popover.Content width="sm">
152 <Popover.Arrow />
153 <Stack gap="2">
154 <Popover.Title>video alt text</Popover.Title>
155 <Textarea
156 value={altText()}
157 onInput={(e) => setAltText(e.currentTarget.value)}
158 placeholder="describe the video content..."
159 rows={4}
160 />
161 </Stack>
162 </Popover.Content>
163 </Popover.Positioner>
164 </Popover.Root>
165 <IconButton
166 disabled={posting()}
167 onClick={() => {
168 setPosting(true);
169 setUploadStatus(null);
170 sendPost(
171 props.account?.did!,
172 props.result,
173 postContent(),
174 altText(),
175 (status) => setUploadStatus(status),
176 )
177 .then((result) => {
178 const parsedUri = parseCanonicalResourceUri(result.uri);
179 if (!parsedUri.ok) throw "failed to parse atproto uri";
180 const { repo, rkey } = parsedUri.value;
181 toaster.create({
182 title: "post sent",
183 description: (
184 <HStack gap="1">
185 <Text>view post</Text>
186 <Link
187 href={`https://bsky.app/profile/${repo}/post/${rkey}`}
188 color={{
189 base: "colorPalette.text",
190 _hover: "colorPalette.emphasized",
191 }}
192 textDecoration={{ _hover: "underline" }}
193 >
194 here
195 </Link>
196 </HStack>
197 ),
198 type: "success",
199 });
200 setOpen(false);
201 })
202 .catch((error) => {
203 toaster.create({
204 title: "send post failed",
205 description: error,
206 type: "error",
207 });
208 })
209 .finally(() => {
210 setPosting(false);
211 setUploadStatus(null);
212 });
213 }}
214 variant="ghost"
215 size="sm"
216 >
217 <SendIcon />
218 </IconButton>
219 </Stack>
220 {posting() && uploadStatus() && (
221 <VStack
222 gap="2"
223 p="2"
224 borderTop="1px solid var(--colors-border-muted)"
225 >
226 <Text fontSize="sm" color="fg.muted">
227 {getStatusMessage()}
228 </Text>
229 <Progress
230 value={getProgressValue()}
231 max={100}
232 colorPalette="blue"
233 />
234 </VStack>
235 )}
236 </Stack>
237 </Dialog.Content>
238 </Dialog.Positioner>
239 </Dialog.Root>
240 );
241};
242
243export default PostDialog;