a tool for shared writing and social publishing
at feature/backdate 288 lines 9.2 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 { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9import { 10 getBasePublicationURL, 11 getPublicationURL, 12} from "app/lish/createPub/getPublicationURL"; 13import { useSubscribe } from "src/replicache/useSubscribe"; 14import { useEntitySetContext } from "components/EntitySetProvider"; 15import { timeAgo } from "src/utils/timeAgo"; 16import { CommentTiny } from "components/Icons/CommentTiny"; 17import { QuoteTiny } from "components/Icons/QuoteTiny"; 18import { TagTiny } from "components/Icons/TagTiny"; 19import { Popover } from "components/Popover"; 20import { TagSelector } from "components/Tags"; 21import { useIdentityData } from "components/IdentityProvider"; 22import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 23import { Backdater } from "./Backdater"; 24 25export const PublicationMetadata = () => { 26 let { rep } = useReplicache(); 27 let { data: pub } = useLeafletPublicationData(); 28 let { identity } = useIdentityData(); 29 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title")); 30 let description = useSubscribe(rep, (tx) => 31 tx.get<string>("publication_description"), 32 ); 33 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 34 let pubRecord = pub?.publications?.record as 35 | PubLeafletPublication.Record 36 | undefined; 37 let publishedAt = record?.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 <div className="flex gap-2 text-border items-center"> 123 {tags && ( 124 <> 125 <AddTags /> 126 {pubRecord?.preferences?.showMentions !== false || 127 pubRecord?.preferences?.showComments !== false ? ( 128 <Separator classname="h-4!" /> 129 ) : null} 130 </> 131 )} 132 {pubRecord?.preferences?.showMentions !== false && ( 133 <div className="flex gap-1 items-center"> 134 <QuoteTiny /> 135 </div> 136 )} 137 {pubRecord?.preferences?.showComments !== false && ( 138 <div className="flex gap-1 items-center"> 139 <CommentTiny /> 140 </div> 141 )} 142 </div> 143 </> 144 } 145 /> 146 ); 147}; 148 149export const TextField = ({ 150 value, 151 onChange, 152 className, 153 placeholder, 154}: { 155 value: string; 156 onChange: (v: string) => Promise<void>; 157 className: string; 158 placeholder: string; 159}) => { 160 let { undoManager } = useReplicache(); 161 let actionTimeout = useRef<number | null>(null); 162 let { permissions } = useEntitySetContext(); 163 let previousSelection = useRef<null | { start: number; end: number }>(null); 164 let ref = useRef<HTMLTextAreaElement | null>(null); 165 return ( 166 <AsyncValueAutosizeTextarea 167 ref={ref} 168 disabled={!permissions.write} 169 onSelect={(e) => { 170 let start = e.currentTarget.selectionStart, 171 end = e.currentTarget.selectionEnd; 172 previousSelection.current = { start, end }; 173 }} 174 className={className} 175 value={value} 176 onBlur={async () => { 177 if (actionTimeout.current) { 178 undoManager.endGroup(); 179 window.clearTimeout(actionTimeout.current); 180 actionTimeout.current = null; 181 } 182 }} 183 onChange={async (e) => { 184 let newValue = e.currentTarget.value; 185 let oldValue = value; 186 let start = e.currentTarget.selectionStart, 187 end = e.currentTarget.selectionEnd; 188 await onChange(e.currentTarget.value); 189 190 if (actionTimeout.current) { 191 window.clearTimeout(actionTimeout.current); 192 } else { 193 undoManager.startGroup(); 194 } 195 196 actionTimeout.current = window.setTimeout(() => { 197 undoManager.endGroup(); 198 actionTimeout.current = null; 199 }, 200); 200 let previousStart = previousSelection.current?.start || null, 201 previousEnd = previousSelection.current?.end || null; 202 undoManager.add({ 203 redo: async () => { 204 await onChange(newValue); 205 ref.current?.setSelectionRange(start, end); 206 ref.current?.focus(); 207 }, 208 undo: async () => { 209 await onChange(oldValue); 210 ref.current?.setSelectionRange(previousStart, previousEnd); 211 ref.current?.focus(); 212 }, 213 }); 214 }} 215 placeholder={placeholder} 216 /> 217 ); 218}; 219 220export const PublicationMetadataPreview = () => { 221 let { data: pub } = useLeafletPublicationData(); 222 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 223 let publishedAt = record?.publishedAt; 224 225 if (!pub) return null; 226 227 return ( 228 <PostHeaderLayout 229 pubLink={ 230 <div className="text-accent-contrast font-bold hover:no-underline"> 231 {pub.publications?.name} 232 </div> 233 } 234 postTitle={pub.title} 235 postDescription={pub.description} 236 postInfo={ 237 pub.doc ? ( 238 <p>Published {publishedAt && timeAgo(publishedAt)}</p> 239 ) : ( 240 <p>Draft</p> 241 ) 242 } 243 /> 244 ); 245}; 246 247const AddTags = () => { 248 let { data: pub } = useLeafletPublicationData(); 249 let { rep } = useReplicache(); 250 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 251 252 // Get tags from Replicache local state or published document 253 let replicacheTags = useSubscribe(rep, (tx) => 254 tx.get<string[]>("publication_tags"), 255 ); 256 257 // Determine which tags to use - prioritize Replicache state 258 let tags: string[] = []; 259 if (Array.isArray(replicacheTags)) { 260 tags = replicacheTags; 261 } else if (record?.tags && Array.isArray(record.tags)) { 262 tags = record.tags as string[]; 263 } 264 265 // Update tags in replicache local state 266 const handleTagsChange = async (newTags: string[]) => { 267 // Store tags in replicache for next publish/update 268 await rep?.mutate.updatePublicationDraft({ 269 tags: newTags, 270 }); 271 }; 272 273 return ( 274 <Popover 275 className="p-2! w-full min-w-xs" 276 trigger={ 277 <div className="addTagTrigger flex gap-1 hover:underline text-sm items-center text-tertiary"> 278 <TagTiny />{" "} 279 {tags.length > 0 280 ? `${tags.length} Tag${tags.length === 1 ? "" : "s"}` 281 : "Add Tags"} 282 </div> 283 } 284 > 285 <TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} /> 286 </Popover> 287 ); 288};