a tool for shared writing and social publishing
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};