creates video voice memos from audio clips; with bluesky integration. trill.ptr.pet
at main 8.2 kB view raw
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;