creates video voice memos from audio clips; with bluesky integration. trill.ptr.pet
1import { Component, createSignal, Signal } from "solid-js"; 2 3import { CaptionsIcon, SendIcon, XIcon } from "lucide-solid"; 4import { Stack } 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 } 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"; 18 19const PostDialog = (props: { 20 result: Blob; 21 account: Account | undefined; 22 openSignal: Signal<boolean>; 23 initialAltText?: string; 24}) => { 25 const [postContent, setPostContent] = createSignal<string>(""); 26 const [altText, setAltText] = createSignal<string>( 27 props.initialAltText ?? "", 28 ); 29 const [posting, setPosting] = createSignal(false); 30 const [open, setOpen] = props.openSignal; 31 32 return ( 33 <Dialog.Root open={open()} onOpenChange={(e) => setOpen(e.open)}> 34 <Dialog.Backdrop /> 35 <Dialog.Positioner> 36 <Dialog.Content> 37 <Stack> 38 <Stack gap="0"> 39 <video 40 class={css({ maxW: "sm", roundedTop: "xs" })} 41 controls 42 src={URL.createObjectURL(props.result)} 43 ></video> 44 <Textarea 45 placeholder="enter text content..." 46 id="post-content" 47 value={postContent()} 48 onChange={(e) => setPostContent(e.target.value)} 49 rows={2} 50 resize="none" 51 border="none" 52 borderTop="1px solid var(--colors-border-muted)" 53 boxShadow={{ base: "none", _focus: "none" }} 54 /> 55 </Stack> 56 <Stack 57 borderTop="1px solid var(--colors-border-muted)" 58 gap="2" 59 p="3" 60 direction="row" 61 align="center" 62 > 63 <Stack direction="row" align="center"> 64 <Dialog.Title> 65 post to {props.account?.handle ?? props.account?.did} 66 </Dialog.Title> 67 </Stack> 68 <div class={css({ flexGrow: 1 })} /> 69 {posting() ? ( 70 <Spinner 71 borderLeftColor="bg.emphasized" 72 borderBottomColor="bg.emphasized" 73 borderWidth="4px" 74 size="sm" 75 /> 76 ) : ( 77 <Dialog.CloseTrigger 78 asChild={(closeTriggerProps) => ( 79 <IconButton 80 {...closeTriggerProps()} 81 aria-label="Close Dialog" 82 variant="ghost" 83 size="sm" 84 > 85 <XIcon /> 86 </IconButton> 87 )} 88 /> 89 )} 90 <Popover.Root> 91 <Popover.Trigger 92 asChild={(triggerProps) => ( 93 <IconButton 94 {...triggerProps()} 95 variant={altText() ? "solid" : "ghost"} 96 size="sm" 97 > 98 <CaptionsIcon /> 99 </IconButton> 100 )} 101 /> 102 <Popover.Positioner> 103 <Popover.Content width="sm"> 104 <Popover.Arrow /> 105 <Stack gap="2"> 106 <Popover.Title>video alt text</Popover.Title> 107 <Textarea 108 value={altText()} 109 onInput={(e) => setAltText(e.currentTarget.value)} 110 placeholder="describe the video content..." 111 rows={4} 112 /> 113 </Stack> 114 </Popover.Content> 115 </Popover.Positioner> 116 </Popover.Root> 117 <IconButton 118 disabled={posting()} 119 onClick={() => { 120 setPosting(true); 121 sendPost( 122 props.account?.did!, 123 props.result, 124 postContent(), 125 altText(), 126 ) 127 .then((result) => { 128 const parsedUri = parseCanonicalResourceUri(result.uri); 129 if (!parsedUri.ok) throw "failed to parse atproto uri"; 130 const { repo, rkey } = parsedUri.value; 131 toaster.create({ 132 title: "post sent", 133 description: ( 134 <> 135 <Text>view post </Text> 136 <Link 137 href={`https://bsky.app/profile/${repo}/post/${rkey}`} 138 color={{ 139 base: "colorPalette.text", 140 _hover: "colorPalette.emphasized", 141 }} 142 textDecoration={{ _hover: "underline" }} 143 > 144 here 145 </Link> 146 </> 147 ), 148 type: "success", 149 }); 150 setOpen(false); 151 }) 152 .catch((error) => { 153 toaster.create({ 154 title: "send post failed", 155 description: error, 156 type: "error", 157 }); 158 }) 159 .finally(() => { 160 setPosting(false); 161 }); 162 }} 163 variant="ghost" 164 size="sm" 165 > 166 <SendIcon /> 167 </IconButton> 168 </Stack> 169 </Stack> 170 </Dialog.Content> 171 </Dialog.Positioner> 172 </Dialog.Root> 173 ); 174}; 175 176export default PostDialog;