a tool for shared writing and social publishing

added an avatar component, styled notification, wip styling mobile footer to fit everything

+271 -111
+53
app/(home-pages)/notifications/CommentNotication.tsx
··· 1 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 2 + import { 3 + AppBskyActorProfile, 4 + PubLeafletComment, 5 + PubLeafletDocument, 6 + } from "lexicons/api"; 7 + import { HydratedCommentNotification } from "src/notifications"; 8 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 9 + import { Avatar } from "components/Avatar"; 10 + import { Notification } from "./Notification"; 11 + 12 + export const CommentNotification = (props: HydratedCommentNotification) => { 13 + let docRecord = props.commentData.documents 14 + ?.data as PubLeafletDocument.Record; 15 + let commentRecord = props.commentData.record as PubLeafletComment.Record; 16 + let profileRecord = props.commentData.bsky_profiles 17 + ?.record as AppBskyActorProfile.Record; 18 + return ( 19 + <Notification 20 + identity={profileRecord.displayName || "Someone"} 21 + action="comment" 22 + content={ 23 + <div className="flex flex-col gap-0.5 mt-2"> 24 + <div className="text-tertiary text-sm italic font-bold"> 25 + {docRecord.title} 26 + </div> 27 + <div className="flex gap-2 border border-border rounded-lg! p-2 text-sm w-full "> 28 + <Avatar 29 + src={ 30 + profileRecord?.avatar?.ref && 31 + blobRefToSrc( 32 + profileRecord?.avatar?.ref, 33 + props.commentData.bsky_profiles?.did || "", 34 + ) 35 + } 36 + displayName={profileRecord?.displayName} 37 + />{" "} 38 + <pre 39 + style={{ wordBreak: "break-word" }} 40 + className="whitespace-pre-wrap text-secondary pt-0.5 line-clamp-6" 41 + > 42 + <BaseTextBlock 43 + index={[]} 44 + plaintext={commentRecord.plaintext} 45 + facets={commentRecord.facets} 46 + /> 47 + </pre> 48 + </div> 49 + </div> 50 + } 51 + /> 52 + ); 53 + };
+37
app/(home-pages)/notifications/Notification.tsx
··· 1 + import { CommentTiny } from "components/Icons/CommentTiny"; 2 + import { ReplyTiny } from "components/Icons/ReplyTiny"; 3 + 4 + export const Notification = (props: { 5 + identity: string; 6 + action: "comment" | "reply" | "follow"; 7 + content: React.ReactNode; 8 + }) => { 9 + return ( 10 + <div className="flex flex-row gap-2 w-full"> 11 + <div className="text-secondary pt-[6px] shrink-0"> 12 + {props.action === "comment" ? ( 13 + <CommentTiny /> 14 + ) : props.action === "reply" ? ( 15 + <ReplyTiny /> 16 + ) : props.action === "follow" ? ( 17 + "followed you!" 18 + ) : ( 19 + "did something!" 20 + )} 21 + </div> 22 + <div className="flex flex-col gap-0 w-full grow "> 23 + <div className="text-base text-secondary font-bold"> 24 + {props.identity}{" "} 25 + {props.action === "comment" 26 + ? "commented on your post" 27 + : props.action === "reply" 28 + ? "replied to your comment" 29 + : props.action === "follow" 30 + ? "followed you!" 31 + : "did something!"} 32 + </div> 33 + {props.content} 34 + </div> 35 + </div> 36 + ); 37 + };
+19 -62
app/(home-pages)/notifications/page.tsx
··· 1 1 import { getIdentityData } from "actions/getIdentityData"; 2 - import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 3 2 import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 4 - import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; 5 3 import { redirect } from "next/navigation"; 6 - import { 7 - HydratedCommentNotification, 8 - hydrateNotifications, 9 - } from "src/notifications"; 4 + import { hydrateNotifications } from "src/notifications"; 10 5 import { supabaseServerClient } from "supabase/serverClient"; 6 + import { CommentNotification } from "./CommentNotication"; 11 7 12 8 export default async function Notifications() { 13 - let identity = await getIdentityData(); 14 - if (!identity?.atp_did) return redirect("/home"); 15 - let { data, error } = await supabaseServerClient 16 - .from("notifications") 17 - .select("*") 18 - .eq("recipient", identity.atp_did); 19 - let notifications = await hydrateNotifications(data || []); 20 9 return ( 21 10 <DashboardLayout 22 11 id="discover" ··· 27 16 tabs={{ 28 17 default: { 29 18 controls: null, 30 - content: ( 31 - <div> 32 - <h2>Notifications</h2> 33 - {notifications.map((n) => { 34 - if (n.type === "comment") { 35 - n; 36 - return <CommentNotification key={n.id} {...n} />; 37 - } 38 - })} 39 - </div> 40 - ), 19 + content: <NotificationContent />, 41 20 }, 42 21 }} 43 22 /> 44 23 ); 45 24 } 46 25 47 - const CommentNotification = (props: HydratedCommentNotification) => { 48 - let docRecord = props.commentData.documents 49 - ?.data as PubLeafletDocument.Record; 50 - let commentRecord = props.commentData.record as PubLeafletComment.Record; 26 + const NotificationContent = async () => { 27 + let identity = await getIdentityData(); 28 + if (!identity?.atp_did) return redirect("/home"); 29 + let { data } = await supabaseServerClient 30 + .from("notifications") 31 + .select("*") 32 + .eq("recipient", identity.atp_did); 33 + let notifications = await hydrateNotifications(data || []); 51 34 return ( 52 - <Notification 53 - identity={props.commentData.bsky_profiles?.handle || "Someone"} 54 - action="commented on your post" 55 - content={ 56 - <div> 57 - <h4>{docRecord.title}</h4> 58 - <div className="border"> 59 - <pre 60 - style={{ wordBreak: "break-word" }} 61 - className="whitespace-pre-wrap text-secondary pb-[4px] " 62 - > 63 - <BaseTextBlock 64 - index={[]} 65 - plaintext={commentRecord.plaintext} 66 - facets={commentRecord.facets} 67 - /> 68 - </pre> 69 - </div> 70 - </div> 71 - } 72 - /> 73 - ); 74 - }; 75 - 76 - const Notification = (props: { 77 - identity: string; 78 - action: string; 79 - content: React.ReactNode; 80 - }) => { 81 - return ( 82 - <div className="flex flex-col gap-2 border"> 83 - <div> 84 - {props.identity} {props.action} 35 + <div className="max-w-prose mx-auto w-full"> 36 + <div className="flex flex-col gap-6 pt-3"> 37 + {notifications.map((n) => { 38 + if (n.type === "comment") { 39 + n; 40 + return <CommentNotification key={n.id} {...n} />; 41 + } 42 + })} 85 43 </div> 86 - {props.content} 87 44 </div> 88 45 ); 89 46 };
+1 -1
app/[leaflet_id]/publish/PublishPost.tsx
··· 146 146 <div className="opaque-container p-3 rounded-lg!"> 147 147 <div className="flex gap-2"> 148 148 <img 149 - className="bg-test rounded-full w-[42px] h-[42px] shrink-0" 149 + className="rounded-full w-[42px] h-[42px] shrink-0" 150 150 src={props.profile.avatar} 151 151 /> 152 152 <div className="flex flex-col w-full">
+6
app/globals.css
··· 96 96 --accent-2: 255, 255, 255; 97 97 --accent-contrast: 0, 0, 225; 98 98 --accent-1-is-contrast: "true"; 99 + --accent-light: color-mix( 100 + in oklab, 101 + rgb(var(--accent-contrast)), 102 + rgb(var(--bg-page)) 85% 103 + ); 99 104 100 105 --highlight-1: 255, 177, 177; 101 106 --highlight-2: 253, 245, 203; ··· 365 370 @apply pl-3; 366 371 @apply ml-2; 367 372 } 373 + 368 374 .transparent-container { 369 375 @apply border; 370 376 @apply border-border-light;
+12 -5
components/ActionBar/ActionButton.tsx
··· 17 17 nav?: boolean; 18 18 className?: string; 19 19 subtext?: string; 20 + labelOnMobile?: boolean; 20 21 }, 21 22 ) => { 22 23 let { id, icon, label, primary, secondary, nav, ...buttonProps } = props; ··· 30 31 }; 31 32 } 32 33 }, [sidebar, inOpenPopover]); 34 + 35 + let showLabelOnMobile = 36 + props.labelOnMobile !== false && 37 + (props.primary || props.secondary || props.nav); 38 + 33 39 return ( 34 40 <button 35 41 {...buttonProps} ··· 38 44 rounded-md border 39 45 flex gap-2 items-start sm:justify-start justify-center 40 46 p-1 sm:mx-0 47 + ${showLabelOnMobile && !secondary ? "w-full" : "sm:w-full w-max"} 41 48 ${ 42 49 primary 43 - ? "w-full bg-accent-1 border-accent-1 text-accent-2 transparent-outline sm:hover:outline-accent-contrast focus:outline-accent-1 outline-offset-1 mx-1 first:ml-0" 50 + ? "bg-accent-1 border-accent-1 text-accent-2 transparent-outline sm:hover:outline-accent-contrast focus:outline-accent-1 outline-offset-1 mx-1 first:ml-0" 44 51 : secondary 45 - ? "sm:w-full w-max bg-bg-page border-accent-contrast text-accent-contrast transparent-outline focus:outline-accent-contrast sm:hover:outline-accent-contrast outline-offset-1 mx-1 first:ml-0" 52 + ? " bg-bg-page border-accent-contrast text-accent-contrast transparent-outline focus:outline-accent-contrast sm:hover:outline-accent-contrast outline-offset-1 mx-1 first:ml-0" 46 53 : nav 47 - ? "w-full border-transparent text-secondary sm:hover:border-border justify-start!" 48 - : "sm:w-full border-transparent text-accent-contrast sm:hover:border-accent-contrast" 54 + ? "border-transparent text-secondary sm:hover:border-border justify-start!" 55 + : "border-transparent text-accent-contrast sm:hover:border-accent-contrast" 49 56 } 50 57 ${props.className} 51 58 `} 52 59 > 53 60 <div className="shrink-0">{icon}</div> 54 61 <div 55 - className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : primary || secondary || nav ? "sm:hidden block" : "hidden"}`} 62 + className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`} 56 63 > 57 64 <div className="truncate text-left pt-[1px]">{label}</div> 58 65 {props.subtext && (
+82 -43
components/ActionBar/Navigation.tsx
··· 13 13 } from "components/Icons/ReaderSmall"; 14 14 import { NotificationsUnreadSmall } from "components/Icons/NotificationSmall"; 15 15 import { SpeedyLink } from "components/SpeedyLink"; 16 + import { NotificationInstance } from "twilio/lib/rest/api/v2010/account/notification"; 17 + import { getIdentityData } from "actions/getIdentityData"; 18 + import { redirect } from "next/navigation"; 19 + import { hydrateNotifications } from "src/notifications"; 20 + import { supabaseServerClient } from "supabase/serverClient"; 21 + import { CommentNotification } from "app/(home-pages)/notifications/CommentNotication"; 16 22 17 23 export type navPages = "home" | "reader" | "pub" | "discover" | "notifications"; 18 24 ··· 21 27 publication?: string; 22 28 }) => { 23 29 return ( 24 - <div className="flex flex-col gap-4"> 30 + <div className="flex flex-col gap-2"> 31 + <Sidebar alwaysOpen> 32 + <NotificationButton /> 33 + </Sidebar> 25 34 <Sidebar alwaysOpen> 26 35 <NavigationOptions 27 36 currentPage={props.currentPage} 28 37 publication={props.publication} 29 38 /> 30 39 </Sidebar> 31 - {/*<Sidebar alwaysOpen> 32 - </Sidebar>*/} 33 40 </div> 34 41 ); 35 42 }; ··· 43 50 (pub) => pub.uri === props.publication, 44 51 ); 45 52 return ( 46 - <Popover 47 - onOpenAutoFocus={(e) => e.preventDefault()} 48 - asChild 49 - className="px-2! !max-w-[256px]" 50 - trigger={ 51 - <div className="shrink-0 p-1 pr-2 text-accent-contrast h-full flex gap-2 font-bold items-center"> 52 - <MenuSmall /> 53 - <div className="truncate max-w-[72px]"> 54 - {props.currentPage === "home" ? ( 55 - <>Home</> 56 - ) : props.currentPage === "reader" ? ( 57 - <>Reader</> 58 - ) : props.currentPage === "discover" ? ( 59 - <>Discover</> 60 - ) : props.currentPage === "pub" ? ( 61 - thisPublication && <>{thisPublication.name}</> 62 - ) : null} 53 + <div className="flex gap-1 pr-2"> 54 + <NotificationButton /> 55 + 56 + <Popover 57 + onOpenAutoFocus={(e) => e.preventDefault()} 58 + asChild 59 + className="px-2! !max-w-[256px]" 60 + trigger={ 61 + <div className="shrink-0 p-1 text-accent-contrast h-full flex gap-2 font-bold items-center"> 62 + <MenuSmall /> 63 + {props.currentPage !== "notifications" && ( 64 + <div className="truncate max-w-[72px]"> 65 + {props.currentPage === "home" ? ( 66 + <>Home</> 67 + ) : props.currentPage === "reader" ? ( 68 + <>Reader</> 69 + ) : props.currentPage === "discover" ? ( 70 + <>Discover</> 71 + ) : props.currentPage === "pub" ? ( 72 + thisPublication && <>{thisPublication.name}</> 73 + ) : null} 74 + </div> 75 + )} 63 76 </div> 64 - </div> 65 - } 66 - > 67 - <NavigationOptions 68 - currentPage={props.currentPage} 69 - publication={props.publication} 70 - /> 71 - </Popover> 77 + } 78 + > 79 + <NavigationOptions 80 + currentPage={props.currentPage} 81 + publication={props.publication} 82 + isMobile 83 + /> 84 + </Popover> 85 + </div> 72 86 ); 73 87 }; 74 88 75 89 const NavigationOptions = (props: { 76 90 currentPage: navPages; 77 91 publication?: string; 92 + isMobile?: boolean; 78 93 }) => { 79 94 let { identity } = useIdentityData(); 80 95 let thisPublication = identity?.publications?.find( ··· 88 103 subs={identity?.publication_subscriptions?.length !== 0} 89 104 /> 90 105 <DiscoverButton current={props.currentPage === "discover"} /> 91 - 92 - {identity && ( 93 - <SpeedyLink href={"/notifications"}> 94 - <ActionButton 95 - nav 96 - icon={<NotificationsUnreadSmall />} 97 - label="Notifications" 98 - className={ 99 - props.currentPage === "notifications" 100 - ? "bg-bg-page! border-border-light!" 101 - : "" 102 - } 103 - /> 104 - </SpeedyLink> 105 - )} 106 + {/*{identity && ( 107 + <> 108 + <hr className="border-dashed border-border-light" /> 109 + <NotificationButton current={props.currentPage === "notifications"} /> 110 + </> 111 + )}*/} 106 112 107 113 <hr className="border-border-light my-1" /> 108 114 <PublicationButtons currentPubUri={thisPublication?.uri} /> ··· 155 161 </Link> 156 162 ); 157 163 }; 164 + 165 + function NotificationButton(props: { current?: boolean }) { 166 + // let identity = await getIdentityData(); 167 + // if (!identity?.atp_did) return; 168 + // let { data } = await supabaseServerClient 169 + // .from("notifications") 170 + // .select("*") 171 + // .eq("recipient", identity.atp_did); 172 + // let notifications = await hydrateNotifications(data || []); 173 + 174 + return ( 175 + <Popover 176 + asChild 177 + trigger={ 178 + <ActionButton 179 + icon={<NotificationsUnreadSmall />} 180 + label="Notifications" 181 + className={props.current ? "bg-bg-page! border-border-light!" : ""} 182 + /> 183 + } 184 + > 185 + <div className="flex flex-col gap-6 pt-3"> 186 + {/*{notifications.map((n) => { 187 + if (n.type === "comment") { 188 + n; 189 + return <CommentNotification key={n.id} {...n} />; 190 + } 191 + })}*/} 192 + </div> 193 + <SpeedyLink href={"/notifications"}>See All</SpeedyLink> 194 + </Popover> 195 + ); 196 + }
+22
components/Avatar.tsx
··· 1 + import { AccountTiny } from "./Icons/AccountTiny"; 2 + 3 + export const Avatar = (props: { 4 + src: string | undefined; 5 + displayName: string | undefined; 6 + small?: boolean; 7 + }) => { 8 + if (props.src) 9 + return ( 10 + <img 11 + className="rounded-full w-6 h-6 shrink-0 border border-border-light " 12 + src={props.src} 13 + alt={props.displayName ? `${props.displayName}'s avatar` : "avatar"} 14 + /> 15 + ); 16 + else 17 + return ( 18 + <div className="bg-[var(--accent-light)] rounded-full w-6 h-6 shrink-0 border border-border-light place-items-center text-accent-1"> 19 + <AccountTiny /> 20 + </div> 21 + ); 22 + };
+19
components/Icons/AccountTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const AccountTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M11.9995 11.6042C12.2359 11.3531 12.6319 11.3406 12.8833 11.5768C13.1345 11.8133 13.1469 12.2102 12.9106 12.4616C10.9942 14.4996 8.48343 14.9669 5.82467 14.4899C5.48536 14.4287 5.25917 14.1047 5.31979 13.7653C5.38075 13.4255 5.7066 13.1985 6.04635 13.2594C8.41545 13.6844 10.4511 13.2509 11.9995 11.6042ZM7.40377 1.64517C7.57942 1.34822 7.96315 1.25 8.26022 1.42544C8.55725 1.60111 8.65554 1.98479 8.47995 2.28189L4.62155 8.80923C4.68119 8.84969 4.74613 8.89372 4.81686 8.93716C5.20557 9.17585 5.72696 9.42535 6.30123 9.51724C7.59938 9.72475 8.32429 9.55762 8.60495 9.41959C8.91451 9.26714 9.28927 9.39433 9.44186 9.70376C9.59429 10.0133 9.46707 10.3881 9.15768 10.5407C8.55667 10.8366 7.53939 10.9811 6.10397 10.7516C5.31168 10.6249 4.63266 10.2913 4.16256 10.0026C3.92499 9.85669 3.73326 9.71756 3.60006 9.61392C3.53354 9.56215 3.48092 9.51848 3.44381 9.48697C3.42534 9.47127 3.41058 9.45834 3.39987 9.44888C3.39453 9.44418 3.38953 9.44016 3.3862 9.43716C3.38469 9.43579 3.38337 9.43423 3.38229 9.43326L3.38034 9.43228V9.4313H3.37936C3.16132 9.23186 3.11298 8.90647 3.26315 8.65201L7.40377 1.64517ZM12.4995 2.25259C13.2777 2.19942 13.9584 2.87497 14.019 3.76138C14.0795 4.64775 13.4974 5.40938 12.7192 5.46255C11.941 5.51572 11.2612 4.84018 11.2006 3.95376C11.1401 3.06754 11.7215 2.306 12.4995 2.25259ZM2.08444 2.98501C2.35274 2.19505 3.03678 1.71257 3.61178 1.90787C4.18673 2.1032 4.43574 2.90212 4.16745 3.69205C3.89911 4.48193 3.21507 4.9635 2.6401 4.76822C2.06529 4.57291 1.81644 3.77476 2.08444 2.98501Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+20
components/Icons/ReplyTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const ReplyTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + > 12 + <path 13 + fill-rule="evenodd" 14 + clip-rule="evenodd" 15 + d="M10.7767 3.01749C11.6289 3.39627 12.1593 3.79765 12.4801 4.2201C12.7868 4.62405 12.9578 5.12048 12.9578 5.81175C12.9578 6.45434 12.7165 7.17288 12.2111 7.72195C11.7245 8.25058 10.9456 8.67427 9.75117 8.67427L4.45638 8.67427L6.97173 6.15892C7.36226 5.7684 7.36226 5.13523 6.97173 4.74471C6.58121 4.35418 5.94804 4.35418 5.55752 4.74471L1.33513 8.9671C0.944605 9.35762 0.944605 9.99079 1.33513 10.3813L5.55752 14.6037C5.94804 14.9942 6.58121 14.9942 6.97173 14.6037C7.36226 14.2132 7.36226 13.58 6.97173 13.1895L4.45652 10.6743L9.75117 10.6743C11.4697 10.6743 12.7941 10.0416 13.6826 9.07646C14.5522 8.13173 14.9578 6.91901 14.9578 5.81175C14.9578 4.75316 14.6829 3.81405 14.073 3.01069C13.4771 2.22581 12.62 1.64809 11.589 1.18986C11.0843 0.965558 10.4933 1.19285 10.269 1.69754C10.0447 2.20222 10.272 2.79318 10.7767 3.01749Z" 16 + fill="currentColor" 17 + /> 18 + </svg> 19 + ); 20 + };