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