a tool for shared writing and social publishing
at feature/recommend 299 lines 9.6 kB view raw
1import Link from "next/link"; 2import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3import { useRef, useState } from "react"; 4import { useReplicache } from "src/replicache"; 5import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6import { Separator } from "components/Layout"; 7import { AtUri } from "@atproto/syntax"; 8import { 9 getBasePublicationURL, 10 getPublicationURL, 11} from "app/lish/createPub/getPublicationURL"; 12import { useSubscribe } from "src/replicache/useSubscribe"; 13import { useEntitySetContext } from "components/EntitySetProvider"; 14import { timeAgo } from "src/utils/timeAgo"; 15import { CommentTiny } from "components/Icons/CommentTiny"; 16import { QuoteTiny } from "components/Icons/QuoteTiny"; 17import { TagTiny } from "components/Icons/TagTiny"; 18import { Popover } from "components/Popover"; 19import { TagSelector } from "components/Tags"; 20import { useIdentityData } from "components/IdentityProvider"; 21import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 22import { Backdater } from "./Backdater"; 23import { RecommendTinyEmpty } from "components/Icons/RecommendTiny"; 24 25export const PublicationMetadata = (props: { noInteractions?: boolean }) => { 26 let { rep } = useReplicache(); 27 let { 28 data: pub, 29 normalizedDocument, 30 normalizedPublication, 31 } = useLeafletPublicationData(); 32 let { identity } = useIdentityData(); 33 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title")); 34 let description = useSubscribe(rep, (tx) => 35 tx.get<string>("publication_description"), 36 ); 37 let publishedAt = normalizedDocument?.publishedAt; 38 39 if (!pub) return null; 40 41 if (typeof title !== "string") { 42 title = pub?.title || ""; 43 } 44 if (typeof description !== "string") { 45 description = pub?.description || ""; 46 } 47 let tags = true; 48 49 return ( 50 <PostHeaderLayout 51 pubLink={ 52 <div className="flex gap-2 items-center"> 53 {pub.publications && ( 54 <Link 55 href={ 56 identity?.atp_did === pub.publications?.identity_did 57 ? `${getBasePublicationURL(pub.publications)}/dashboard` 58 : getPublicationURL(pub.publications) 59 } 60 className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 61 > 62 {pub.publications?.name} 63 </Link> 64 )} 65 <div className="font-bold text-tertiary px-1 h-[20px] text-sm flex place-items-center bg-border-light rounded-md "> 66 DRAFT 67 </div> 68 </div> 69 } 70 postTitle={ 71 <TextField 72 className="leading-tight pt-0.5 text-xl font-bold outline-hidden bg-transparent" 73 value={title} 74 onChange={async (newTitle) => { 75 await rep?.mutate.updatePublicationDraft({ 76 title: newTitle, 77 description, 78 }); 79 }} 80 placeholder="Untitled" 81 /> 82 } 83 postDescription={ 84 <TextField 85 placeholder="add an optional description..." 86 className="pt-1 italic text-secondary outline-hidden bg-transparent" 87 value={description} 88 onChange={async (newDescription) => { 89 await rep?.mutate.updatePublicationDraft({ 90 title, 91 description: newDescription, 92 }); 93 }} 94 /> 95 } 96 postInfo={ 97 <> 98 {pub.doc ? ( 99 <div className="flex gap-2 items-center"> 100 <p className="text-sm text-tertiary"> 101 Published{" "} 102 {publishedAt && ( 103 <Backdater publishedAt={publishedAt} docURI={pub.doc} /> 104 )} 105 </p> 106 107 <Link 108 target="_blank" 109 className="text-sm" 110 href={ 111 pub.publications 112 ? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}` 113 : `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}` 114 } 115 > 116 View 117 </Link> 118 </div> 119 ) : ( 120 <p>Draft</p> 121 )} 122 {!props.noInteractions && ( 123 <div className="flex gap-2 text-border items-center"> 124 {normalizedPublication?.preferences?.showRecommends !== false && ( 125 <div className="flex gap-1 items-center"> 126 <RecommendTinyEmpty /> 127 </div> 128 )} 129 130 {normalizedPublication?.preferences?.showMentions !== false && ( 131 <div className="flex gap-1 items-center"> 132 <QuoteTiny /> 133 </div> 134 )} 135 {normalizedPublication?.preferences?.showComments !== false && ( 136 <div className="flex gap-1 items-center"> 137 <CommentTiny /> 138 </div> 139 )} 140 {tags && ( 141 <> 142 {normalizedPublication?.preferences?.showRecommends !== 143 false || 144 normalizedPublication?.preferences?.showMentions !== false || 145 normalizedPublication?.preferences?.showComments !== false ? ( 146 <Separator classname="h-4!" /> 147 ) : null} 148 <AddTags /> 149 </> 150 )} 151 </div> 152 )} 153 </> 154 } 155 /> 156 ); 157}; 158 159export const TextField = ({ 160 value, 161 onChange, 162 className, 163 placeholder, 164}: { 165 value: string; 166 onChange: (v: string) => Promise<void>; 167 className: string; 168 placeholder: string; 169}) => { 170 let { undoManager } = useReplicache(); 171 let actionTimeout = useRef<number | null>(null); 172 let { permissions } = useEntitySetContext(); 173 let previousSelection = useRef<null | { start: number; end: number }>(null); 174 let ref = useRef<HTMLTextAreaElement | null>(null); 175 return ( 176 <AsyncValueAutosizeTextarea 177 ref={ref} 178 disabled={!permissions.write} 179 onSelect={(e) => { 180 let start = e.currentTarget.selectionStart, 181 end = e.currentTarget.selectionEnd; 182 previousSelection.current = { start, end }; 183 }} 184 className={className} 185 value={value} 186 onBlur={async () => { 187 if (actionTimeout.current) { 188 undoManager.endGroup(); 189 window.clearTimeout(actionTimeout.current); 190 actionTimeout.current = null; 191 } 192 }} 193 onChange={async (e) => { 194 let newValue = e.currentTarget.value; 195 let oldValue = value; 196 let start = e.currentTarget.selectionStart, 197 end = e.currentTarget.selectionEnd; 198 await onChange(e.currentTarget.value); 199 200 if (actionTimeout.current) { 201 window.clearTimeout(actionTimeout.current); 202 } else { 203 undoManager.startGroup(); 204 } 205 206 actionTimeout.current = window.setTimeout(() => { 207 undoManager.endGroup(); 208 actionTimeout.current = null; 209 }, 200); 210 let previousStart = previousSelection.current?.start || null, 211 previousEnd = previousSelection.current?.end || null; 212 undoManager.add({ 213 redo: async () => { 214 await onChange(newValue); 215 ref.current?.setSelectionRange(start, end); 216 ref.current?.focus(); 217 }, 218 undo: async () => { 219 await onChange(oldValue); 220 ref.current?.setSelectionRange(previousStart, previousEnd); 221 ref.current?.focus(); 222 }, 223 }); 224 }} 225 placeholder={placeholder} 226 /> 227 ); 228}; 229 230export const PublicationMetadataPreview = () => { 231 let { data: pub, normalizedDocument } = useLeafletPublicationData(); 232 let publishedAt = normalizedDocument?.publishedAt; 233 234 if (!pub) return null; 235 236 return ( 237 <PostHeaderLayout 238 pubLink={ 239 <div className="text-accent-contrast font-bold hover:no-underline"> 240 {pub.publications?.name} 241 </div> 242 } 243 postTitle={pub.title} 244 postDescription={pub.description} 245 postInfo={ 246 pub.doc ? ( 247 <p>Published {publishedAt && timeAgo(publishedAt)}</p> 248 ) : ( 249 <p>Draft</p> 250 ) 251 } 252 /> 253 ); 254}; 255 256export const AddTags = () => { 257 let { data: pub, normalizedDocument } = useLeafletPublicationData(); 258 let { rep } = useReplicache(); 259 260 // Get tags from Replicache local state or published document 261 let replicacheTags = useSubscribe(rep, (tx) => 262 tx.get<string[]>("publication_tags"), 263 ); 264 265 // Determine which tags to use - prioritize Replicache state 266 let tags: string[] = []; 267 if (Array.isArray(replicacheTags)) { 268 tags = replicacheTags; 269 } else if ( 270 normalizedDocument?.tags && 271 Array.isArray(normalizedDocument.tags) 272 ) { 273 tags = normalizedDocument.tags as string[]; 274 } 275 276 // Update tags in replicache local state 277 const handleTagsChange = async (newTags: string[]) => { 278 // Store tags in replicache for next publish/update 279 await rep?.mutate.updatePublicationDraft({ 280 tags: newTags, 281 }); 282 }; 283 284 return ( 285 <Popover 286 className="p-2! w-full min-w-xs" 287 trigger={ 288 <div className="addTagTrigger flex gap-1 hover:underline text-sm items-center text-tertiary"> 289 <TagTiny />{" "} 290 {tags.length > 0 291 ? `${tags.length} Tag${tags.length === 1 ? "" : "s"}` 292 : "Add Tags"} 293 </div> 294 } 295 > 296 <TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} /> 297 </Popover> 298 ); 299};