a tool for shared writing and social publishing

Feature/discover (#151)

* added a discover page

* tweaks to the list styling, bg color

* typo

* Wire up

* add setting and respect it

* added toggle foy discovery in create and update pub

* add theme to pub listings

* link to create pub from discover page

* shorten description text a bit

* couple little signup form tweaks

* center header, add some padding

* align logo to text if page bg set

* more header copy ands spacing tweaks

* add initial if no icon

* make sorts clientside

---------

Co-authored-by: celine <celine@hyperlink.academy>
Co-authored-by: Brendan Schlagel <brendan.schlagel@gmail.com>

authored by awarm.space

celine
Brendan Schlagel
and committed by
GitHub
36c61dce 9908aa85

+517 -12
+95
app/discover/PubListing.tsx
··· 1 + "use client"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 4 + import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 5 + import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 6 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 7 + import { Json } from "supabase/database.types"; 8 + 9 + export const PubListing = (props: { 10 + record: Json; 11 + uri: string; 12 + documents_in_publications: { 13 + indexed_at: string; 14 + documents: { data: Json } | null; 15 + }[]; 16 + }) => { 17 + let record = props.record as PubLeafletPublication.Record; 18 + let theme = usePubTheme(record); 19 + let backgroundImage = record?.theme?.backgroundImage?.image?.ref 20 + ? blobRefToSrc( 21 + record?.theme?.backgroundImage?.image?.ref, 22 + new AtUri(props.uri).host, 23 + ) 24 + : null; 25 + 26 + let backgroundImageRepeat = record?.theme?.backgroundImage?.repeat; 27 + let backgroundImageSize = record?.theme?.backgroundImage?.width || 500; 28 + if (!record) return null; 29 + return ( 30 + <BaseThemeProvider {...theme} local> 31 + <a 32 + target="_blank" 33 + href={`https://${record.base_path}`} 34 + style={{ 35 + backgroundImage: `url(${backgroundImage})`, 36 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 37 + backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 38 + }} 39 + className={`!no-underline flex flex-row gap-2 40 + bg-bg-leaflet 41 + border border-border-light rounded-lg 42 + px-3 py-3 selected-outline 43 + hover:outline-accent-contrast hover:border-accent-contrast`} 44 + > 45 + <div 46 + style={{ 47 + backgroundRepeat: "no-repeat", 48 + backgroundPosition: "center", 49 + backgroundSize: "cover", 50 + backgroundImage: record?.icon 51 + ? `url(${blobRefToSrc(record.icon?.ref, new AtUri(props.uri).host)})` 52 + : undefined, 53 + }} 54 + className={`w-6 h-6 mt-0.5 rounded-full bg-accent-1 text-accent-2 flex place-content-center leading-snug font-bold text-center shrink-0 ${record.theme?.showPageBackground ? "mt-[10px]" : "mt-0.5"}`} 55 + > 56 + {!record?.icon ? record.name.slice(0, 1).toLocaleUpperCase() : null} 57 + </div> 58 + <div 59 + className={`flex w-full flex-col ${record.theme?.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))] px-2 py-1 rounded-lg" : ""}`} 60 + > 61 + <h3>{record.name}</h3> 62 + <p className="text-secondary">{record.description}</p> 63 + <div className="flex gap-1 items-center text-sm text-tertiary pt-2 "> 64 + <p> 65 + Updated {timeAgo(props.documents_in_publications[0].indexed_at)} 66 + </p> 67 + </div> 68 + </div> 69 + </a> 70 + </BaseThemeProvider> 71 + ); 72 + }; 73 + 74 + function timeAgo(timestamp: string): string { 75 + const now = new Date(); 76 + const date = new Date(timestamp); 77 + const diffMs = now.getTime() - date.getTime(); 78 + const diffSeconds = Math.floor(diffMs / 1000); 79 + const diffMinutes = Math.floor(diffSeconds / 60); 80 + const diffHours = Math.floor(diffMinutes / 60); 81 + const diffDays = Math.floor(diffHours / 24); 82 + const diffYears = Math.floor(diffDays / 365); 83 + 84 + if (diffYears > 0) { 85 + return `${diffYears} year${diffYears === 1 ? "" : "s"} ago`; 86 + } else if (diffDays > 0) { 87 + return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; 88 + } else if (diffHours > 0) { 89 + return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`; 90 + } else if (diffMinutes > 0) { 91 + return `${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} ago`; 92 + } else { 93 + return "just now"; 94 + } 95 + }
+97
app/discover/SortButtons.tsx
··· 1 + "use client"; 2 + import Link from "next/link"; 3 + import { useState } from "react"; 4 + import { theme } from "tailwind.config"; 5 + 6 + export default function SortButtons(props: { order: string }) { 7 + const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( 8 + "recentlyUpdated", 9 + ); 10 + 11 + return ( 12 + <div className="flex gap-2 pt-1"> 13 + <Link href="?order=recentlyUpdated"> 14 + <SortButton selected={props.order === "recentlyUpdated"}> 15 + Recently Updated 16 + </SortButton> 17 + </Link> 18 + 19 + <Link href="?order=popular"> 20 + <SortButton selected={props.order === "popular"}>Popular</SortButton> 21 + </Link> 22 + </div> 23 + ); 24 + } 25 + 26 + const SortButton = (props: { 27 + children: React.ReactNode; 28 + selected: boolean; 29 + }) => { 30 + return ( 31 + <div className="relative"> 32 + <button 33 + style={ 34 + props.selected 35 + ? { backgroundColor: `rgba(var(--accent-1), 0.2)` } 36 + : {} 37 + } 38 + className={`text-sm rounded-md px-[8px] py-0.5 border ${props.selected ? "border-accent-contrast text-accent-1 font-bold" : "text-tertiary border-border-light"}`} 39 + > 40 + {props.children} 41 + </button> 42 + {props.selected && ( 43 + <> 44 + <div className="absolute top-0 -left-2"> 45 + <GlitterBig /> 46 + </div> 47 + <div className="absolute top-4 left-0"> 48 + <GlitterSmall /> 49 + </div> 50 + <div className="absolute -top-2 -right-1"> 51 + <GlitterSmall /> 52 + </div> 53 + </> 54 + )} 55 + </div> 56 + ); 57 + }; 58 + 59 + const GlitterBig = () => { 60 + return ( 61 + <svg 62 + width="16" 63 + height="17" 64 + viewBox="0 0 16 17" 65 + fill="none" 66 + xmlns="http://www.w3.org/2000/svg" 67 + > 68 + <path 69 + d="M8.16553 0.804321C8.5961 0.804329 8.97528 1.03925 9.22803 1.40393C9.47845 1.76546 9.6128 2.25816 9.61279 2.84338C9.61279 2.98187 9.6178 3.11647 9.62646 3.2467C9.65365 3.65499 9.72104 4.02319 9.81006 4.35022C10.0833 5.35388 10.5641 5.96726 10.7349 6.14221C10.7443 6.15184 10.7543 6.16234 10.7642 6.17249C10.9808 6.39533 11.3925 6.8162 12.0142 7.09338C12.206 7.17892 12.4177 7.2502 12.6489 7.29749C12.8402 7.3366 13.0466 7.35993 13.2681 7.35999H13.269C14.2688 7.36032 14.9747 7.96603 14.9771 8.77014C14.9793 9.57755 14.272 10.1833 13.2681 10.1832C13.0278 10.1832 12.8137 10.2034 12.6226 10.2369C12.3793 10.2796 12.1697 10.3455 11.9858 10.4254C11.4714 10.6492 11.1325 10.9918 10.7935 11.3405C10.7739 11.3605 10.7544 11.381 10.7349 11.401C10.3936 11.7507 10.0271 12.1792 9.81006 12.9352C9.72175 13.2428 9.65679 13.6119 9.63135 14.0592C9.62378 14.1924 9.61963 14.3325 9.61963 14.4801C9.61963 15.5836 9.06909 16.4876 8.17822 16.4996C7.74928 16.5053 7.36767 16.2783 7.11182 15.9147C6.85918 15.5556 6.72412 15.065 6.72412 14.4801C6.72412 14.3385 6.71808 14.2015 6.70654 14.069C6.6724 13.6774 6.59177 13.324 6.48779 13.0123C6.16402 12.0419 5.61395 11.4722 5.54443 11.401C5.54371 11.4003 5.54043 11.3977 5.53467 11.3922C5.52778 11.3857 5.51839 11.3767 5.50635 11.3658C5.4823 11.3442 5.44954 11.3158 5.40869 11.2819C5.3268 11.2139 5.21473 11.1255 5.07764 11.0289C4.80173 10.8346 4.43374 10.6113 4.01611 10.443C3.82579 10.3663 3.62728 10.3019 3.42432 10.2565C3.21687 10.21 3.00599 10.1832 2.79541 10.1832C1.79834 10.1832 1.11533 9.56575 1.11865 8.76917C1.12219 7.9773 1.80451 7.36002 2.79541 7.35999C3.01821 7.35999 3.22798 7.33422 3.42432 7.29065C3.62557 7.24597 3.81426 7.18216 3.98877 7.10608C4.6567 6.81484 5.10772 6.35442 5.3042 6.15295C5.30777 6.1493 5.31147 6.14577 5.31494 6.14221C5.51076 5.94157 6.14024 5.28964 6.48584 4.26233C6.59001 3.95264 6.66793 3.60887 6.70068 3.23303C6.71166 3.10697 6.71826 2.977 6.71826 2.84338L6.72412 2.62854C6.75331 2.13723 6.88387 1.72031 7.10303 1.40393C7.35578 1.03923 7.73495 0.804326 8.16553 0.804321Z" 70 + fill={theme.colors["accent-1"]} 71 + stroke={theme.colors["bg-leaflet"]} 72 + strokeLinecap="round" 73 + strokeLinejoin="round" 74 + /> 75 + </svg> 76 + ); 77 + }; 78 + 79 + const GlitterSmall = () => { 80 + return ( 81 + <svg 82 + width="13" 83 + height="14" 84 + viewBox="0 0 13 14" 85 + fill="none" 86 + xmlns="http://www.w3.org/2000/svg" 87 + > 88 + <path 89 + d="M6.37585 1.23596C6.7489 1.23598 7.07064 1.44034 7.28015 1.7428C7.48716 2.04187 7.59266 2.4408 7.59265 2.901C7.59266 3.00294 7.59605 3.10213 7.60242 3.19788C7.62244 3.49844 7.67183 3.76938 7.73718 4.0094C7.93813 4.74731 8.29123 5.1934 8.4071 5.31213L8.57703 5.48206C8.75042 5.64731 9.00188 5.85577 9.33777 6.00549C9.47565 6.06695 9.62723 6.11812 9.79285 6.15198C9.92991 6.18 10.0779 6.1959 10.2372 6.19592C11.0418 6.19604 11.6503 6.69195 11.6522 7.38538C11.654 8.08176 11.0444 8.57683 10.2372 8.57678C10.0618 8.57678 9.90679 8.59077 9.76941 8.61487C9.59484 8.6455 9.4456 8.69297 9.31531 8.74963C8.95055 8.9083 8.70884 9.15057 8.45203 9.41467C8.43719 9.42993 8.42207 9.44621 8.4071 9.46155C8.15582 9.71904 7.89358 10.0262 7.73718 10.5709C7.67315 10.7941 7.62512 11.064 7.60632 11.3942C7.60073 11.4925 7.59754 11.5963 7.59753 11.7057C7.59753 12.5657 7.16303 13.3455 6.38757 13.3561C6.01608 13.3611 5.6911 13.1642 5.47839 12.8619C5.26902 12.5643 5.16394 12.1657 5.16394 11.7057C5.16393 11.6022 5.15871 11.5017 5.15027 11.4049C5.1253 11.1189 5.06701 10.861 4.99109 10.6334C4.75475 9.92518 4.35324 9.51044 4.30554 9.46155C4.30554 9.46155 4.27494 9.43195 4.21179 9.37952C4.15207 9.32993 4.06961 9.26511 3.96863 9.19397C3.76515 9.05064 3.49524 8.88718 3.19031 8.76428C3.05151 8.70835 2.90782 8.66129 2.7616 8.62854C2.61218 8.59509 2.4616 8.57679 2.31238 8.57678C1.50706 8.57678 0.918891 8.07006 0.921753 7.3844C0.924612 6.70329 1.51125 6.19594 2.31238 6.19592C2.47154 6.19591 2.6213 6.17821 2.7616 6.14709C2.90554 6.11514 3.04128 6.06904 3.16687 6.01428C3.64904 5.80395 3.97684 5.47074 4.1239 5.31995C4.12648 5.3173 4.12918 5.31473 4.13171 5.31213C4.27635 5.16393 4.73656 4.68608 4.98914 3.93518C5.06511 3.70931 5.12247 3.45905 5.14636 3.18518C5.15436 3.09338 5.15905 2.99838 5.15906 2.901C5.15906 2.44078 5.26453 2.04187 5.47156 1.7428C5.68108 1.44033 6.00279 1.23595 6.37585 1.23596Z" 90 + fill={theme.colors["accent-1"]} 91 + stroke={theme.colors["bg-leaflet"]} 92 + strokeLinecap="round" 93 + strokeLinejoin="round" 94 + /> 95 + </svg> 96 + ); 97 + };
+149
app/discover/SortedPublicationList.tsx
··· 1 + "use client"; 2 + import Link from "next/link"; 3 + import { useState } from "react"; 4 + import { theme } from "tailwind.config"; 5 + import { PublicationsList } from "./page"; 6 + import { PubListing } from "./PubListing"; 7 + 8 + export function SortedPublicationList(props: { 9 + publications: PublicationsList; 10 + order: string; 11 + }) { 12 + let [order, setOrder] = useState(props.order); 13 + return ( 14 + <div className="discoverHeader flex flex-col items-center px-4"> 15 + <SortButtons 16 + order={order} 17 + setOrder={(o) => { 18 + const url = new URL(window.location.href); 19 + url.searchParams.set("order", o); 20 + window.history.pushState({}, "", url); 21 + setOrder(o); 22 + }} 23 + /> 24 + <div className="discoverPubList flex flex-col gap-3 pt-6"> 25 + {props.publications 26 + ?.filter((pub) => pub.documents_in_publications.length > 0) 27 + ?.sort((a, b) => { 28 + if (order === "popular") { 29 + console.log("sorting by popularity"); 30 + return ( 31 + b.publication_subscriptions[0].count - 32 + a.publication_subscriptions[0].count 33 + ); 34 + } 35 + const aDate = new Date( 36 + a.documents_in_publications[0]?.indexed_at || 0, 37 + ); 38 + const bDate = new Date( 39 + b.documents_in_publications[0]?.indexed_at || 0, 40 + ); 41 + return bDate.getTime() - aDate.getTime(); 42 + }) 43 + .map((pub) => <PubListing key={pub.uri} {...pub} />)} 44 + </div> 45 + </div> 46 + ); 47 + } 48 + 49 + export default function SortButtons(props: { 50 + order: string; 51 + setOrder: (order: string) => void; 52 + }) { 53 + const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( 54 + "recentlyUpdated", 55 + ); 56 + 57 + return ( 58 + <div className="flex gap-2 pt-1"> 59 + <SortButton 60 + selected={props.order === "recentlyUpdated"} 61 + onClick={() => props.setOrder("recentlyUpdated")} 62 + > 63 + Recently Updated 64 + </SortButton> 65 + 66 + <SortButton 67 + selected={props.order === "popular"} 68 + onClick={() => props.setOrder("popular")} 69 + > 70 + Popular 71 + </SortButton> 72 + </div> 73 + ); 74 + } 75 + 76 + const SortButton = (props: { 77 + children: React.ReactNode; 78 + onClick: () => void; 79 + selected: boolean; 80 + }) => { 81 + return ( 82 + <div className="relative"> 83 + <button 84 + onClick={props.onClick} 85 + style={ 86 + props.selected 87 + ? { backgroundColor: `rgba(var(--accent-1), 0.2)` } 88 + : {} 89 + } 90 + className={`text-sm rounded-md px-[8px] py-0.5 border ${props.selected ? "border-accent-contrast text-accent-1 font-bold" : "text-tertiary border-border-light"}`} 91 + > 92 + {props.children} 93 + </button> 94 + {props.selected && ( 95 + <> 96 + <div className="absolute top-0 -left-2"> 97 + <GlitterBig /> 98 + </div> 99 + <div className="absolute top-4 left-0"> 100 + <GlitterSmall /> 101 + </div> 102 + <div className="absolute -top-2 -right-1"> 103 + <GlitterSmall /> 104 + </div> 105 + </> 106 + )} 107 + </div> 108 + ); 109 + }; 110 + 111 + const GlitterBig = () => { 112 + return ( 113 + <svg 114 + width="16" 115 + height="17" 116 + viewBox="0 0 16 17" 117 + fill="none" 118 + xmlns="http://www.w3.org/2000/svg" 119 + > 120 + <path 121 + d="M8.16553 0.804321C8.5961 0.804329 8.97528 1.03925 9.22803 1.40393C9.47845 1.76546 9.6128 2.25816 9.61279 2.84338C9.61279 2.98187 9.6178 3.11647 9.62646 3.2467C9.65365 3.65499 9.72104 4.02319 9.81006 4.35022C10.0833 5.35388 10.5641 5.96726 10.7349 6.14221C10.7443 6.15184 10.7543 6.16234 10.7642 6.17249C10.9808 6.39533 11.3925 6.8162 12.0142 7.09338C12.206 7.17892 12.4177 7.2502 12.6489 7.29749C12.8402 7.3366 13.0466 7.35993 13.2681 7.35999H13.269C14.2688 7.36032 14.9747 7.96603 14.9771 8.77014C14.9793 9.57755 14.272 10.1833 13.2681 10.1832C13.0278 10.1832 12.8137 10.2034 12.6226 10.2369C12.3793 10.2796 12.1697 10.3455 11.9858 10.4254C11.4714 10.6492 11.1325 10.9918 10.7935 11.3405C10.7739 11.3605 10.7544 11.381 10.7349 11.401C10.3936 11.7507 10.0271 12.1792 9.81006 12.9352C9.72175 13.2428 9.65679 13.6119 9.63135 14.0592C9.62378 14.1924 9.61963 14.3325 9.61963 14.4801C9.61963 15.5836 9.06909 16.4876 8.17822 16.4996C7.74928 16.5053 7.36767 16.2783 7.11182 15.9147C6.85918 15.5556 6.72412 15.065 6.72412 14.4801C6.72412 14.3385 6.71808 14.2015 6.70654 14.069C6.6724 13.6774 6.59177 13.324 6.48779 13.0123C6.16402 12.0419 5.61395 11.4722 5.54443 11.401C5.54371 11.4003 5.54043 11.3977 5.53467 11.3922C5.52778 11.3857 5.51839 11.3767 5.50635 11.3658C5.4823 11.3442 5.44954 11.3158 5.40869 11.2819C5.3268 11.2139 5.21473 11.1255 5.07764 11.0289C4.80173 10.8346 4.43374 10.6113 4.01611 10.443C3.82579 10.3663 3.62728 10.3019 3.42432 10.2565C3.21687 10.21 3.00599 10.1832 2.79541 10.1832C1.79834 10.1832 1.11533 9.56575 1.11865 8.76917C1.12219 7.9773 1.80451 7.36002 2.79541 7.35999C3.01821 7.35999 3.22798 7.33422 3.42432 7.29065C3.62557 7.24597 3.81426 7.18216 3.98877 7.10608C4.6567 6.81484 5.10772 6.35442 5.3042 6.15295C5.30777 6.1493 5.31147 6.14577 5.31494 6.14221C5.51076 5.94157 6.14024 5.28964 6.48584 4.26233C6.59001 3.95264 6.66793 3.60887 6.70068 3.23303C6.71166 3.10697 6.71826 2.977 6.71826 2.84338L6.72412 2.62854C6.75331 2.13723 6.88387 1.72031 7.10303 1.40393C7.35578 1.03923 7.73495 0.804326 8.16553 0.804321Z" 122 + fill={theme.colors["accent-1"]} 123 + stroke={theme.colors["bg-leaflet"]} 124 + strokeLinecap="round" 125 + strokeLinejoin="round" 126 + /> 127 + </svg> 128 + ); 129 + }; 130 + 131 + const GlitterSmall = () => { 132 + return ( 133 + <svg 134 + width="13" 135 + height="14" 136 + viewBox="0 0 13 14" 137 + fill="none" 138 + xmlns="http://www.w3.org/2000/svg" 139 + > 140 + <path 141 + d="M6.37585 1.23596C6.7489 1.23598 7.07064 1.44034 7.28015 1.7428C7.48716 2.04187 7.59266 2.4408 7.59265 2.901C7.59266 3.00294 7.59605 3.10213 7.60242 3.19788C7.62244 3.49844 7.67183 3.76938 7.73718 4.0094C7.93813 4.74731 8.29123 5.1934 8.4071 5.31213L8.57703 5.48206C8.75042 5.64731 9.00188 5.85577 9.33777 6.00549C9.47565 6.06695 9.62723 6.11812 9.79285 6.15198C9.92991 6.18 10.0779 6.1959 10.2372 6.19592C11.0418 6.19604 11.6503 6.69195 11.6522 7.38538C11.654 8.08176 11.0444 8.57683 10.2372 8.57678C10.0618 8.57678 9.90679 8.59077 9.76941 8.61487C9.59484 8.6455 9.4456 8.69297 9.31531 8.74963C8.95055 8.9083 8.70884 9.15057 8.45203 9.41467C8.43719 9.42993 8.42207 9.44621 8.4071 9.46155C8.15582 9.71904 7.89358 10.0262 7.73718 10.5709C7.67315 10.7941 7.62512 11.064 7.60632 11.3942C7.60073 11.4925 7.59754 11.5963 7.59753 11.7057C7.59753 12.5657 7.16303 13.3455 6.38757 13.3561C6.01608 13.3611 5.6911 13.1642 5.47839 12.8619C5.26902 12.5643 5.16394 12.1657 5.16394 11.7057C5.16393 11.6022 5.15871 11.5017 5.15027 11.4049C5.1253 11.1189 5.06701 10.861 4.99109 10.6334C4.75475 9.92518 4.35324 9.51044 4.30554 9.46155C4.30554 9.46155 4.27494 9.43195 4.21179 9.37952C4.15207 9.32993 4.06961 9.26511 3.96863 9.19397C3.76515 9.05064 3.49524 8.88718 3.19031 8.76428C3.05151 8.70835 2.90782 8.66129 2.7616 8.62854C2.61218 8.59509 2.4616 8.57679 2.31238 8.57678C1.50706 8.57678 0.918891 8.07006 0.921753 7.3844C0.924612 6.70329 1.51125 6.19594 2.31238 6.19592C2.47154 6.19591 2.6213 6.17821 2.7616 6.14709C2.90554 6.11514 3.04128 6.06904 3.16687 6.01428C3.64904 5.80395 3.97684 5.47074 4.1239 5.31995C4.12648 5.3173 4.12918 5.31473 4.13171 5.31213C4.27635 5.16393 4.73656 4.68608 4.98914 3.93518C5.06511 3.70931 5.12247 3.45905 5.14636 3.18518C5.15436 3.09338 5.15905 2.99838 5.15906 2.901C5.15906 2.44078 5.26453 2.04187 5.47156 1.7428C5.68108 1.44033 6.00279 1.23595 6.37585 1.23596Z" 142 + fill={theme.colors["accent-1"]} 143 + stroke={theme.colors["bg-leaflet"]} 144 + strokeLinecap="round" 145 + strokeLinejoin="round" 146 + /> 147 + </svg> 148 + ); 149 + };
+42
app/discover/page.tsx
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import Link from "next/link"; 3 + import { SortedPublicationList } from "./SortedPublicationList"; 4 + 5 + export type PublicationsList = Awaited<ReturnType<typeof getPublications>>; 6 + async function getPublications() { 7 + let { data: publications, error } = await supabaseServerClient 8 + .from("publications") 9 + .select( 10 + "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", 11 + ) 12 + .or( 13 + "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 14 + ) 15 + .order("indexed_at", { 16 + referencedTable: "documents_in_publications", 17 + ascending: false, 18 + }) 19 + .limit(1, { referencedTable: "documents_in_publications" }); 20 + return publications; 21 + } 22 + export default async function Discover(props: { 23 + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 24 + }) { 25 + let order = ((await props.searchParams).order as string) || "recentlyUpdated"; 26 + let publications = await getPublications(); 27 + 28 + return ( 29 + <div className="bg-[#FDFCFA] w-full h-full overflow-scroll"> 30 + <div className="max-w-prose mx-auto sm:py-6 py-4 px-4"> 31 + <div className="discoverHeader flex flex-col items-center px-4"> 32 + <h1>Discover</h1> 33 + <p className="text-lg text-secondary italic mb-2"> 34 + Explore publications on Leaflet ✨ Or{" "} 35 + <Link href="/lish/createPub">make your own</Link>! 36 + </p> 37 + </div> 38 + <SortedPublicationList publications={publications} order={order} /> 39 + </div> 40 + </div> 41 + ); 42 + }
+1 -1
app/lish/Subscribe.tsx
··· 311 311 > 312 312 {isClient && ( 313 313 <LoginForm 314 - publication 314 + text="Log in to subscribe to this publication!" 315 315 noEmail 316 316 redirectRoute={window?.location.href + "?refreshAuth"} 317 317 action={{ action: "subscribe", publication: props.pub_uri }}
+21
app/lish/createPub/CreatePubForm.tsx
··· 12 12 import { getBasePublicationURL, getPublicationURL } from "./getPublicationURL"; 13 13 import { string } from "zod"; 14 14 import { DotLoader } from "components/utils/DotLoader"; 15 + import { Checkbox } from "components/Checkbox"; 15 16 16 17 type DomainState = 17 18 | { status: "empty" } ··· 24 25 let [formState, setFormState] = useState<"normal" | "loading">("normal"); 25 26 let [nameValue, setNameValue] = useState(""); 26 27 let [descriptionValue, setDescriptionValue] = useState(""); 28 + let [showInDiscover, setShowInDiscover] = useState(true); 27 29 let [logoFile, setLogoFile] = useState<File | null>(null); 28 30 let [logoPreview, setLogoPreview] = useState<string | null>(null); 29 31 let [domainValue, setDomainValue] = useState(""); ··· 45 47 description: descriptionValue, 46 48 iconFile: logoFile, 47 49 subdomain: domainValue, 50 + preferences: { showInDiscover }, 48 51 }); 49 52 // Show a spinner while this is happening! Maybe a progress bar? 50 53 setTimeout(() => { ··· 117 120 domainState={domainState} 118 121 setDomainState={setDomainState} 119 122 /> 123 + <hr className="border-border-light" /> 124 + <Checkbox 125 + checked={showInDiscover} 126 + onChange={(e) => setShowInDiscover(e.target.checked)} 127 + > 128 + <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 129 + <p className="font-bold italic"> 130 + Show In{" "} 131 + <a href="/discover" target="_blank"> 132 + Discover 133 + </a> 134 + </p> 135 + <p className="text-sm text-tertiary font-normal"> 136 + This publication will appear on our public Discover page 137 + </p> 138 + </div> 139 + </Checkbox> 140 + <hr className="border-border-light" /> 120 141 121 142 <div className="flex w-full justify-center"> 122 143 <ButtonPrimary
+31
app/lish/createPub/UpdatePubForm.tsx
··· 17 17 import { LoadingTiny } from "components/Icons/LoadingTiny"; 18 18 import { PinTiny } from "components/Icons/PinTiny"; 19 19 import { Verification } from "@vercel/sdk/esm/models/getprojectdomainop"; 20 + import Link from "next/link"; 21 + import { Checkbox } from "components/Checkbox"; 20 22 21 23 export const EditPubForm = () => { 22 24 let { data: pubData } = usePublicationData(); ··· 24 26 let [formState, setFormState] = useState<"normal" | "loading">("normal"); 25 27 26 28 let [nameValue, setNameValue] = useState(record?.name || ""); 29 + let [showInDiscover, setShowInDiscover] = useState( 30 + record?.preferences?.showInDiscover === undefined 31 + ? true 32 + : record.preferences.showInDiscover, 33 + ); 27 34 let [descriptionValue, setDescriptionValue] = useState( 28 35 record?.description || "", 29 36 ); ··· 53 60 name: nameValue, 54 61 description: descriptionValue, 55 62 iconFile: iconFile, 63 + preferences: { 64 + showInDiscover: showInDiscover, 65 + }, 56 66 }); 57 67 toast({ type: "success", content: "Updated!" }); 58 68 setFormState("normal"); ··· 95 105 }} 96 106 /> 97 107 </div> 108 + 98 109 <label> 99 110 <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 100 111 Publication Name ··· 126 137 </label> 127 138 128 139 <CustomDomainForm /> 140 + <hr className="border-border-light" /> 141 + 142 + <Checkbox 143 + checked={showInDiscover} 144 + onChange={(e) => setShowInDiscover(e.target.checked)} 145 + > 146 + <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 147 + <p className="font-bold"> 148 + Show In{" "} 149 + <a href="/discover" target="_blank"> 150 + Discover 151 + </a> 152 + </p> 153 + <p className="text-xs text-tertiary font-normal"> 154 + This publication will appear on our public Discover page 155 + </p> 156 + </div> 157 + </Checkbox> 158 + <hr className="border-border-light" /> 159 + 129 160 <ButtonPrimary className="place-self-end" type="submit"> 130 161 {formState === "loading" ? <DotLoader /> : "Update!"} 131 162 </ButtonPrimary>
+3
app/lish/createPub/createPublication.ts
··· 23 23 description, 24 24 iconFile, 25 25 subdomain, 26 + preferences, 26 27 }: { 27 28 name: string; 28 29 description: string; 29 30 iconFile: File | null; 30 31 subdomain: string; 32 + preferences: Omit<PubLeafletPublication.Preferences, "$type">; 31 33 }) { 32 34 let isSubdomainValid = subdomainValidator.safeParse(subdomain); 33 35 if (!isSubdomainValid.success) { ··· 46 48 let record: Un$Typed<PubLeafletPublication.Record> = { 47 49 name, 48 50 base_path: domain, 51 + preferences, 49 52 }; 50 53 51 54 if (description) {
+19 -2
app/lish/createPub/page.tsx
··· 1 1 import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 2 2 import { CreatePubForm } from "./CreatePubForm"; 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import LoginForm from "app/login/LoginForm"; 3 5 4 6 export default async function CreatePub() { 7 + let identity = await getIdentityData(); 8 + if (!identity) 9 + return ( 10 + <div className="createPubPage relative w-full h-full flex items-stretch bg-bg-leaflet p-4"> 11 + <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto"> 12 + <div className="container w-full p-3 justify-items-center text-center"> 13 + <LoginForm 14 + text="Log in to create a publication!" 15 + noEmail 16 + redirectRoute={"/lish/createPub"} 17 + /> 18 + </div> 19 + </div> 20 + </div> 21 + ); 5 22 return ( 6 23 // Eventually this can pull from home theme? 7 24 <ThemeProvider entityID={null}> 8 25 <div className="createPubPage relative w-full h-full flex items-stretch bg-bg-leaflet p-4"> 9 - <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto "> 26 + <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto"> 10 27 <div className="createPubFormWrapper h-fit w-full flex flex-col gap-4"> 11 28 <h2 className="text-center">Create Your Publication!</h2> 12 - <div className="container w-full p-3"> 29 + <div className="container w-full p-3"> 13 30 <CreatePubForm /> 14 31 </div> 15 32 </div>
+5
app/lish/createPub/updatePublication.ts
··· 19 19 name, 20 20 description, 21 21 iconFile, 22 + preferences, 22 23 }: { 23 24 uri: string; 24 25 name: string; 25 26 description: string; 26 27 iconFile: File | null; 28 + preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 27 29 }) { 28 30 const oauthClient = await createOauthClient(); 29 31 let identity = await getIdentityData(); ··· 46 48 ...(existingPub.record as object), 47 49 name, 48 50 }; 51 + if (preferences) { 52 + record.preferences = preferences; 53 + } 49 54 50 55 if (description) { 51 56 record.description = description;
+2 -6
app/login/LoginForm.tsx
··· 16 16 17 17 export default function LoginForm(props: { 18 18 noEmail?: boolean; 19 - publication?: boolean; 20 19 redirectRoute?: string; 21 20 action?: ActionAfterSignIn; 21 + text: React.ReactNode; 22 22 }) { 23 23 type FormState = 24 24 | { ··· 121 121 <div className="flex flex-col gap-3 w-full max-w-xs pb-1"> 122 122 <div className="flex flex-col"> 123 123 <h4 className="text-primary">Log In or Sign Up</h4> 124 - <div className=" text-tertiary text-sm"> 125 - {props.publication 126 - ? "Log in to Bluesky to subscribe this publication!" 127 - : "Save your Leaflets and access them on multiple devices!"} 128 - </div> 124 + <div className=" text-tertiary text-sm">{props.text}</div> 129 125 </div> 130 126 131 127 <BlueskyLogin {...props} />
+2 -2
components/LoginButton.tsx
··· 18 18 </ButtonPrimary> 19 19 } 20 20 > 21 - <LoginForm /> 21 + <LoginForm text="Save your Leaflets and access them on multiple devices!" /> 22 22 </Popover> 23 23 ); 24 24 } ··· 33 33 <ActionButton secondary icon={<AccountSmall />} label="Sign In" /> 34 34 } 35 35 > 36 - <LoginForm /> 36 + <LoginForm text="Save your Leaflets and access them on multiple devices!" /> 37 37 </Popover> 38 38 ); 39 39 }
+1 -1
components/ShareOptions/index.tsx
··· 62 62 > 63 63 {menuState === "login" ? ( 64 64 <div className="px-3 py-1"> 65 - <LoginForm /> 65 + <LoginForm text="Save your Leaflets and access them on multiple devices!" /> 66 66 </div> 67 67 ) : menuState === "domain" ? ( 68 68 <CustomDomainMenu setShareMenuState={setMenuState} />
+13
lexicons/api/lexicons.ts
··· 95 95 type: 'ref', 96 96 ref: 'lex:pub.leaflet.publication#theme', 97 97 }, 98 + preferences: { 99 + type: 'ref', 100 + ref: 'lex:pub.leaflet.publication#preferences', 101 + }, 102 + }, 103 + }, 104 + }, 105 + preferences: { 106 + type: 'object', 107 + properties: { 108 + showInDiscover: { 109 + type: 'boolean', 110 + default: true, 98 111 }, 99 112 }, 100 113 },
+16
lexicons/api/types/pub/leaflet/publication.ts
··· 19 19 description?: string 20 20 icon?: BlobRef 21 21 theme?: Theme 22 + preferences?: Preferences 22 23 [k: string]: unknown 23 24 } 24 25 ··· 30 31 31 32 export function validateRecord<V>(v: V) { 32 33 return validate<Record & V>(v, id, hashRecord, true) 34 + } 35 + 36 + export interface Preferences { 37 + $type?: 'pub.leaflet.publication#preferences' 38 + showInDiscover: boolean 39 + } 40 + 41 + const hashPreferences = 'preferences' 42 + 43 + export function isPreferences<V>(v: V) { 44 + return is$typed(v, id, hashPreferences) 45 + } 46 + 47 + export function validatePreferences<V>(v: V) { 48 + return validate<Preferences & V>(v, id, hashPreferences) 33 49 } 34 50 35 51 export interface Theme {
+13
lexicons/pub/leaflet/publication.json
··· 34 34 "theme": { 35 35 "type": "ref", 36 36 "ref": "#theme" 37 + }, 38 + "preferences": { 39 + "type": "ref", 40 + "ref": "#preferences" 37 41 } 42 + } 43 + } 44 + }, 45 + "preferences": { 46 + "type": "object", 47 + "properties": { 48 + "showInDiscover": { 49 + "type": "boolean", 50 + "default": true 38 51 } 39 52 } 40 53 },
+7
lexicons/src/publication.ts
··· 18 18 description: { type: "string", maxLength: 2000 }, 19 19 icon: { type: "blob", accept: ["image/*"], maxSize: 1000000 }, 20 20 theme: { type: "ref", ref: "#theme" }, 21 + preferences: { type: "ref", ref: "#preferences" }, 21 22 }, 23 + }, 24 + }, 25 + preferences: { 26 + type: "object", 27 + properties: { 28 + showInDiscover: { type: "boolean", default: true }, 22 29 }, 23 30 }, 24 31 theme: {