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