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