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";
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};