diff --git a/actions/getIdentityData.ts b/actions/getIdentityData.ts --- a/actions/getIdentityData.ts +++ b/actions/getIdentityData.ts @@ -2,8 +2,9 @@ import { cookies } from "next/headers"; import { supabaseServerClient } from "supabase/serverClient"; - -export async function getIdentityData() { +import { cache } from "react"; +export const getIdentityData = cache(uncachedGetIdentityData); +export async function uncachedGetIdentityData() { let cookieStore = await cookies(); let auth_token = cookieStore.get("auth_token")?.value || @@ -18,7 +19,9 @@ bsky_profiles(*), publication_subscriptions(*), custom_domains!custom_domains_identity_id_fkey(publication_domains(*), *), - home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), + home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*, + entity_sets(entities(facts(*))) + )), permission_token_on_homepage( created_at, permission_tokens!inner( diff --git a/app/(home-pages)/layout.tsx b/app/(home-pages)/layout.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/layout.tsx @@ -0,0 +1,38 @@ +import { getIdentityData } from "actions/getIdentityData"; +import { EntitySetProvider } from "components/EntitySetProvider"; +import { + ThemeProvider, + ThemeBackgroundProvider, +} from "components/ThemeManager/ThemeProvider"; +import { ReplicacheProvider, type Fact } from "src/replicache"; + +export default async function HomePagesLayout(props: { + children: React.ReactNode; +}) { + let identityData = await getIdentityData(); + if (!identityData?.home_leaflet) return <>{props.children}; + let facts = + (identityData?.home_leaflet?.permission_token_rights[0].entity_sets?.entities.flatMap( + (e) => e.facts, + ) || []) as Fact[]; + + let root_entity = identityData.home_leaflet.root_entity; + return ( + + + + + {props.children} + + + + + ); +} diff --git a/app/discover/PubListing.tsx b/app/discover/PubListing.tsx deleted file mode 100644 --- a/app/discover/PubListing.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; -import { AtUri } from "@atproto/syntax"; -import { PublicationSubscription } from "app/reader/getSubscriptions"; -import { PubIcon } from "components/ActionBar/Publications"; -import { Separator } from "components/Layout"; -import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; -import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; -import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; -import { blobRefToSrc } from "src/utils/blobRefToSrc"; -import { timeAgo } from "src/utils/timeAgo"; -import { Json } from "supabase/database.types"; - -export const PubListing = ( - props: PublicationSubscription & { - resizeHeight?: boolean; - }, -) => { - let record = props.record as PubLeafletPublication.Record; - let theme = usePubTheme(record); - let backgroundImage = record?.theme?.backgroundImage?.image?.ref - ? blobRefToSrc( - record?.theme?.backgroundImage?.image?.ref, - new AtUri(props.uri).host, - ) - : null; - - let backgroundImageRepeat = record?.theme?.backgroundImage?.repeat; - let backgroundImageSize = record?.theme?.backgroundImage?.width || 500; - if (!record) return null; - return ( - - -
-
- -
- -

{record.name}

- {record.description && ( -

- {record.description} -

- )} -
-
- {props.authorProfile?.handle} -
-

- Updated{" "} - {timeAgo( - props.documents_in_publications?.[0]?.documents?.indexed_at || - "", - )} -

-
-
-
-
- ); -}; diff --git a/app/discover/SortButtons.tsx b/app/discover/SortButtons.tsx deleted file mode 100644 --- a/app/discover/SortButtons.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; -import Link from "next/link"; -import { useState } from "react"; -import { theme } from "tailwind.config"; - -export default function SortButtons(props: { order: string }) { - const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( - "recentlyUpdated", - ); - - return ( -
- - - Recently Updated - - - - - Popular - -
- ); -} - -const SortButton = (props: { - children: React.ReactNode; - selected: boolean; -}) => { - return ( -
- - {props.selected && ( - <> -
- -
-
- -
-
- -
- - )} -
- ); -}; - -const GlitterBig = () => { - return ( - - - - ); -}; - -const GlitterSmall = () => { - return ( - - - - ); -}; diff --git a/app/discover/SortedPublicationList.tsx b/app/discover/SortedPublicationList.tsx deleted file mode 100644 --- a/app/discover/SortedPublicationList.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; -import Link from "next/link"; -import { useState } from "react"; -import { theme } from "tailwind.config"; -import { PublicationsList } from "./page"; -import { PubListing } from "./PubListing"; - -export function SortedPublicationList(props: { - publications: PublicationsList; - order: string; -}) { - let [order, setOrder] = useState(props.order); - return ( -
- { - const url = new URL(window.location.href); - url.searchParams.set("order", o); - window.history.pushState({}, "", url); - setOrder(o); - }} - /> -
- {props.publications - ?.filter((pub) => pub.documents_in_publications.length > 0) - ?.sort((a, b) => { - if (order === "popular") { - return ( - b.publication_subscriptions[0].count - - a.publication_subscriptions[0].count - ); - } - const aDate = new Date( - a.documents_in_publications[0]?.indexed_at || 0, - ); - const bDate = new Date( - b.documents_in_publications[0]?.indexed_at || 0, - ); - return bDate.getTime() - aDate.getTime(); - }) - .map((pub) => )} -
-
- ); -} - -export default function SortButtons(props: { - order: string; - setOrder: (order: string) => void; -}) { - const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( - "recentlyUpdated", - ); - - return ( -
- props.setOrder("recentlyUpdated")} - > - Recently Updated - - - props.setOrder("popular")} - > - Popular - -
- ); -} - -const SortButton = (props: { - children: React.ReactNode; - onClick: () => void; - selected: boolean; -}) => { - return ( -
- - {props.selected && ( - <> -
- -
-
- -
-
- -
- - )} -
- ); -}; - -const GlitterBig = () => { - return ( - - - - ); -}; - -const GlitterSmall = () => { - return ( - - - - ); -}; diff --git a/app/discover/page.tsx b/app/discover/page.tsx deleted file mode 100644 --- a/app/discover/page.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { supabaseServerClient } from "supabase/serverClient"; -import Link from "next/link"; -import { SortedPublicationList } from "./SortedPublicationList"; -import { Metadata } from "next"; -import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; - -export const dynamic = "force-static"; -export const revalidate = 60; - -export type PublicationsList = Awaited>; -async function getPublications() { - let { data: publications, error } = await supabaseServerClient - .from("publications") - .select( - "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", - ) - .or( - "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", - ) - .order("indexed_at", { - referencedTable: "documents_in_publications", - ascending: false, - }) - .limit(1, { referencedTable: "documents_in_publications" }); - return publications; -} - -export const metadata: Metadata = { - title: "Leaflet Discover", - description: "Explore publications on Leaflet ✨ Or make your own!", -}; - -export default async function Discover(props: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -}) { - let order = ((await props.searchParams).order as string) || "recentlyUpdated"; - let publications = await getPublications(); - - return ( -
- , - }, - }} - /> -
- ); -} - -const DiscoverContent = async (props: { order: string }) => { - let publications = await getPublications(); - - return ( -
-
-

Discover

-

- Explore publications on Leaflet ✨ Or{" "} - make your own! -

-
- -
- ); -}; diff --git a/app/home/HomeLayout.tsx b/app/home/HomeLayout.tsx deleted file mode 100644 --- a/app/home/HomeLayout.tsx +++ /dev/null @@ -1,333 +0,0 @@ -"use client"; - -import { getHomeDocs, HomeDoc } from "./storage"; -import useSWR from "swr"; -import { - Fact, - PermissionToken, - ReplicacheProvider, - useEntity, -} from "src/replicache"; -import { LeafletListItem } from "./LeafletList/LeafletListItem"; -import { useIdentityData } from "components/IdentityProvider"; -import type { Attribute } from "src/replicache/attributes"; -import { callRPC } from "app/api/rpc/client"; -import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; -import { HomeSmall } from "components/Icons/HomeSmall"; -import { - HomeDashboardControls, - DashboardLayout, - DashboardState, - useDashboardState, -} from "components/PageLayouts/DashboardLayout"; -import { Actions } from "./Actions/Actions"; -import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; -import { Json } from "supabase/database.types"; -import { useTemplateState } from "./Actions/CreateNewButton"; -import { CreateNewLeafletButton } from "./Actions/CreateNewButton"; -import { ActionButton } from "components/ActionBar/ActionButton"; -import { AddTiny } from "components/Icons/AddTiny"; -import { - get_leaflet_data, - GetLeafletDataReturnType, -} from "app/api/rpc/[command]/get_leaflet_data"; -import { useEffect, useRef, useState } from "react"; -import { Input } from "components/Input"; -import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; -import { - ButtonPrimary, - ButtonSecondary, - ButtonTertiary, -} from "components/Buttons"; -import { AddSmall } from "components/Icons/AddSmall"; -import { PublishIllustration } from "app/[leaflet_id]/publish/PublishIllustration/PublishIllustration"; -import { PubListEmptyIllo } from "components/ActionBar/Publications"; -import { theme } from "tailwind.config"; -import Link from "next/link"; -import { DiscoverIllo } from "./HomeEmpty/DiscoverIllo"; -import { WelcomeToLeafletIllo } from "./HomeEmpty/WelcomeToLeafletIllo"; -import { - DiscoverBanner, - HomeEmptyState, - PublicationBanner, -} from "./HomeEmpty/HomeEmpty"; - -type Leaflet = { - added_at: string; - token: PermissionToken & { - leaflets_in_publications?: Exclude< - GetLeafletDataReturnType["result"]["data"], - null - >["leaflets_in_publications"]; - }; -}; - -export const HomeLayout = (props: { - entityID: string; - titles: { [root_entity: string]: string }; - initialFacts: { - [root_entity: string]: Fact[]; - }; -}) => { - let hasBackgroundImage = !!useEntity( - props.entityID, - "theme/background-image", - ); - let cardBorderHidden = !!useCardBorderHidden(props.entityID); - - let [searchValue, setSearchValue] = useState(""); - let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); - - useDebouncedEffect( - () => { - setDebouncedSearchValue(searchValue); - }, - 200, - [searchValue], - ); - - let { identity } = useIdentityData(); - - let hasPubs = !identity || identity.publications.length === 0 ? false : true; - let hasTemplates = - useTemplateState((s) => s.templates).length === 0 ? false : true; - - return ( - } - tabs={{ - home: { - controls: ( - - ), - content: ( - - ), - }, - }} - /> - ); -}; - -export function HomeLeafletList(props: { - titles: { [root_entity: string]: string }; - initialFacts: { - [root_entity: string]: Fact[]; - }; - searchValue: string; - cardBorderHidden: boolean; -}) { - let { identity } = useIdentityData(); - let { data: initialFacts } = useSWR( - "home-leaflet-data", - async () => { - if (identity) { - let { result } = await callRPC("getFactsFromHomeLeaflets", { - tokens: identity.permission_token_on_homepage.map( - (ptrh) => ptrh.permission_tokens.root_entity, - ), - }); - let titles = { - ...result.titles, - ...identity.permission_token_on_homepage.reduce( - (acc, tok) => { - let title = - tok.permission_tokens.leaflets_in_publications[0]?.title; - if (title) acc[tok.permission_tokens.root_entity] = title; - return acc; - }, - {} as { [k: string]: string }, - ), - }; - return { ...result, titles }; - } - }, - { fallbackData: { facts: props.initialFacts, titles: props.titles } }, - ); - - let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { - fallbackData: [], - }); - let leaflets: Leaflet[] = identity - ? identity.permission_token_on_homepage.map((ptoh) => ({ - added_at: ptoh.created_at, - token: ptoh.permission_tokens as PermissionToken, - })) - : localLeaflets - .sort((a, b) => (a.added_at > b.added_at ? -1 : 1)) - .filter((d) => !d.hidden) - .map((ll) => ll); - - return leaflets.length === 0 ? ( - - ) : ( - <> - -
- - {leaflets.filter((l) => !!l.token.leaflets_in_publications).length === - 0 && } - - - ); -} - -export function LeafletList(props: { - leaflets: Leaflet[]; - titles: { [root_entity: string]: string }; - defaultDisplay: Exclude; - initialFacts: { - [root_entity: string]: Fact[]; - }; - searchValue: string; - cardBorderHidden: boolean; - showPreview?: boolean; -}) { - let { identity } = useIdentityData(); - let { display } = useDashboardState(); - - display = display || props.defaultDisplay; - - let searchedLeaflets = useSearchedLeaflets( - props.leaflets, - props.titles, - props.searchValue, - ); - - return ( -
- {props.leaflets.map(({ token: leaflet, added_at }, index) => ( - - - l.doc)} - publishedAt={ - leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents - ?.indexed_at - } - leaflet_id={leaflet.root_entity} - loggedIn={!!identity} - display={display} - added_at={added_at} - cardBorderHidden={props.cardBorderHidden} - index={index} - showPreview={props.showPreview} - isHidden={ - !searchedLeaflets.some( - (sl) => sl.token.root_entity === leaflet.root_entity, - ) - } - /> - - - ))} -
- ); -} - -function useSearchedLeaflets( - leaflets: Leaflet[], - titles: { [root_entity: string]: string }, - searchValue: string, -) { - let { sort, filter } = useDashboardState(); - - let sortedLeaflets = leaflets.sort((a, b) => { - if (sort === "alphabetical") { - if (titles[a.token.root_entity] === titles[b.token.root_entity]) { - return a.added_at > b.added_at ? -1 : 1; - } else { - return titles[a.token.root_entity].toLocaleLowerCase() > - titles[b.token.root_entity].toLocaleLowerCase() - ? 1 - : -1; - } - } else { - return a.added_at === b.added_at - ? a.token.root_entity > b.token.root_entity - ? -1 - : 1 - : a.added_at > b.added_at - ? -1 - : 1; - } - }); - - let allTemplates = useTemplateState((s) => s.templates); - let filteredLeaflets = sortedLeaflets.filter(({ token: leaflet }) => { - let published = !!leaflet.leaflets_in_publications?.find((l) => l.doc); - let drafts = !!leaflet.leaflets_in_publications?.length && !published; - let docs = !leaflet.leaflets_in_publications?.length; - let templates = !!allTemplates.find((t) => t.id === leaflet.id); - // If no filters are active, show all - if ( - !filter.drafts && - !filter.published && - !filter.docs && - !filter.templates - ) - return true; - - return ( - (filter.drafts && drafts) || - (filter.published && published) || - (filter.docs && docs) || - (filter.templates && templates) - ); - }); - if (searchValue === "") return filteredLeaflets; - let searchedLeaflets = filteredLeaflets.filter(({ token: leaflet }) => { - return titles[leaflet.root_entity] - ?.toLowerCase() - .includes(searchValue.toLowerCase()); - }); - - return searchedLeaflets; -} diff --git a/app/home/IdentitySetter.tsx b/app/home/IdentitySetter.tsx deleted file mode 100644 --- a/app/home/IdentitySetter.tsx +++ /dev/null @@ -1,13 +0,0 @@ -"use client"; - -import { useEffect } from "react"; - -export function IdentitySetter(props: { - cb: () => Promise; - call: boolean; -}) { - useEffect(() => { - if (props.call) props.cb(); - }, [props]); - return null; -} diff --git a/app/home/LoggedOutWarning.tsx b/app/home/LoggedOutWarning.tsx deleted file mode 100644 --- a/app/home/LoggedOutWarning.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; -import { useIdentityData } from "components/IdentityProvider"; -import { LoginButton } from "components/LoginButton"; - -export const LoggedOutWarning = (props: {}) => { - let { identity } = useIdentityData(); - if (identity) return null; - return ( -
-
-

- Log in to collect all your Leaflets and access them on multiple - devices -

- -
-
- ); -}; diff --git a/app/home/icon.tsx b/app/home/icon.tsx deleted file mode 100644 --- a/app/home/icon.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { ImageResponse } from "next/og"; -import type { Fact } from "src/replicache"; -import type { Attribute } from "src/replicache/attributes"; -import { Database } from "../../supabase/database.types"; -import { createServerClient } from "@supabase/ssr"; -import { parseHSBToRGB } from "src/utils/parseHSB"; -import { cookies } from "next/headers"; - -// Route segment config -export const revalidate = 0; -export const preferredRegion = ["sfo1"]; -export const dynamic = "force-dynamic"; -export const fetchCache = "force-no-store"; - -// Image metadata -export const size = { - width: 32, - height: 32, -}; -export const contentType = "image/png"; - -// Image generation -let supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, - process.env.SUPABASE_SERVICE_ROLE_KEY as string, - { cookies: {} }, -); -export default async function Icon() { - let cookieStore = await cookies(); - let identity = cookieStore.get("identity"); - let rootEntity: string | null = null; - if (identity) { - let res = await supabase - .from("identities") - .select( - `*, - permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), - permission_token_on_homepage( - *, permission_tokens(*, permission_token_rights(*)) - ) - `, - ) - .eq("id", identity?.value) - .single(); - rootEntity = res.data?.permission_tokens?.root_entity || null; - } - let outlineColor, fillColor; - if (rootEntity) { - let { data } = await supabase.rpc("get_facts", { - root: rootEntity, - }); - let initialFacts = (data as unknown as Fact[]) || []; - let themePageBG = initialFacts.find( - (f) => f.attribute === "theme/card-background", - ) as Fact<"theme/card-background"> | undefined; - - let themePrimary = initialFacts.find( - (f) => f.attribute === "theme/primary", - ) as Fact<"theme/primary"> | undefined; - - outlineColor = parseHSBToRGB(`hsba(${themePageBG?.data.value})`); - - fillColor = parseHSBToRGB(`hsba(${themePrimary?.data.value})`); - } - - return new ImageResponse( - ( - // ImageResponse JSX element -
- - {/* outline */} - - - {/* fill */} - - -
- ), - // ImageResponse options - { - // For convenience, we can re-use the exported icons size metadata - // config to also set the ImageResponse's width and height. - ...size, - headers: { - "Cache-Control": "no-cache", - }, - }, - ); -} diff --git a/app/home/page.tsx b/app/home/page.tsx deleted file mode 100644 --- a/app/home/page.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { cookies } from "next/headers"; -import { Fact, ReplicacheProvider, useEntity } from "src/replicache"; -import type { Attribute } from "src/replicache/attributes"; -import { - ThemeBackgroundProvider, - ThemeProvider, -} from "components/ThemeManager/ThemeProvider"; -import { EntitySetProvider } from "components/EntitySetProvider"; -import { createIdentity } from "actions/createIdentity"; -import { drizzle } from "drizzle-orm/node-postgres"; -import { IdentitySetter } from "./IdentitySetter"; - -import { getIdentityData } from "actions/getIdentityData"; -import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; -import { supabaseServerClient } from "supabase/serverClient"; -import { pool } from "supabase/pool"; - -import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; -import { HomeLayout } from "./HomeLayout"; - -export default async function Home() { - let cookieStore = await cookies(); - let auth_res = await getIdentityData(); - let identity: string | undefined; - if (auth_res) identity = auth_res.id; - else identity = cookieStore.get("identity")?.value; - let needstosetcookie = false; - if (!identity) { - const client = await pool.connect(); - const db = drizzle(client); - let newIdentity = await createIdentity(db); - client.release(); - identity = newIdentity.id; - needstosetcookie = true; - } - - async function setCookie() { - "use server"; - - (await cookies()).set("identity", identity as string, { - sameSite: "strict", - }); - } - - let permission_token = auth_res?.home_leaflet; - if (!permission_token) { - let res = await supabaseServerClient - .from("identities") - .select( - `*, - permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)) - `, - ) - .eq("id", identity) - .single(); - permission_token = res.data?.permission_tokens; - } - - if (!permission_token) - return ( - -

Sorry, we can't find this home!

-

- This may be a glitch on our end. If the issue persists please{" "} - send us a note. -

-
- ); - let [homeLeafletFacts, allLeafletFacts] = await Promise.all([ - supabaseServerClient.rpc("get_facts", { - root: permission_token.root_entity, - }), - auth_res - ? getFactsFromHomeLeaflets.handler( - { - tokens: auth_res.permission_token_on_homepage.map( - (r) => r.permission_tokens.root_entity, - ), - }, - { supabase: supabaseServerClient }, - ) - : undefined, - ]); - let initialFacts = - (homeLeafletFacts.data as unknown as Fact[]) || []; - - let root_entity = permission_token.root_entity; - let home_docs_initialFacts = allLeafletFacts?.result || {}; - - return ( - - - - - - { - let title = - tok.permission_tokens.leaflets_in_publications[0]?.title; - if (title) acc[tok.permission_tokens.root_entity] = title; - return acc; - }, - {} as { [k: string]: string }, - ), - }} - entityID={root_entity} - initialFacts={home_docs_initialFacts.facts || {}} - /> - - - - - ); -} diff --git a/app/home/storage.ts b/app/home/storage.ts deleted file mode 100644 --- a/app/home/storage.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { PermissionToken } from "src/replicache"; -import { mutate } from "swr"; - -export type HomeDoc = { - token: PermissionToken; - added_at: string; - hidden?: boolean; -}; -type HomeDocsStorage = { - version: number; - docs: Array; -}; -let defaultValue: HomeDocsStorage = { - version: 1, - docs: [], -}; -const key = "homepageDocs-v1"; -let tokenCache = new Map(); -export function getHomeDocs() { - let homepageDocs: HomeDocsStorage = JSON.parse( - window.localStorage.getItem(key) || JSON.stringify(defaultValue), - ); - return homepageDocs.docs.map((d) => { - let cachedToken = tokenCache.get(d.token.id); - if (!cachedToken) { - cachedToken = d.token; - tokenCache.set(d.token.id, d.token); - } - return { ...d, token: cachedToken }; - }); -} - -export function addDocToHome(doc: PermissionToken) { - let homepageDocs = getHomeDocs(); - if (homepageDocs.find((d) => d.token.id === doc.id)) return; - homepageDocs.push({ token: doc, added_at: new Date().toISOString() }); - let newValue: HomeDocsStorage = { - version: 1, - docs: homepageDocs, - }; - window.localStorage.setItem(key, JSON.stringify(newValue)); -} - -export function removeDocFromHome(doc: PermissionToken) { - let homepageDocs = getHomeDocs(); - let newDocs = homepageDocs.filter((d) => d.token.id !== doc.id); - let newValue: HomeDocsStorage = { - version: 1, - docs: newDocs, - }; - window.localStorage.setItem(key, JSON.stringify(newValue)); -} - -export function hideDoc(doc: PermissionToken) { - let homepageDocs = getHomeDocs(); - let newDocs = homepageDocs.filter((d) => d.token.id !== doc.id); - newDocs.push({ - token: doc, - added_at: new Date().toISOString(), - hidden: true, - }); - let newValue: HomeDocsStorage = { - version: 1, - docs: newDocs, - }; - window.localStorage.setItem(key, JSON.stringify(newValue)); - mutate("leaflets"); -} diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -5,7 +5,7 @@ } from "actions/emailAuth"; import { loginWithEmailToken } from "actions/login"; import { ActionAfterSignIn } from "app/api/oauth/[route]/afterSignInActions"; -import { getHomeDocs } from "app/home/storage"; +import { getHomeDocs } from "app/(home-pages)/home/storage"; import { ButtonPrimary } from "components/Buttons"; import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; import { BlueskySmall } from "components/Icons/BlueskySmall"; diff --git a/app/reader/ReaderContent.tsx b/app/reader/ReaderContent.tsx deleted file mode 100644 --- a/app/reader/ReaderContent.tsx +++ /dev/null @@ -1,286 +0,0 @@ -"use client"; -import { AtUri } from "@atproto/api"; -import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; -import { PubIcon } from "components/ActionBar/Publications"; -import { ButtonPrimary } from "components/Buttons"; -import { CommentTiny } from "components/Icons/CommentTiny"; -import { DiscoverSmall } from "components/Icons/DiscoverSmall"; -import { QuoteTiny } from "components/Icons/QuoteTiny"; -import { Separator } from "components/Layout"; -import { SpeedyLink } from "components/SpeedyLink"; -import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; -import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; -import { useSmoker } from "components/Toast"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; -import { blobRefToSrc } from "src/utils/blobRefToSrc"; -import { Json } from "supabase/database.types"; -import type { Cursor, Post } from "./getReaderFeed"; -import useSWRInfinite from "swr/infinite"; -import { getReaderFeed } from "./getReaderFeed"; -import { useEffect, useRef } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { useLocalizedDate } from "src/hooks/useLocalizedDate"; - -export const ReaderContent = (props: { - root_entity: string; - posts: Post[]; - nextCursor: Cursor | null; -}) => { - const getKey = ( - pageIndex: number, - previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, - ) => { - // Reached the end - if (previousPageData && !previousPageData.nextCursor) return null; - - // First page, we don't have previousPageData - if (pageIndex === 0) return ["reader-feed", null] as const; - - // Add the cursor to the key - return ["reader-feed", previousPageData?.nextCursor] as const; - }; - - const { data, error, size, setSize, isValidating } = useSWRInfinite( - getKey, - ([_, cursor]) => getReaderFeed(cursor), - { - fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], - revalidateFirstPage: false, - }, - ); - - const loadMoreRef = useRef(null); - - // Set up intersection observer to load more when trigger element is visible - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && !isValidating) { - const hasMore = data && data[data.length - 1]?.nextCursor; - if (hasMore) { - setSize(size + 1); - } - } - }, - { threshold: 0.1 }, - ); - - if (loadMoreRef.current) { - observer.observe(loadMoreRef.current); - } - - return () => observer.disconnect(); - }, [data, size, setSize, isValidating]); - - const allPosts = data ? data.flatMap((page) => page.posts) : []; - - if (allPosts.length === 0 && !isValidating) return ; - - return ( -
- {allPosts.map((p) => ( - - ))} - {/* Trigger element for loading more posts */} - - ); -}; - -const Post = (props: Post) => { - let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; - - let postRecord = props.documents.data as PubLeafletDocument.Record; - let postUri = new AtUri(props.documents.uri); - - let theme = usePubTheme(pubRecord); - let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref - ? blobRefToSrc( - pubRecord?.theme?.backgroundImage?.image?.ref, - new AtUri(props.publication.uri).host, - ) - : null; - - let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; - let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; - - let showPageBackground = pubRecord.theme?.showPageBackground; - - let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; - let comments = - pubRecord.preferences?.showComments === false - ? 0 - : props.documents.comments_on_documents?.[0]?.count || 0; - - return ( - - - - ); -}; - -const PubInfo = (props: { - href: string; - pubRecord: PubLeafletPublication.Record; - uri: string; -}) => { - return ( - - - {props.pubRecord.name} - - ); -}; - -const PostInfo = (props: { - author: string; - publishedAt: string | undefined; -}) => { - const formattedDate = useLocalizedDate( - props.publishedAt || new Date().toISOString(), - { - year: "numeric", - month: "short", - day: "numeric", - } - ); - - return ( -
- {props.author} - {props.publishedAt && ( - <> - - {formattedDate}{" "} - - )} -
- ); -}; - -const PostInterations = (props: { - quotesCount: number; - commentsCount: number; - postUrl: string; - showComments: boolean | undefined; -}) => { - let smoker = useSmoker(); - let interactionsAvailable = - props.quotesCount > 0 || - (props.showComments !== false && props.commentsCount > 0); - - return ( -
- {props.quotesCount === 0 ? null : ( -
- {props.quotesCount} -
- )} - {props.showComments === false || props.commentsCount === 0 ? null : ( -
- {props.commentsCount} -
- )} - {interactionsAvailable && } - -
- ); -}; -export const ReaderEmpty = () => { - return ( -
- Nothing to read yet…
- Subscribe to publications and find their posts here! - - - Discover Publications - - -
- ); -}; diff --git a/app/reader/SubscriptionsContent.tsx b/app/reader/SubscriptionsContent.tsx deleted file mode 100644 --- a/app/reader/SubscriptionsContent.tsx +++ /dev/null @@ -1,105 +0,0 @@ -"use client"; -import { PubListing } from "app/discover/PubListing"; -import { ButtonPrimary } from "components/Buttons"; -import { DiscoverSmall } from "components/Icons/DiscoverSmall"; -import { Json } from "supabase/database.types"; -import { PublicationSubscription, getSubscriptions } from "./getSubscriptions"; -import useSWRInfinite from "swr/infinite"; -import { useEffect, useRef } from "react"; -import { Cursor } from "./getReaderFeed"; -import Link from "next/link"; - -export const SubscriptionsContent = (props: { - publications: PublicationSubscription[]; - nextCursor: Cursor | null; -}) => { - const getKey = ( - pageIndex: number, - previousPageData: { - subscriptions: PublicationSubscription[]; - nextCursor: Cursor | null; - } | null, - ) => { - // Reached the end - if (previousPageData && !previousPageData.nextCursor) return null; - - // First page, we don't have previousPageData - if (pageIndex === 0) return ["subscriptions", null] as const; - - // Add the cursor to the key - return ["subscriptions", previousPageData?.nextCursor] as const; - }; - - const { data, error, size, setSize, isValidating } = useSWRInfinite( - getKey, - ([_, cursor]) => getSubscriptions(cursor), - { - fallbackData: [ - { subscriptions: props.publications, nextCursor: props.nextCursor }, - ], - revalidateFirstPage: false, - }, - ); - - const loadMoreRef = useRef(null); - - // Set up intersection observer to load more when trigger element is visible - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && !isValidating) { - const hasMore = data && data[data.length - 1]?.nextCursor; - if (hasMore) { - setSize(size + 1); - } - } - }, - { threshold: 0.1 }, - ); - - if (loadMoreRef.current) { - observer.observe(loadMoreRef.current); - } - - return () => observer.disconnect(); - }, [data, size, setSize, isValidating]); - - const allPublications = data - ? data.flatMap((page) => page.subscriptions) - : []; - - if (allPublications.length === 0 && !isValidating) - return ; - - return ( -
-
- {allPublications?.map((p, index) => )} -
- {/* Trigger element for loading more subscriptions */} - - ); -}; - -export const SubscriptionsEmpty = () => { - return ( -
- You haven't subscribed to any publications yet! - - - Discover Publications - - -
- ); -}; diff --git a/app/reader/getReaderFeed.ts b/app/reader/getReaderFeed.ts deleted file mode 100644 --- a/app/reader/getReaderFeed.ts +++ /dev/null @@ -1,106 +0,0 @@ -"use server"; - -import { getIdentityData } from "actions/getIdentityData"; -import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; -import { supabaseServerClient } from "supabase/serverClient"; -import { IdResolver } from "@atproto/identity"; -import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; -import Client from "ioredis"; -import { AtUri } from "@atproto/api"; -import { Json } from "supabase/database.types"; -import { idResolver } from "./idResolver"; - -export type Cursor = { - timestamp: string; - uri: string; -}; - -export async function getReaderFeed( - cursor?: Cursor | null, -): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { - let auth_res = await getIdentityData(); - if (!auth_res?.atp_did) return { posts: [], nextCursor: null }; - let query = supabaseServerClient - .from("documents") - .select( - `*, - comments_on_documents(count), - document_mentions_in_bsky(count), - documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, - ) - .eq( - "documents_in_publications.publications.publication_subscriptions.identity", - auth_res.atp_did, - ) - .order("indexed_at", { ascending: false }) - .order("uri", { ascending: false }) - .limit(25); - if (cursor) { - query = query.or( - `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, - ); - } - let { data: feed, error } = await query; - - let posts = await Promise.all( - feed?.map(async (post) => { - let pub = post.documents_in_publications[0].publications!; - let uri = new AtUri(post.uri); - let handle = await idResolver.did.resolve(uri.host); - let p: Post = { - publication: { - href: getPublicationURL(pub), - pubRecord: pub?.record || null, - uri: pub?.uri || "", - }, - author: handle?.alsoKnownAs?.[0] - ? `@${handle.alsoKnownAs[0].slice(5)}` - : null, - documents: { - comments_on_documents: post.comments_on_documents, - document_mentions_in_bsky: post.document_mentions_in_bsky, - data: post.data, - uri: post.uri, - indexed_at: post.indexed_at, - }, - }; - return p; - }) || [], - ); - const nextCursor = - posts.length > 0 - ? { - timestamp: posts[posts.length - 1].documents.indexed_at, - uri: posts[posts.length - 1].documents.uri, - } - : null; - - return { - posts, - nextCursor, - }; -} - -export type Post = { - author: string | null; - publication: { - href: string; - pubRecord: Json; - uri: string; - }; - documents: { - data: Json; - uri: string; - indexed_at: string; - comments_on_documents: - | { - count: number; - }[] - | undefined; - document_mentions_in_bsky: - | { - count: number; - }[] - | undefined; - }; -}; diff --git a/app/reader/getSubscriptions.ts b/app/reader/getSubscriptions.ts deleted file mode 100644 --- a/app/reader/getSubscriptions.ts +++ /dev/null @@ -1,70 +0,0 @@ -"use server"; - -import { AtpAgent } from "@atproto/api"; -import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; -import { getIdentityData } from "actions/getIdentityData"; -import { Json } from "supabase/database.types"; -import { supabaseServerClient } from "supabase/serverClient"; -import { idResolver } from "./idResolver"; -import { Cursor } from "./getReaderFeed"; - -export async function getSubscriptions(cursor?: Cursor | null): Promise<{ - nextCursor: null | Cursor; - subscriptions: PublicationSubscription[]; -}> { - let auth_res = await getIdentityData(); - if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; - let query = supabaseServerClient - .from("publication_subscriptions") - .select(`*, publications(*, documents_in_publications(*, documents(*)))`) - .order(`created_at`, { ascending: false }) - .order(`uri`, { ascending: false }) - .order("indexed_at", { - ascending: false, - referencedTable: "publications.documents_in_publications", - }) - .limit(1, { referencedTable: "publications.documents_in_publications" }) - .limit(25) - .eq("identity", auth_res.atp_did); - - if (cursor) { - query = query.or( - `created_at.lt.${cursor.timestamp},and(created_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, - ); - } - let { data: pubs, error } = await query; - - const hydratedSubscriptions: PublicationSubscription[] = await Promise.all( - pubs?.map(async (pub) => { - let id = await idResolver.did.resolve(pub.publications?.identity_did!); - return { - ...pub.publications!, - authorProfile: id?.alsoKnownAs?.[0] - ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } - : undefined, - }; - }) || [], - ); - - const nextCursor = - pubs && pubs.length > 0 - ? { - timestamp: pubs[pubs.length - 1].created_at, - uri: pubs[pubs.length - 1].uri, - } - : null; - - return { - subscriptions: hydratedSubscriptions, - nextCursor, - }; -} - -export type PublicationSubscription = { - authorProfile?: { handle: string }; - record: Json; - uri: string; - documents_in_publications: { - documents: { data?: Json; indexed_at: string } | null; - }[]; -}; diff --git a/app/reader/idResolver.ts b/app/reader/idResolver.ts deleted file mode 100644 --- a/app/reader/idResolver.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { IdResolver } from "@atproto/identity"; -import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; -import Client from "ioredis"; -// Create Redis client for DID caching -let redisClient: Client | null = null; -if (process.env.REDIS_URL) { - redisClient = new Client(process.env.REDIS_URL); -} - -// Redis-based DID cache implementation -class RedisDidCache implements DidCache { - private staleTTL: number; - private maxTTL: number; - - constructor( - private client: Client, - staleTTL = 60 * 60, // 1 hour - maxTTL = 60 * 60 * 24, // 24 hours - ) { - this.staleTTL = staleTTL; - this.maxTTL = maxTTL; - } - - async cacheDid(did: string, doc: DidDocument): Promise { - const cacheVal = { - doc, - updatedAt: Date.now(), - }; - await this.client.setex( - `did:${did}`, - this.maxTTL, - JSON.stringify(cacheVal), - ); - } - - async checkCache(did: string): Promise { - const cached = await this.client.get(`did:${did}`); - if (!cached) return null; - - const { doc, updatedAt } = JSON.parse(cached); - const now = Date.now(); - const age = now - updatedAt; - - return { - did, - doc, - updatedAt, - stale: age > this.staleTTL * 1000, - expired: age > this.maxTTL * 1000, - }; - } - - async refreshCache( - did: string, - getDoc: () => Promise, - ): Promise { - const doc = await getDoc(); - if (doc) { - await this.cacheDid(did, doc); - } - } - - async clearEntry(did: string): Promise { - await this.client.del(`did:${did}`); - } - - async clear(): Promise { - const keys = await this.client.keys("did:*"); - if (keys.length > 0) { - await this.client.del(...keys); - } - } -} - -// Create IdResolver with Redis-based DID cache -export const idResolver = new IdResolver({ - didCache: redisClient ? new RedisDidCache(redisClient) : undefined, -}); diff --git a/app/reader/page.tsx b/app/reader/page.tsx deleted file mode 100644 --- a/app/reader/page.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { cookies } from "next/headers"; -import { Fact, ReplicacheProvider } from "src/replicache"; -import type { Attribute } from "src/replicache/attributes"; -import { - ThemeBackgroundProvider, - ThemeProvider, -} from "components/ThemeManager/ThemeProvider"; -import { EntitySetProvider } from "components/EntitySetProvider"; -import { getIdentityData } from "actions/getIdentityData"; -import { supabaseServerClient } from "supabase/serverClient"; - -import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; -import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; -import { ReaderContent, ReaderEmpty } from "./ReaderContent"; -import { - SubscriptionsContent, - SubscriptionsEmpty, -} from "./SubscriptionsContent"; -import { getReaderFeed } from "./getReaderFeed"; -import { getSubscriptions } from "./getSubscriptions"; - -export default async function Reader(props: {}) { - let cookieStore = await cookies(); - let auth_res = await getIdentityData(); - let identity: string | undefined; - let permission_token = auth_res?.home_leaflet; - if (!permission_token) - return ( - , - }, - Subscriptions: { - controls: null, - content: , - }, - }} - /> - ); - let [homeLeafletFacts] = await Promise.all([ - supabaseServerClient.rpc("get_facts", { - root: permission_token.root_entity, - }), - ]); - let initialFacts = - (homeLeafletFacts.data as unknown as Fact[]) || []; - let root_entity = permission_token.root_entity; - - if (!auth_res?.atp_did) return; - let posts = await getReaderFeed(); - let publications = await getSubscriptions(); - return ( - - - - - - ), - }, - Subscriptions: { - controls: null, - content: ( - - ), - }, - }} - /> - - - - - ); -} diff --git a/components/ActionBar/Publications.tsx b/components/ActionBar/Publications.tsx --- a/components/ActionBar/Publications.tsx +++ b/components/ActionBar/Publications.tsx @@ -102,15 +102,15 @@ let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`; return props.record.icon ? ( -
+
+ {`${props.record.name} +
) : (
{ + let record = props.record as PubLeafletPublication.Record; + let theme = usePubTheme(record); + let backgroundImage = record?.theme?.backgroundImage?.image?.ref + ? blobRefToSrc( + record?.theme?.backgroundImage?.image?.ref, + new AtUri(props.uri).host, + ) + : null; + + let backgroundImageRepeat = record?.theme?.backgroundImage?.repeat; + let backgroundImageSize = record?.theme?.backgroundImage?.width || 500; + if (!record) return null; + return ( + + + {backgroundImage && ( + + )} +
+
+ +
+ +

{record.name}

+ {record.description && ( +

+ {record.description} +

+ )} +
+
+ {props.authorProfile?.handle} +
+

+ Updated{" "} + {timeAgo( + props.documents_in_publications?.[0]?.documents?.indexed_at || + "", + )} +

+
+
+
+
+ ); +}; diff --git a/app/(home-pages)/discover/SortButtons.tsx b/app/(home-pages)/discover/SortButtons.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/discover/SortButtons.tsx @@ -0,0 +1,97 @@ +"use client"; +import Link from "next/link"; +import { useState } from "react"; +import { theme } from "tailwind.config"; + +export default function SortButtons(props: { order: string }) { + const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( + "recentlyUpdated", + ); + + return ( +
+ + + Recently Updated + + + + + Popular + +
+ ); +} + +const SortButton = (props: { + children: React.ReactNode; + selected: boolean; +}) => { + return ( +
+ + {props.selected && ( + <> +
+ +
+
+ +
+
+ +
+ + )} +
+ ); +}; + +const GlitterBig = () => { + return ( + + + + ); +}; + +const GlitterSmall = () => { + return ( + + + + ); +}; diff --git a/app/(home-pages)/discover/SortedPublicationList.tsx b/app/(home-pages)/discover/SortedPublicationList.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/discover/SortedPublicationList.tsx @@ -0,0 +1,143 @@ +"use client"; +import Link from "next/link"; +import { useState } from "react"; +import { theme } from "tailwind.config"; +import { PublicationsList } from "./page"; +import { PubListing } from "./PubListing"; + +export function SortedPublicationList(props: { + publications: PublicationsList; + order: string; +}) { + let [order, setOrder] = useState(props.order); + return ( +
+ { + const url = new URL(window.location.href); + url.searchParams.set("order", o); + window.history.pushState({}, "", url); + setOrder(o); + }} + /> +
+ {props.publications + ?.filter((pub) => pub.documents_in_publications.length > 0) + ?.sort((a, b) => { + if (order === "popular") { + return ( + b.publication_subscriptions[0].count - + a.publication_subscriptions[0].count + ); + } + const aDate = new Date( + a.documents_in_publications[0]?.indexed_at || 0, + ); + const bDate = new Date( + b.documents_in_publications[0]?.indexed_at || 0, + ); + return bDate.getTime() - aDate.getTime(); + }) + .map((pub) => )} +
+
+ ); +} + +export default function SortButtons(props: { + order: string; + setOrder: (order: string) => void; +}) { + const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( + "recentlyUpdated", + ); + + return ( +
+ props.setOrder("recentlyUpdated")} + > + Recently Updated + + + props.setOrder("popular")} + > + Popular + +
+ ); +} + +const SortButton = (props: { + children: React.ReactNode; + onClick: () => void; + selected: boolean; +}) => { + return ( +
+ + {props.selected && ( + <> +
+ +
+
+ +
+
+ +
+ + )} +
+ ); +}; + +const GlitterBig = () => { + return ( + + + + ); +}; + +const GlitterSmall = () => { + return ( + + + + ); +}; diff --git a/app/(home-pages)/discover/page.tsx b/app/(home-pages)/discover/page.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/discover/page.tsx @@ -0,0 +1,67 @@ +import { supabaseServerClient } from "supabase/serverClient"; +import Link from "next/link"; +import { SortedPublicationList } from "./SortedPublicationList"; +import { Metadata } from "next"; +import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; + +export type PublicationsList = Awaited>; +async function getPublications() { + let { data: publications, error } = await supabaseServerClient + .from("publications") + .select( + "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", + ) + .or( + "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", + ) + .order("indexed_at", { + referencedTable: "documents_in_publications", + ascending: false, + }) + .limit(1, { referencedTable: "documents_in_publications" }); + return publications; +} + +export const metadata: Metadata = { + title: "Leaflet Discover", + description: "Explore publications on Leaflet ✨ Or make your own!", +}; + +export default async function Discover(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + let order = ((await props.searchParams).order as string) || "recentlyUpdated"; + + return ( + , + }, + }} + /> + ); +} + +const DiscoverContent = async (props: { order: string }) => { + let publications = await getPublications(); + + return ( +
+
+

Discover

+

+ Explore publications on Leaflet ✨ Or{" "} + make your own! +

+
+ +
+ ); +}; diff --git a/app/(home-pages)/home/HomeLayout.tsx b/app/(home-pages)/home/HomeLayout.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/HomeLayout.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { getHomeDocs, HomeDoc } from "./storage"; +import useSWR from "swr"; +import { + Fact, + PermissionToken, + ReplicacheProvider, + useEntity, +} from "src/replicache"; +import { LeafletListItem } from "./LeafletList/LeafletListItem"; +import { useIdentityData } from "components/IdentityProvider"; +import type { Attribute } from "src/replicache/attributes"; +import { callRPC } from "app/api/rpc/client"; +import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; +import { HomeSmall } from "components/Icons/HomeSmall"; +import { + HomeDashboardControls, + DashboardLayout, + DashboardState, + useDashboardState, +} from "components/PageLayouts/DashboardLayout"; +import { Actions } from "./Actions/Actions"; +import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; +import { Json } from "supabase/database.types"; +import { useTemplateState } from "./Actions/CreateNewButton"; +import { CreateNewLeafletButton } from "./Actions/CreateNewButton"; +import { ActionButton } from "components/ActionBar/ActionButton"; +import { AddTiny } from "components/Icons/AddTiny"; +import { + get_leaflet_data, + GetLeafletDataReturnType, +} from "app/api/rpc/[command]/get_leaflet_data"; +import { useEffect, useRef, useState } from "react"; +import { Input } from "components/Input"; +import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; +import { + ButtonPrimary, + ButtonSecondary, + ButtonTertiary, +} from "components/Buttons"; +import { AddSmall } from "components/Icons/AddSmall"; +import { PublishIllustration } from "app/[leaflet_id]/publish/PublishIllustration/PublishIllustration"; +import { PubListEmptyIllo } from "components/ActionBar/Publications"; +import { theme } from "tailwind.config"; +import Link from "next/link"; +import { DiscoverIllo } from "./HomeEmpty/DiscoverIllo"; +import { WelcomeToLeafletIllo } from "./HomeEmpty/WelcomeToLeafletIllo"; +import { + DiscoverBanner, + HomeEmptyState, + PublicationBanner, +} from "./HomeEmpty/HomeEmpty"; + +type Leaflet = { + added_at: string; + token: PermissionToken & { + leaflets_in_publications?: Exclude< + GetLeafletDataReturnType["result"]["data"], + null + >["leaflets_in_publications"]; + }; +}; + +export const HomeLayout = (props: { + entityID: string | null; + titles: { [root_entity: string]: string }; + initialFacts: { + [root_entity: string]: Fact[]; + }; +}) => { + let hasBackgroundImage = !!useEntity( + props.entityID, + "theme/background-image", + ); + let cardBorderHidden = !!useCardBorderHidden(props.entityID); + + let [searchValue, setSearchValue] = useState(""); + let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); + + useDebouncedEffect( + () => { + setDebouncedSearchValue(searchValue); + }, + 200, + [searchValue], + ); + + let { identity } = useIdentityData(); + + let hasPubs = !identity || identity.publications.length === 0 ? false : true; + let hasTemplates = + useTemplateState((s) => s.templates).length === 0 ? false : true; + + return ( + } + tabs={{ + home: { + controls: ( + + ), + content: ( + + ), + }, + }} + /> + ); +}; + +export function HomeLeafletList(props: { + titles: { [root_entity: string]: string }; + initialFacts: { + [root_entity: string]: Fact[]; + }; + searchValue: string; + cardBorderHidden: boolean; +}) { + let { identity } = useIdentityData(); + let { data: initialFacts } = useSWR( + "home-leaflet-data", + async () => { + if (identity) { + let { result } = await callRPC("getFactsFromHomeLeaflets", { + tokens: identity.permission_token_on_homepage.map( + (ptrh) => ptrh.permission_tokens.root_entity, + ), + }); + let titles = { + ...result.titles, + ...identity.permission_token_on_homepage.reduce( + (acc, tok) => { + let title = + tok.permission_tokens.leaflets_in_publications[0]?.title; + if (title) acc[tok.permission_tokens.root_entity] = title; + return acc; + }, + {} as { [k: string]: string }, + ), + }; + return { ...result, titles }; + } + }, + { fallbackData: { facts: props.initialFacts, titles: props.titles } }, + ); + + let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { + fallbackData: [], + }); + let leaflets: Leaflet[] = identity + ? identity.permission_token_on_homepage.map((ptoh) => ({ + added_at: ptoh.created_at, + token: ptoh.permission_tokens as PermissionToken, + })) + : localLeaflets + .sort((a, b) => (a.added_at > b.added_at ? -1 : 1)) + .filter((d) => !d.hidden) + .map((ll) => ll); + + return leaflets.length === 0 ? ( + + ) : ( + <> + +
+ + {leaflets.filter((l) => !!l.token.leaflets_in_publications).length === + 0 && } + + + ); +} + +export function LeafletList(props: { + leaflets: Leaflet[]; + titles: { [root_entity: string]: string }; + defaultDisplay: Exclude; + initialFacts: { + [root_entity: string]: Fact[]; + }; + searchValue: string; + cardBorderHidden: boolean; + showPreview?: boolean; +}) { + let { identity } = useIdentityData(); + let { display } = useDashboardState(); + + display = display || props.defaultDisplay; + + let searchedLeaflets = useSearchedLeaflets( + props.leaflets, + props.titles, + props.searchValue, + ); + + return ( +
+ {props.leaflets.map(({ token: leaflet, added_at }, index) => ( + + + l.doc)} + publishedAt={ + leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents + ?.indexed_at + } + leaflet_id={leaflet.root_entity} + loggedIn={!!identity} + display={display} + added_at={added_at} + cardBorderHidden={props.cardBorderHidden} + index={index} + showPreview={props.showPreview} + isHidden={ + !searchedLeaflets.some( + (sl) => sl.token.root_entity === leaflet.root_entity, + ) + } + /> + + + ))} +
+ ); +} + +function useSearchedLeaflets( + leaflets: Leaflet[], + titles: { [root_entity: string]: string }, + searchValue: string, +) { + let { sort, filter } = useDashboardState(); + + let sortedLeaflets = leaflets.sort((a, b) => { + if (sort === "alphabetical") { + if (titles[a.token.root_entity] === titles[b.token.root_entity]) { + return a.added_at > b.added_at ? -1 : 1; + } else { + return titles[a.token.root_entity].toLocaleLowerCase() > + titles[b.token.root_entity].toLocaleLowerCase() + ? 1 + : -1; + } + } else { + return a.added_at === b.added_at + ? a.token.root_entity > b.token.root_entity + ? -1 + : 1 + : a.added_at > b.added_at + ? -1 + : 1; + } + }); + + let allTemplates = useTemplateState((s) => s.templates); + let filteredLeaflets = sortedLeaflets.filter(({ token: leaflet }) => { + let published = !!leaflet.leaflets_in_publications?.find((l) => l.doc); + let drafts = !!leaflet.leaflets_in_publications?.length && !published; + let docs = !leaflet.leaflets_in_publications?.length; + let templates = !!allTemplates.find((t) => t.id === leaflet.id); + // If no filters are active, show all + if ( + !filter.drafts && + !filter.published && + !filter.docs && + !filter.templates + ) + return true; + + return ( + (filter.drafts && drafts) || + (filter.published && published) || + (filter.docs && docs) || + (filter.templates && templates) + ); + }); + if (searchValue === "") return filteredLeaflets; + let searchedLeaflets = filteredLeaflets.filter(({ token: leaflet }) => { + return titles[leaflet.root_entity] + ?.toLowerCase() + .includes(searchValue.toLowerCase()); + }); + + return searchedLeaflets; +} diff --git a/app/(home-pages)/home/IdentitySetter.tsx b/app/(home-pages)/home/IdentitySetter.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/IdentitySetter.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useEffect } from "react"; + +export function IdentitySetter(props: { + cb: () => Promise; + call: boolean; +}) { + useEffect(() => { + if (props.call) props.cb(); + }, [props]); + return null; +} diff --git a/app/(home-pages)/home/LoggedOutWarning.tsx b/app/(home-pages)/home/LoggedOutWarning.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/LoggedOutWarning.tsx @@ -0,0 +1,25 @@ +"use client"; +import { useIdentityData } from "components/IdentityProvider"; +import { LoginButton } from "components/LoginButton"; + +export const LoggedOutWarning = (props: {}) => { + let { identity } = useIdentityData(); + if (identity) return null; + return ( +
+
+

+ Log in to collect all your Leaflets and access them on multiple + devices +

+ +
+
+ ); +}; diff --git a/app/(home-pages)/home/icon.tsx b/app/(home-pages)/home/icon.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/icon.tsx @@ -0,0 +1,105 @@ +import { ImageResponse } from "next/og"; +import type { Fact } from "src/replicache"; +import type { Attribute } from "src/replicache/attributes"; +import { Database } from "supabase/database.types"; +import { createServerClient } from "@supabase/ssr"; +import { parseHSBToRGB } from "src/utils/parseHSB"; +import { cookies } from "next/headers"; + +// Route segment config +export const revalidate = 0; +export const preferredRegion = ["sfo1"]; +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; + +// Image metadata +export const size = { + width: 32, + height: 32, +}; +export const contentType = "image/png"; + +// Image generation +let supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, + { cookies: {} }, +); +export default async function Icon() { + let cookieStore = await cookies(); + let identity = cookieStore.get("identity"); + let rootEntity: string | null = null; + if (identity) { + let res = await supabase + .from("identities") + .select( + `*, + permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), + permission_token_on_homepage( + *, permission_tokens(*, permission_token_rights(*)) + ) + `, + ) + .eq("id", identity?.value) + .single(); + rootEntity = res.data?.permission_tokens?.root_entity || null; + } + let outlineColor, fillColor; + if (rootEntity) { + let { data } = await supabase.rpc("get_facts", { + root: rootEntity, + }); + let initialFacts = (data as unknown as Fact[]) || []; + let themePageBG = initialFacts.find( + (f) => f.attribute === "theme/card-background", + ) as Fact<"theme/card-background"> | undefined; + + let themePrimary = initialFacts.find( + (f) => f.attribute === "theme/primary", + ) as Fact<"theme/primary"> | undefined; + + outlineColor = parseHSBToRGB(`hsba(${themePageBG?.data.value})`); + + fillColor = parseHSBToRGB(`hsba(${themePrimary?.data.value})`); + } + + return new ImageResponse( + ( + // ImageResponse JSX element +
+ + {/* outline */} + + + {/* fill */} + + +
+ ), + // ImageResponse options + { + // For convenience, we can re-use the exported icons size metadata + // config to also set the ImageResponse's width and height. + ...size, + headers: { + "Cache-Control": "no-cache", + }, + }, + ); +} diff --git a/app/(home-pages)/home/page.tsx b/app/(home-pages)/home/page.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/page.tsx @@ -0,0 +1,43 @@ +import { getIdentityData } from "actions/getIdentityData"; +import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; +import { supabaseServerClient } from "supabase/serverClient"; + +import { HomeLayout } from "./HomeLayout"; + +export default async function Home() { + let auth_res = await getIdentityData(); + + let [allLeafletFacts] = await Promise.all([ + auth_res + ? getFactsFromHomeLeaflets.handler( + { + tokens: auth_res.permission_token_on_homepage.map( + (r) => r.permission_tokens.root_entity, + ), + }, + { supabase: supabaseServerClient }, + ) + : undefined, + ]); + + let home_docs_initialFacts = allLeafletFacts?.result || {}; + + return ( + { + let title = + tok.permission_tokens.leaflets_in_publications[0]?.title; + if (title) acc[tok.permission_tokens.root_entity] = title; + return acc; + }, + {} as { [k: string]: string }, + ), + }} + entityID={auth_res?.home_leaflet?.root_entity || null} + initialFacts={home_docs_initialFacts.facts || {}} + /> + ); +} diff --git a/app/(home-pages)/home/storage.ts b/app/(home-pages)/home/storage.ts new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/storage.ts @@ -0,0 +1,68 @@ +import type { PermissionToken } from "src/replicache"; +import { mutate } from "swr"; + +export type HomeDoc = { + token: PermissionToken; + added_at: string; + hidden?: boolean; +}; +type HomeDocsStorage = { + version: number; + docs: Array; +}; +let defaultValue: HomeDocsStorage = { + version: 1, + docs: [], +}; +const key = "homepageDocs-v1"; +let tokenCache = new Map(); +export function getHomeDocs() { + let homepageDocs: HomeDocsStorage = JSON.parse( + window.localStorage.getItem(key) || JSON.stringify(defaultValue), + ); + return homepageDocs.docs.map((d) => { + let cachedToken = tokenCache.get(d.token.id); + if (!cachedToken) { + cachedToken = d.token; + tokenCache.set(d.token.id, d.token); + } + return { ...d, token: cachedToken }; + }); +} + +export function addDocToHome(doc: PermissionToken) { + let homepageDocs = getHomeDocs(); + if (homepageDocs.find((d) => d.token.id === doc.id)) return; + homepageDocs.push({ token: doc, added_at: new Date().toISOString() }); + let newValue: HomeDocsStorage = { + version: 1, + docs: homepageDocs, + }; + window.localStorage.setItem(key, JSON.stringify(newValue)); +} + +export function removeDocFromHome(doc: PermissionToken) { + let homepageDocs = getHomeDocs(); + let newDocs = homepageDocs.filter((d) => d.token.id !== doc.id); + let newValue: HomeDocsStorage = { + version: 1, + docs: newDocs, + }; + window.localStorage.setItem(key, JSON.stringify(newValue)); +} + +export function hideDoc(doc: PermissionToken) { + let homepageDocs = getHomeDocs(); + let newDocs = homepageDocs.filter((d) => d.token.id !== doc.id); + newDocs.push({ + token: doc, + added_at: new Date().toISOString(), + hidden: true, + }); + let newValue: HomeDocsStorage = { + version: 1, + docs: newDocs, + }; + window.localStorage.setItem(key, JSON.stringify(newValue)); + mutate("leaflets"); +} diff --git a/app/(home-pages)/notifications/page.tsx b/app/(home-pages)/notifications/page.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/notifications/page.tsx @@ -0,0 +1,3 @@ +export default async function Notifications() { + return
Notifications
; +} diff --git a/app/(home-pages)/reader/ReaderContent.tsx b/app/(home-pages)/reader/ReaderContent.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/reader/ReaderContent.tsx @@ -0,0 +1,285 @@ +"use client"; +import { AtUri } from "@atproto/api"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { PubIcon } from "components/ActionBar/Publications"; +import { ButtonPrimary } from "components/Buttons"; +import { CommentTiny } from "components/Icons/CommentTiny"; +import { DiscoverSmall } from "components/Icons/DiscoverSmall"; +import { QuoteTiny } from "components/Icons/QuoteTiny"; +import { Separator } from "components/Layout"; +import { SpeedyLink } from "components/SpeedyLink"; +import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; +import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; +import { useSmoker } from "components/Toast"; +import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { Json } from "supabase/database.types"; +import type { Cursor, Post } from "./getReaderFeed"; +import useSWRInfinite from "swr/infinite"; +import { getReaderFeed } from "./getReaderFeed"; +import { useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { useLocalizedDate } from "src/hooks/useLocalizedDate"; + +export const ReaderContent = (props: { + posts: Post[]; + nextCursor: Cursor | null; +}) => { + const getKey = ( + pageIndex: number, + previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, + ) => { + // Reached the end + if (previousPageData && !previousPageData.nextCursor) return null; + + // First page, we don't have previousPageData + if (pageIndex === 0) return ["reader-feed", null] as const; + + // Add the cursor to the key + return ["reader-feed", previousPageData?.nextCursor] as const; + }; + + const { data, error, size, setSize, isValidating } = useSWRInfinite( + getKey, + ([_, cursor]) => getReaderFeed(cursor), + { + fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], + revalidateFirstPage: false, + }, + ); + + const loadMoreRef = useRef(null); + + // Set up intersection observer to load more when trigger element is visible + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isValidating) { + const hasMore = data && data[data.length - 1]?.nextCursor; + if (hasMore) { + setSize(size + 1); + } + } + }, + { threshold: 0.1 }, + ); + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current); + } + + return () => observer.disconnect(); + }, [data, size, setSize, isValidating]); + + const allPosts = data ? data.flatMap((page) => page.posts) : []; + + if (allPosts.length === 0 && !isValidating) return ; + + return ( +
+ {allPosts.map((p) => ( + + ))} + {/* Trigger element for loading more posts */} + + ); +}; + +const Post = (props: Post) => { + let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; + + let postRecord = props.documents.data as PubLeafletDocument.Record; + let postUri = new AtUri(props.documents.uri); + + let theme = usePubTheme(pubRecord); + let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref + ? blobRefToSrc( + pubRecord?.theme?.backgroundImage?.image?.ref, + new AtUri(props.publication.uri).host, + ) + : null; + + let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; + let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; + + let showPageBackground = pubRecord.theme?.showPageBackground; + + let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; + let comments = + pubRecord.preferences?.showComments === false + ? 0 + : props.documents.comments_on_documents?.[0]?.count || 0; + + return ( + + + + ); +}; + +const PubInfo = (props: { + href: string; + pubRecord: PubLeafletPublication.Record; + uri: string; +}) => { + return ( + + + {props.pubRecord.name} + + ); +}; + +const PostInfo = (props: { + author: string; + publishedAt: string | undefined; +}) => { + const formattedDate = useLocalizedDate( + props.publishedAt || new Date().toISOString(), + { + year: "numeric", + month: "short", + day: "numeric", + }, + ); + + return ( +
+ {props.author} + {props.publishedAt && ( + <> + + {formattedDate}{" "} + + )} +
+ ); +}; + +const PostInterations = (props: { + quotesCount: number; + commentsCount: number; + postUrl: string; + showComments: boolean | undefined; +}) => { + let smoker = useSmoker(); + let interactionsAvailable = + props.quotesCount > 0 || + (props.showComments !== false && props.commentsCount > 0); + + return ( +
+ {props.quotesCount === 0 ? null : ( +
+ {props.quotesCount} +
+ )} + {props.showComments === false || props.commentsCount === 0 ? null : ( +
+ {props.commentsCount} +
+ )} + {interactionsAvailable && } + +
+ ); +}; +export const ReaderEmpty = () => { + return ( +
+ Nothing to read yet…
+ Subscribe to publications and find their posts here! + + + Discover Publications + + +
+ ); +}; diff --git a/app/(home-pages)/reader/SubscriptionsContent.tsx b/app/(home-pages)/reader/SubscriptionsContent.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/reader/SubscriptionsContent.tsx @@ -0,0 +1,105 @@ +"use client"; +import { PubListing } from "app/(home-pages)/discover/PubListing"; +import { ButtonPrimary } from "components/Buttons"; +import { DiscoverSmall } from "components/Icons/DiscoverSmall"; +import { Json } from "supabase/database.types"; +import { PublicationSubscription, getSubscriptions } from "./getSubscriptions"; +import useSWRInfinite from "swr/infinite"; +import { useEffect, useRef } from "react"; +import { Cursor } from "./getReaderFeed"; +import Link from "next/link"; + +export const SubscriptionsContent = (props: { + publications: PublicationSubscription[]; + nextCursor: Cursor | null; +}) => { + const getKey = ( + pageIndex: number, + previousPageData: { + subscriptions: PublicationSubscription[]; + nextCursor: Cursor | null; + } | null, + ) => { + // Reached the end + if (previousPageData && !previousPageData.nextCursor) return null; + + // First page, we don't have previousPageData + if (pageIndex === 0) return ["subscriptions", null] as const; + + // Add the cursor to the key + return ["subscriptions", previousPageData?.nextCursor] as const; + }; + + const { data, error, size, setSize, isValidating } = useSWRInfinite( + getKey, + ([_, cursor]) => getSubscriptions(cursor), + { + fallbackData: [ + { subscriptions: props.publications, nextCursor: props.nextCursor }, + ], + revalidateFirstPage: false, + }, + ); + + const loadMoreRef = useRef(null); + + // Set up intersection observer to load more when trigger element is visible + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isValidating) { + const hasMore = data && data[data.length - 1]?.nextCursor; + if (hasMore) { + setSize(size + 1); + } + } + }, + { threshold: 0.1 }, + ); + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current); + } + + return () => observer.disconnect(); + }, [data, size, setSize, isValidating]); + + const allPublications = data + ? data.flatMap((page) => page.subscriptions) + : []; + + if (allPublications.length === 0 && !isValidating) + return ; + + return ( +
+
+ {allPublications?.map((p, index) => )} +
+ {/* Trigger element for loading more subscriptions */} + + ); +}; + +export const SubscriptionsEmpty = () => { + return ( +
+ You haven't subscribed to any publications yet! + + + Discover Publications + + +
+ ); +}; diff --git a/app/(home-pages)/reader/getReaderFeed.ts b/app/(home-pages)/reader/getReaderFeed.ts new file mode 100644 --- /dev/null +++ b/app/(home-pages)/reader/getReaderFeed.ts @@ -0,0 +1,106 @@ +"use server"; + +import { getIdentityData } from "actions/getIdentityData"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { supabaseServerClient } from "supabase/serverClient"; +import { IdResolver } from "@atproto/identity"; +import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; +import Client from "ioredis"; +import { AtUri } from "@atproto/api"; +import { Json } from "supabase/database.types"; +import { idResolver } from "./idResolver"; + +export type Cursor = { + timestamp: string; + uri: string; +}; + +export async function getReaderFeed( + cursor?: Cursor | null, +): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { + let auth_res = await getIdentityData(); + if (!auth_res?.atp_did) return { posts: [], nextCursor: null }; + let query = supabaseServerClient + .from("documents") + .select( + `*, + comments_on_documents(count), + document_mentions_in_bsky(count), + documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, + ) + .eq( + "documents_in_publications.publications.publication_subscriptions.identity", + auth_res.atp_did, + ) + .order("indexed_at", { ascending: false }) + .order("uri", { ascending: false }) + .limit(25); + if (cursor) { + query = query.or( + `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, + ); + } + let { data: feed, error } = await query; + + let posts = await Promise.all( + feed?.map(async (post) => { + let pub = post.documents_in_publications[0].publications!; + let uri = new AtUri(post.uri); + let handle = await idResolver.did.resolve(uri.host); + let p: Post = { + publication: { + href: getPublicationURL(pub), + pubRecord: pub?.record || null, + uri: pub?.uri || "", + }, + author: handle?.alsoKnownAs?.[0] + ? `@${handle.alsoKnownAs[0].slice(5)}` + : null, + documents: { + comments_on_documents: post.comments_on_documents, + document_mentions_in_bsky: post.document_mentions_in_bsky, + data: post.data, + uri: post.uri, + indexed_at: post.indexed_at, + }, + }; + return p; + }) || [], + ); + const nextCursor = + posts.length > 0 + ? { + timestamp: posts[posts.length - 1].documents.indexed_at, + uri: posts[posts.length - 1].documents.uri, + } + : null; + + return { + posts, + nextCursor, + }; +} + +export type Post = { + author: string | null; + publication: { + href: string; + pubRecord: Json; + uri: string; + }; + documents: { + data: Json; + uri: string; + indexed_at: string; + comments_on_documents: + | { + count: number; + }[] + | undefined; + document_mentions_in_bsky: + | { + count: number; + }[] + | undefined; + }; +}; diff --git a/app/(home-pages)/reader/getSubscriptions.ts b/app/(home-pages)/reader/getSubscriptions.ts new file mode 100644 --- /dev/null +++ b/app/(home-pages)/reader/getSubscriptions.ts @@ -0,0 +1,70 @@ +"use server"; + +import { AtpAgent } from "@atproto/api"; +import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import { getIdentityData } from "actions/getIdentityData"; +import { Json } from "supabase/database.types"; +import { supabaseServerClient } from "supabase/serverClient"; +import { idResolver } from "./idResolver"; +import { Cursor } from "./getReaderFeed"; + +export async function getSubscriptions(cursor?: Cursor | null): Promise<{ + nextCursor: null | Cursor; + subscriptions: PublicationSubscription[]; +}> { + let auth_res = await getIdentityData(); + if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; + let query = supabaseServerClient + .from("publication_subscriptions") + .select(`*, publications(*, documents_in_publications(*, documents(*)))`) + .order(`created_at`, { ascending: false }) + .order(`uri`, { ascending: false }) + .order("indexed_at", { + ascending: false, + referencedTable: "publications.documents_in_publications", + }) + .limit(1, { referencedTable: "publications.documents_in_publications" }) + .limit(25) + .eq("identity", auth_res.atp_did); + + if (cursor) { + query = query.or( + `created_at.lt.${cursor.timestamp},and(created_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, + ); + } + let { data: pubs, error } = await query; + + const hydratedSubscriptions: PublicationSubscription[] = await Promise.all( + pubs?.map(async (pub) => { + let id = await idResolver.did.resolve(pub.publications?.identity_did!); + return { + ...pub.publications!, + authorProfile: id?.alsoKnownAs?.[0] + ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } + : undefined, + }; + }) || [], + ); + + const nextCursor = + pubs && pubs.length > 0 + ? { + timestamp: pubs[pubs.length - 1].created_at, + uri: pubs[pubs.length - 1].uri, + } + : null; + + return { + subscriptions: hydratedSubscriptions, + nextCursor, + }; +} + +export type PublicationSubscription = { + authorProfile?: { handle: string }; + record: Json; + uri: string; + documents_in_publications: { + documents: { data?: Json; indexed_at: string } | null; + }[]; +}; diff --git a/app/(home-pages)/reader/idResolver.ts b/app/(home-pages)/reader/idResolver.ts new file mode 100644 --- /dev/null +++ b/app/(home-pages)/reader/idResolver.ts @@ -0,0 +1,78 @@ +import { IdResolver } from "@atproto/identity"; +import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; +import Client from "ioredis"; +// Create Redis client for DID caching +let redisClient: Client | null = null; +if (process.env.REDIS_URL) { + redisClient = new Client(process.env.REDIS_URL); +} + +// Redis-based DID cache implementation +class RedisDidCache implements DidCache { + private staleTTL: number; + private maxTTL: number; + + constructor( + private client: Client, + staleTTL = 60 * 60, // 1 hour + maxTTL = 60 * 60 * 24, // 24 hours + ) { + this.staleTTL = staleTTL; + this.maxTTL = maxTTL; + } + + async cacheDid(did: string, doc: DidDocument): Promise { + const cacheVal = { + doc, + updatedAt: Date.now(), + }; + await this.client.setex( + `did:${did}`, + this.maxTTL, + JSON.stringify(cacheVal), + ); + } + + async checkCache(did: string): Promise { + const cached = await this.client.get(`did:${did}`); + if (!cached) return null; + + const { doc, updatedAt } = JSON.parse(cached); + const now = Date.now(); + const age = now - updatedAt; + + return { + did, + doc, + updatedAt, + stale: age > this.staleTTL * 1000, + expired: age > this.maxTTL * 1000, + }; + } + + async refreshCache( + did: string, + getDoc: () => Promise, + ): Promise { + const doc = await getDoc(); + if (doc) { + await this.cacheDid(did, doc); + } + } + + async clearEntry(did: string): Promise { + await this.client.del(`did:${did}`); + } + + async clear(): Promise { + const keys = await this.client.keys("did:*"); + if (keys.length > 0) { + await this.client.del(...keys); + } + } +} + +// Create IdResolver with Redis-based DID cache +export const idResolver = new IdResolver({ + didCache: redisClient ? new RedisDidCache(redisClient) : undefined, +}); diff --git a/app/(home-pages)/reader/page.tsx b/app/(home-pages)/reader/page.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/reader/page.tsx @@ -0,0 +1,38 @@ +import { getIdentityData } from "actions/getIdentityData"; + +import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; +import { ReaderContent } from "./ReaderContent"; +import { SubscriptionsContent } from "./SubscriptionsContent"; +import { getReaderFeed } from "./getReaderFeed"; +import { getSubscriptions } from "./getSubscriptions"; + +export default async function Reader(props: {}) { + let posts = await getReaderFeed(); + let publications = await getSubscriptions(); + return ( + + ), + }, + Subscriptions: { + controls: null, + content: ( + + ), + }, + }} + /> + ); +} diff --git a/app/home/Actions/AccountSettings.tsx b/app/home/Actions/AccountSettings.tsx deleted file mode 100644 --- a/app/home/Actions/AccountSettings.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import { ActionButton } from "components/ActionBar/ActionButton"; -import { Menu, MenuItem } from "components/Layout"; -import { mutate } from "swr"; -import { AccountSmall } from "components/Icons/AccountSmall"; -import { LogoutSmall } from "components/Icons/LogoutSmall"; - -// it was going have a popover with a log out button -export const AccountSettings = () => { - return ( - label="Settings" />} - > - { - await fetch("/api/auth/logout"); - mutate("identity", null); - }} - > - - Logout - - - ); -}; diff --git a/app/home/Actions/Actions.tsx b/app/home/Actions/Actions.tsx deleted file mode 100644 --- a/app/home/Actions/Actions.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; -import { ThemePopover } from "components/ThemeManager/ThemeSetter"; -import { CreateNewLeafletButton } from "./CreateNewButton"; -import { HelpPopover } from "components/HelpPopover"; -import { AccountSettings } from "./AccountSettings"; -import { useIdentityData } from "components/IdentityProvider"; -import { useReplicache } from "src/replicache"; -import { LoginActionButton } from "components/LoginButton"; - -export const Actions = () => { - let { identity } = useIdentityData(); - let { rootEntity } = useReplicache(); - return ( - <> - - {identity ? : } - {/**/} - - - - ); -}; diff --git a/app/home/Actions/CreateNewButton.tsx b/app/home/Actions/CreateNewButton.tsx deleted file mode 100644 --- a/app/home/Actions/CreateNewButton.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; - -import { createNewLeaflet } from "actions/createNewLeaflet"; -import { createNewLeafletFromTemplate } from "actions/createNewLeafletFromTemplate"; -import { ActionButton } from "components/ActionBar/ActionButton"; -import { AddTiny } from "components/Icons/AddTiny"; -import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; -import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; -import { TemplateSmall } from "components/Icons/TemplateSmall"; -import { Menu, MenuItem } from "components/Layout"; -import { useIsMobile } from "src/hooks/isMobile"; -import { create } from "zustand"; -import { combine, createJSONStorage, persist } from "zustand/middleware"; - -export const useTemplateState = create( - persist( - combine( - { - templates: [] as { id: string; name: string }[], - }, - (set) => ({ - removeTemplate: (template: { id: string }) => - set((state) => { - return { - templates: state.templates.filter((t) => t.id !== template.id), - }; - }), - addTemplate: (template: { id: string; name: string }) => - set((state) => { - if (state.templates.find((t) => t.id === template.id)) return state; - return { templates: [...state.templates, template] }; - }), - }), - ), - { - name: "home-templates", - storage: createJSONStorage(() => localStorage), - }, - ), -); -export const CreateNewLeafletButton = (props: {}) => { - let isMobile = useIsMobile(); - let templates = useTemplateState((s) => s.templates); - let openNewLeaflet = (id: string) => { - if (isMobile) { - window.location.href = `/${id}?focusFirstBlock`; - } else { - window.open(`/${id}?focusFirstBlock`, "_blank"); - } - }; - return ( - - label="New" - /> - } - > - { - let id = await createNewLeaflet({ - pageType: "doc", - redirectUser: false, - }); - openNewLeaflet(id); - }} - > - {" "} -
-
New Doc
-
- A good ol' text document -
-
-
- { - let id = await createNewLeaflet({ - pageType: "canvas", - redirectUser: false, - }); - openNewLeaflet(id); - }} - > - -
- New Canvas -
- A digital whiteboard -
-
-
- {templates.length > 0 && ( -
- )} - {templates.map((t) => { - return ( - { - let id = await createNewLeafletFromTemplate(t.id, false); - if (!id.error) openNewLeaflet(id.id); - }} - > - - New {t.name} - - ); - })} -
- ); -}; diff --git a/app/home/Actions/HomeHelp.tsx b/app/home/Actions/HomeHelp.tsx deleted file mode 100644 --- a/app/home/Actions/HomeHelp.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; -import { ActionButton } from "components/ActionBar/ActionButton"; -import { HelpSmall } from "components/Icons/HelpSmall"; -import { Popover } from "components/Popover"; - -export const HomeHelp = () => { - return ( - } label="Info" />} - > -
-

- Leaflets are saved to home per-device / browser using - cookies. -

-

- If you clear your cookies, they'll disappear. -

-

- Please contact us for help - recovering Leaflets! -

-
-
- ); -}; diff --git a/app/home/HomeEmpty/DiscoverIllo.tsx b/app/home/HomeEmpty/DiscoverIllo.tsx deleted file mode 100644 --- a/app/home/HomeEmpty/DiscoverIllo.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { theme } from "tailwind.config"; -export const DiscoverIllo = () => { - return ( - - - - - - ); -}; diff --git a/app/home/HomeEmpty/HomeEmpty.tsx b/app/home/HomeEmpty/HomeEmpty.tsx deleted file mode 100644 --- a/app/home/HomeEmpty/HomeEmpty.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { PubListEmptyIllo } from "components/ActionBar/Publications"; -import { ButtonPrimary } from "components/Buttons"; -import { AddSmall } from "components/Icons/AddSmall"; -import { Link } from "react-aria-components"; -import { DiscoverIllo } from "./DiscoverIllo"; -import { WelcomeToLeafletIllo } from "./WelcomeToLeafletIllo"; -import { DiscoverSmall } from "components/Icons/DiscoverSmall"; -import { PublishSmall } from "components/Icons/PublishSmall"; -import { createNewLeaflet } from "actions/createNewLeaflet"; -import { useIsMobile } from "src/hooks/isMobile"; - -export function HomeEmptyState() { - let isMobile = useIsMobile(); - return ( -
-
-
- -
-
-

Leaflet

- {/*

A platform for social publishing.

*/} -
- Write and share delightful documents! -
- { - let openNewLeaflet = (id: string) => { - if (isMobile) { - window.location.href = `/${id}?focusFirstBlock`; - } else { - window.open(`/${id}?focusFirstBlock`, "_blank"); - } - }; - - let id = await createNewLeaflet({ - pageType: "doc", - redirectUser: false, - }); - openNewLeaflet(id); - }} - > - Write a Doc! - -
-
-
-
-
or
-
-
- - - -
- Right now docs and publications are separate. Soon you'll be able to add - docs to pubs! -
-
- ); -} - -export const PublicationBanner = (props: { small?: boolean }) => { - return ( -
- {props.small ? ( - - ) : ( -
- -
- )} -
- - Start a Publication - {" "} - and blog in the Atmosphere -
-
- ); -}; - -export const DiscoverBanner = (props: { small?: boolean }) => { - return ( -
- {props.small ? ( - - ) : ( -
- -
- )} -
- - Explore Publications - {" "} - on art, tech, games, music & more! -
-
- ); -}; diff --git a/app/home/HomeEmpty/WelcomeToLeafletIllo.tsx b/app/home/HomeEmpty/WelcomeToLeafletIllo.tsx deleted file mode 100644 --- a/app/home/HomeEmpty/WelcomeToLeafletIllo.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { theme } from "tailwind.config"; - -export const WelcomeToLeafletIllo = () => { - return ( - - - - - ); -}; diff --git a/app/home/LeafletList/LeafletContent.tsx b/app/home/LeafletList/LeafletContent.tsx deleted file mode 100644 --- a/app/home/LeafletList/LeafletContent.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client"; -import { BlockPreview } from "components/Blocks/PageLinkBlock"; -import { useEffect, useRef, useState } from "react"; -import { useBlocks } from "src/hooks/queries/useBlocks"; -import { useEntity } from "src/replicache"; -import { CanvasContent } from "components/Canvas"; -import styles from "./LeafletPreview.module.css"; -import { PublicationMetadataPreview } from "components/Pages/PublicationMetadata"; - -export const LeafletContent = (props: { - entityID: string; - isOnScreen: boolean; -}) => { - let type = useEntity(props.entityID, "page/type")?.data.value || "doc"; - let blocks = useBlocks(props.entityID); - let previewRef = useRef(null); - - if (type === "canvas") - return ( -
-
- {props.isOnScreen && ( - - )} -
-
- ); - - return ( -
-
- - - {props.isOnScreen && - blocks.slice(0, 10).map((b, index, arr) => { - return ( - - ); - })} -
-
- ); -}; diff --git a/app/home/LeafletList/LeafletInfo.tsx b/app/home/LeafletList/LeafletInfo.tsx deleted file mode 100644 --- a/app/home/LeafletList/LeafletInfo.tsx +++ /dev/null @@ -1,88 +0,0 @@ -"use client"; -import { PermissionToken } from "src/replicache"; -import { LeafletOptions } from "./LeafletOptions"; -import Link from "next/link"; -import { useState } from "react"; -import { theme } from "tailwind.config"; -import { TemplateSmall } from "components/Icons/TemplateSmall"; -import { timeAgo } from "src/utils/timeAgo"; - -export const LeafletInfo = (props: { - title?: string; - draft?: boolean; - published?: boolean; - token: PermissionToken; - leaflet_id: string; - loggedIn: boolean; - isTemplate: boolean; - className?: string; - display: "grid" | "list"; - added_at: string; - publishedAt?: string; -}) => { - let [prefetch, setPrefetch] = useState(false); - let prettyCreatedAt = props.added_at ? timeAgo(props.added_at) : ""; - - let prettyPublishedAt = props.publishedAt ? timeAgo(props.publishedAt) : ""; - - return ( -
-
- setPrefetch(true)} - onPointerDown={() => setPrefetch(true)} - prefetch={prefetch} - href={`/${props.token.id}`} - className="no-underline sm:hover:no-underline text-primary grow min-w-0" - > -

- {props.title} -

- -
- {props.isTemplate && props.display === "list" ? ( - - ) : null} - -
-
- setPrefetch(true)} - onPointerDown={() => setPrefetch(true)} - prefetch={prefetch} - href={`/${props.token.id}`} - className="no-underline sm:hover:no-underline text-primary w-full" - > - {props.draft || props.published ? ( -
- {props.published - ? `Published ${prettyPublishedAt}` - : `Draft ${prettyCreatedAt}`} -
- ) : ( -
{prettyCreatedAt}
- )} - - {props.isTemplate && props.display === "grid" ? ( -
- -
- ) : null} -
- ); -}; diff --git a/app/home/LeafletList/LeafletListItem.tsx b/app/home/LeafletList/LeafletListItem.tsx deleted file mode 100644 --- a/app/home/LeafletList/LeafletListItem.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client"; -import { PermissionToken } from "src/replicache"; -import { useTemplateState } from "../Actions/CreateNewButton"; -import { LeafletListPreview, LeafletGridPreview } from "./LeafletPreview"; -import { LeafletInfo } from "./LeafletInfo"; -import { useState, useRef, useEffect } from "react"; - -export const LeafletListItem = (props: { - token: PermissionToken; - leaflet_id: string; - loggedIn: boolean; - display: "list" | "grid"; - cardBorderHidden: boolean; - added_at: string; - title: string; - draft?: boolean; - published?: boolean; - publishedAt?: string; - index: number; - isHidden: boolean; - showPreview?: boolean; -}) => { - let isTemplate = useTemplateState( - (s) => !!s.templates.find((t) => t.id === props.token.id), - ); - - let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false); - let previewRef = useRef(null); - - useEffect(() => { - if (!previewRef.current) return; - let observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - setIsOnScreen(true); - } else { - setIsOnScreen(false); - } - }); - }, - { threshold: 0.1, root: null }, - ); - observer.observe(previewRef.current); - return () => observer.disconnect(); - }, [previewRef]); - - if (props.display === "list") - return ( - <> -
- {props.showPreview && ( - - )} - -
- {props.cardBorderHidden && ( -
- )} - - ); - return ( -
-
- -
- -
- ); -}; diff --git a/app/home/LeafletList/LeafletOptions.tsx b/app/home/LeafletList/LeafletOptions.tsx deleted file mode 100644 --- a/app/home/LeafletList/LeafletOptions.tsx +++ /dev/null @@ -1,220 +0,0 @@ -"use client"; - -import { Menu, MenuItem } from "components/Layout"; -import { useReplicache, type PermissionToken } from "src/replicache"; -import { hideDoc } from "../storage"; -import { useState } from "react"; -import { ButtonPrimary } from "components/Buttons"; -import { useTemplateState } from "../Actions/CreateNewButton"; -import { useSmoker, useToaster } from "components/Toast"; -import { removeLeafletFromHome } from "actions/removeLeafletFromHome"; -import { useIdentityData } from "components/IdentityProvider"; -import { HideSmall } from "components/Icons/HideSmall"; -import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; -import { TemplateRemoveSmall } from "components/Icons/TemplateRemoveSmall"; -import { TemplateSmall } from "components/Icons/TemplateSmall"; -import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; -import { addLeafletToHome } from "actions/addLeafletToHome"; - -export const LeafletOptions = (props: { - leaflet: PermissionToken; - isTemplate: boolean; - loggedIn: boolean; - added_at: string; -}) => { - let { mutate: mutateIdentity } = useIdentityData(); - let [state, setState] = useState<"normal" | "template">("normal"); - let [open, setOpen] = useState(false); - let smoker = useSmoker(); - let toaster = useToaster(); - return ( - <> - { - setOpen(o); - setState("normal"); - }} - trigger={ -
{ - e.preventDefault; - e.stopPropagation; - }} - > - -
- } - > - {state === "normal" ? ( - <> - {!props.isTemplate ? ( - { - e.preventDefault(); - setState("template"); - }} - > - Add as Template - - ) : ( - { - useTemplateState.getState().removeTemplate(props.leaflet); - let newLeafletButton = - document.getElementById("new-leaflet-button"); - if (!newLeafletButton) return; - let rect = newLeafletButton.getBoundingClientRect(); - smoker({ - static: true, - text: Removed template!, - position: { - y: rect.top, - x: rect.right + 5, - }, - }); - }} - > - Remove from Templates - - )} - { - if (props.loggedIn) { - mutateIdentity( - (s) => { - if (!s) return s; - return { - ...s, - permission_token_on_homepage: - s.permission_token_on_homepage.filter( - (ptrh) => - ptrh.permission_tokens.id !== props.leaflet.id, - ), - }; - }, - { revalidate: false }, - ); - await removeLeafletFromHome([props.leaflet.id]); - mutateIdentity(); - } else { - hideDoc(props.leaflet); - } - toaster({ - content: ( -
- Doc removed!{" "} - -
- ), - type: "success", - }); - }} - > - - Remove from Home -
- - ) : state === "template" ? ( - setOpen(false)} - /> - ) : null} -
- - ); -}; - -const UndoRemoveFromHomeButton = (props: { - leaflet: PermissionToken; - added_at: string | undefined; -}) => { - let toaster = useToaster(); - let { mutate } = useIdentityData(); - return ( - - ); -}; - -const AddTemplateForm = (props: { - leaflet: PermissionToken; - close: () => void; -}) => { - let [name, setName] = useState(""); - let smoker = useSmoker(); - return ( -
- - - { - useTemplateState.getState().addTemplate({ - name, - id: props.leaflet.id, - }); - let newLeafletButton = document.getElementById("new-leaflet-button"); - if (!newLeafletButton) return; - let rect = newLeafletButton.getBoundingClientRect(); - smoker({ - static: true, - text: Added {name}!, - position: { - y: rect.top, - x: rect.right + 5, - }, - }); - props.close(); - }} - className="place-self-end" - > - Add Template - -
- ); -}; diff --git a/app/home/LeafletList/LeafletPreview.module.css b/app/home/LeafletList/LeafletPreview.module.css deleted file mode 100644 --- a/app/home/LeafletList/LeafletPreview.module.css +++ /dev/null @@ -1,16 +0,0 @@ -.scaleLeafletDocPreview { - transform: scale(calc(160 / var(--page-width-unitless))); -} - -.scaleLeafletCanvasPreview { - transform: scale(calc(160 / 1272)); -} - -@media (min-width: 640px) { - .scaleLeafletDocPreview { - transform: scale(calc(192 / var(--page-width-unitless))); - } - .scaleLeafletCanvasPreview { - transform: scale(calc(192 / 1272)); - } -} diff --git a/app/home/LeafletList/LeafletPreview.tsx b/app/home/LeafletList/LeafletPreview.tsx deleted file mode 100644 --- a/app/home/LeafletList/LeafletPreview.tsx +++ /dev/null @@ -1,190 +0,0 @@ -"use client"; -import { - ThemeBackgroundProvider, - ThemeProvider, -} from "components/ThemeManager/ThemeProvider"; -import { - PermissionToken, - useEntity, - useReferenceToEntity, -} from "src/replicache"; -import { useTemplateState } from "../Actions/CreateNewButton"; -import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; -import { LeafletContent } from "./LeafletContent"; -import { Tooltip } from "components/Tooltip"; -import { useState } from "react"; -import Link from "next/link"; -import { SpeedyLink } from "components/SpeedyLink"; - -export const LeafletListPreview = (props: { - draft?: boolean; - published?: boolean; - isVisible: boolean; - token: PermissionToken; - leaflet_id: string; - loggedIn: boolean; -}) => { - let root = - useReferenceToEntity("root/page", props.leaflet_id)[0]?.entity || - props.leaflet_id; - let firstPage = useEntity(root, "root/page")[0]; - let page = firstPage?.data.value || root; - - let cardBorderHidden = useCardBorderHidden(root); - let rootBackgroundImage = useEntity(root, "theme/card-background-image"); - let rootBackgroundRepeat = useEntity( - root, - "theme/card-background-image-repeat", - ); - let rootBackgroundOpacity = useEntity( - root, - "theme/card-background-image-opacity", - ); - - return ( - -
- - -
-
-
- - -
-
- } - className="p-1!" - > - - -
-
- -
-
-
-
-
- ); -}; - -export const LeafletGridPreview = (props: { - draft?: boolean; - published?: boolean; - token: PermissionToken; - leaflet_id: string; - loggedIn: boolean; - isVisible: boolean; -}) => { - let root = - useReferenceToEntity("root/page", props.leaflet_id)[0]?.entity || - props.leaflet_id; - let firstPage = useEntity(root, "root/page")[0]; - let page = firstPage?.data.value || root; - - let cardBorderHidden = useCardBorderHidden(root); - let rootBackgroundImage = useEntity(root, "theme/card-background-image"); - let rootBackgroundRepeat = useEntity( - root, - "theme/card-background-image-repeat", - ); - let rootBackgroundOpacity = useEntity( - root, - "theme/card-background-image-opacity", - ); - return ( - -
-
- -
-
- -
-
-
-
- -
-
- ); -}; - -const LeafletPreviewLink = (props: { id: string }) => { - return ( - - ); -}; diff --git a/app/(home-pages)/home/Actions/AccountSettings.tsx b/app/(home-pages)/home/Actions/AccountSettings.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/Actions/AccountSettings.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { ActionButton } from "components/ActionBar/ActionButton"; +import { Menu, MenuItem } from "components/Layout"; +import { mutate } from "swr"; +import { AccountSmall } from "components/Icons/AccountSmall"; +import { LogoutSmall } from "components/Icons/LogoutSmall"; + +// it was going have a popover with a log out button +export const AccountSettings = () => { + return ( + label="Settings" />} + > + { + await fetch("/api/auth/logout"); + mutate("identity", null); + }} + > + + Logout + + + ); +}; diff --git a/app/(home-pages)/home/Actions/Actions.tsx b/app/(home-pages)/home/Actions/Actions.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/Actions/Actions.tsx @@ -0,0 +1,22 @@ +"use client"; +import { ThemePopover } from "components/ThemeManager/ThemeSetter"; +import { CreateNewLeafletButton } from "./CreateNewButton"; +import { HelpPopover } from "components/HelpPopover"; +import { AccountSettings } from "./AccountSettings"; +import { useIdentityData } from "components/IdentityProvider"; +import { useReplicache } from "src/replicache"; +import { LoginActionButton } from "components/LoginButton"; + +export const Actions = () => { + let { identity } = useIdentityData(); + let { rootEntity } = useReplicache(); + return ( + <> + + {identity ? : } + {/**/} + + + + ); +}; diff --git a/app/(home-pages)/home/Actions/CreateNewButton.tsx b/app/(home-pages)/home/Actions/CreateNewButton.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/Actions/CreateNewButton.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { createNewLeaflet } from "actions/createNewLeaflet"; +import { createNewLeafletFromTemplate } from "actions/createNewLeafletFromTemplate"; +import { ActionButton } from "components/ActionBar/ActionButton"; +import { AddTiny } from "components/Icons/AddTiny"; +import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; +import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; +import { TemplateSmall } from "components/Icons/TemplateSmall"; +import { Menu, MenuItem } from "components/Layout"; +import { useIsMobile } from "src/hooks/isMobile"; +import { create } from "zustand"; +import { combine, createJSONStorage, persist } from "zustand/middleware"; + +export const useTemplateState = create( + persist( + combine( + { + templates: [] as { id: string; name: string }[], + }, + (set) => ({ + removeTemplate: (template: { id: string }) => + set((state) => { + return { + templates: state.templates.filter((t) => t.id !== template.id), + }; + }), + addTemplate: (template: { id: string; name: string }) => + set((state) => { + if (state.templates.find((t) => t.id === template.id)) return state; + return { templates: [...state.templates, template] }; + }), + }), + ), + { + name: "home-templates", + storage: createJSONStorage(() => localStorage), + }, + ), +); +export const CreateNewLeafletButton = (props: {}) => { + let isMobile = useIsMobile(); + let templates = useTemplateState((s) => s.templates); + let openNewLeaflet = (id: string) => { + if (isMobile) { + window.location.href = `/${id}?focusFirstBlock`; + } else { + window.open(`/${id}?focusFirstBlock`, "_blank"); + } + }; + return ( + + label="New" + /> + } + > + { + let id = await createNewLeaflet({ + pageType: "doc", + redirectUser: false, + }); + openNewLeaflet(id); + }} + > + {" "} +
+
New Doc
+
+ A good ol' text document +
+
+
+ { + let id = await createNewLeaflet({ + pageType: "canvas", + redirectUser: false, + }); + openNewLeaflet(id); + }} + > + +
+ New Canvas +
+ A digital whiteboard +
+
+
+ {templates.length > 0 && ( +
+ )} + {templates.map((t) => { + return ( + { + let id = await createNewLeafletFromTemplate(t.id, false); + if (!id.error) openNewLeaflet(id.id); + }} + > + + New {t.name} + + ); + })} +
+ ); +}; diff --git a/app/(home-pages)/home/Actions/HomeHelp.tsx b/app/(home-pages)/home/Actions/HomeHelp.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/Actions/HomeHelp.tsx @@ -0,0 +1,27 @@ +"use client"; +import { ActionButton } from "components/ActionBar/ActionButton"; +import { HelpSmall } from "components/Icons/HelpSmall"; +import { Popover } from "components/Popover"; + +export const HomeHelp = () => { + return ( + } label="Info" />} + > +
+

+ Leaflets are saved to home per-device / browser using + cookies. +

+

+ If you clear your cookies, they'll disappear. +

+

+ Please contact us for help + recovering Leaflets! +

+
+
+ ); +}; diff --git a/app/(home-pages)/home/HomeEmpty/DiscoverIllo.tsx b/app/(home-pages)/home/HomeEmpty/DiscoverIllo.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/HomeEmpty/DiscoverIllo.tsx @@ -0,0 +1,26 @@ +import { theme } from "tailwind.config"; +export const DiscoverIllo = () => { + return ( + + + + + + ); +}; diff --git a/app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx b/app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { PubListEmptyIllo } from "components/ActionBar/Publications"; +import { ButtonPrimary } from "components/Buttons"; +import { AddSmall } from "components/Icons/AddSmall"; +import { Link } from "react-aria-components"; +import { DiscoverIllo } from "./DiscoverIllo"; +import { WelcomeToLeafletIllo } from "./WelcomeToLeafletIllo"; +import { DiscoverSmall } from "components/Icons/DiscoverSmall"; +import { PublishSmall } from "components/Icons/PublishSmall"; +import { createNewLeaflet } from "actions/createNewLeaflet"; +import { useIsMobile } from "src/hooks/isMobile"; + +export function HomeEmptyState() { + let isMobile = useIsMobile(); + return ( +
+
+
+ +
+
+

Leaflet

+ {/*

A platform for social publishing.

*/} +
+ Write and share delightful documents! +
+ { + let openNewLeaflet = (id: string) => { + if (isMobile) { + window.location.href = `/${id}?focusFirstBlock`; + } else { + window.open(`/${id}?focusFirstBlock`, "_blank"); + } + }; + + let id = await createNewLeaflet({ + pageType: "doc", + redirectUser: false, + }); + openNewLeaflet(id); + }} + > + Write a Doc! + +
+
+
+
+
or
+
+
+ + + +
+ Right now docs and publications are separate. Soon you'll be able to add + docs to pubs! +
+
+ ); +} + +export const PublicationBanner = (props: { small?: boolean }) => { + return ( +
+ {props.small ? ( + + ) : ( +
+ +
+ )} +
+ + Start a Publication + {" "} + and blog in the Atmosphere +
+
+ ); +}; + +export const DiscoverBanner = (props: { small?: boolean }) => { + return ( +
+ {props.small ? ( + + ) : ( +
+ +
+ )} +
+ + Explore Publications + {" "} + on art, tech, games, music & more! +
+
+ ); +}; diff --git a/app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx b/app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx @@ -0,0 +1,24 @@ +import { theme } from "tailwind.config"; + +export const WelcomeToLeafletIllo = () => { + return ( + + + + + ); +}; diff --git a/app/(home-pages)/home/LeafletList/LeafletContent.tsx b/app/(home-pages)/home/LeafletList/LeafletContent.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/LeafletList/LeafletContent.tsx @@ -0,0 +1,68 @@ +"use client"; +import { BlockPreview } from "components/Blocks/PageLinkBlock"; +import { useEffect, useRef, useState } from "react"; +import { useBlocks } from "src/hooks/queries/useBlocks"; +import { useEntity } from "src/replicache"; +import { CanvasContent } from "components/Canvas"; +import styles from "./LeafletPreview.module.css"; +import { PublicationMetadataPreview } from "components/Pages/PublicationMetadata"; + +export const LeafletContent = (props: { + entityID: string; + isOnScreen: boolean; +}) => { + let type = useEntity(props.entityID, "page/type")?.data.value || "doc"; + let blocks = useBlocks(props.entityID); + let previewRef = useRef(null); + + if (type === "canvas") + return ( +
+
+ {props.isOnScreen && ( + + )} +
+
+ ); + + return ( +
+
+ + + {props.isOnScreen && + blocks.slice(0, 10).map((b, index, arr) => { + return ( + + ); + })} +
+
+ ); +}; diff --git a/app/(home-pages)/home/LeafletList/LeafletInfo.tsx b/app/(home-pages)/home/LeafletList/LeafletInfo.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/LeafletList/LeafletInfo.tsx @@ -0,0 +1,88 @@ +"use client"; +import { PermissionToken } from "src/replicache"; +import { LeafletOptions } from "./LeafletOptions"; +import Link from "next/link"; +import { useState } from "react"; +import { theme } from "tailwind.config"; +import { TemplateSmall } from "components/Icons/TemplateSmall"; +import { timeAgo } from "src/utils/timeAgo"; + +export const LeafletInfo = (props: { + title?: string; + draft?: boolean; + published?: boolean; + token: PermissionToken; + leaflet_id: string; + loggedIn: boolean; + isTemplate: boolean; + className?: string; + display: "grid" | "list"; + added_at: string; + publishedAt?: string; +}) => { + let [prefetch, setPrefetch] = useState(false); + let prettyCreatedAt = props.added_at ? timeAgo(props.added_at) : ""; + + let prettyPublishedAt = props.publishedAt ? timeAgo(props.publishedAt) : ""; + + return ( +
+
+ setPrefetch(true)} + onPointerDown={() => setPrefetch(true)} + prefetch={prefetch} + href={`/${props.token.id}`} + className="no-underline sm:hover:no-underline text-primary grow min-w-0" + > +

+ {props.title} +

+ +
+ {props.isTemplate && props.display === "list" ? ( + + ) : null} + +
+
+ setPrefetch(true)} + onPointerDown={() => setPrefetch(true)} + prefetch={prefetch} + href={`/${props.token.id}`} + className="no-underline sm:hover:no-underline text-primary w-full" + > + {props.draft || props.published ? ( +
+ {props.published + ? `Published ${prettyPublishedAt}` + : `Draft ${prettyCreatedAt}`} +
+ ) : ( +
{prettyCreatedAt}
+ )} + + {props.isTemplate && props.display === "grid" ? ( +
+ +
+ ) : null} +
+ ); +}; diff --git a/app/(home-pages)/home/LeafletList/LeafletListItem.tsx b/app/(home-pages)/home/LeafletList/LeafletListItem.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/LeafletList/LeafletListItem.tsx @@ -0,0 +1,102 @@ +"use client"; +import { PermissionToken } from "src/replicache"; +import { useTemplateState } from "../Actions/CreateNewButton"; +import { LeafletListPreview, LeafletGridPreview } from "./LeafletPreview"; +import { LeafletInfo } from "./LeafletInfo"; +import { useState, useRef, useEffect } from "react"; + +export const LeafletListItem = (props: { + token: PermissionToken; + leaflet_id: string; + loggedIn: boolean; + display: "list" | "grid"; + cardBorderHidden: boolean; + added_at: string; + title: string; + draft?: boolean; + published?: boolean; + publishedAt?: string; + index: number; + isHidden: boolean; + showPreview?: boolean; +}) => { + let isTemplate = useTemplateState( + (s) => !!s.templates.find((t) => t.id === props.token.id), + ); + + let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false); + let previewRef = useRef(null); + + useEffect(() => { + if (!previewRef.current) return; + let observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setIsOnScreen(true); + } else { + setIsOnScreen(false); + } + }); + }, + { threshold: 0.1, root: null }, + ); + observer.observe(previewRef.current); + return () => observer.disconnect(); + }, [previewRef]); + + if (props.display === "list") + return ( + <> +
+ {props.showPreview && ( + + )} + +
+ {props.cardBorderHidden && ( +
+ )} + + ); + return ( +
+
+ +
+ +
+ ); +}; diff --git a/app/(home-pages)/home/LeafletList/LeafletOptions.tsx b/app/(home-pages)/home/LeafletList/LeafletOptions.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/LeafletList/LeafletOptions.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { Menu, MenuItem } from "components/Layout"; +import { useReplicache, type PermissionToken } from "src/replicache"; +import { hideDoc } from "../storage"; +import { useState } from "react"; +import { ButtonPrimary } from "components/Buttons"; +import { useTemplateState } from "../Actions/CreateNewButton"; +import { useSmoker, useToaster } from "components/Toast"; +import { removeLeafletFromHome } from "actions/removeLeafletFromHome"; +import { useIdentityData } from "components/IdentityProvider"; +import { HideSmall } from "components/Icons/HideSmall"; +import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; +import { TemplateRemoveSmall } from "components/Icons/TemplateRemoveSmall"; +import { TemplateSmall } from "components/Icons/TemplateSmall"; +import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; +import { addLeafletToHome } from "actions/addLeafletToHome"; + +export const LeafletOptions = (props: { + leaflet: PermissionToken; + isTemplate: boolean; + loggedIn: boolean; + added_at: string; +}) => { + let { mutate: mutateIdentity } = useIdentityData(); + let [state, setState] = useState<"normal" | "template">("normal"); + let [open, setOpen] = useState(false); + let smoker = useSmoker(); + let toaster = useToaster(); + return ( + <> + { + setOpen(o); + setState("normal"); + }} + trigger={ +
{ + e.preventDefault; + e.stopPropagation; + }} + > + +
+ } + > + {state === "normal" ? ( + <> + {!props.isTemplate ? ( + { + e.preventDefault(); + setState("template"); + }} + > + Add as Template + + ) : ( + { + useTemplateState.getState().removeTemplate(props.leaflet); + let newLeafletButton = + document.getElementById("new-leaflet-button"); + if (!newLeafletButton) return; + let rect = newLeafletButton.getBoundingClientRect(); + smoker({ + static: true, + text: Removed template!, + position: { + y: rect.top, + x: rect.right + 5, + }, + }); + }} + > + Remove from Templates + + )} + { + if (props.loggedIn) { + mutateIdentity( + (s) => { + if (!s) return s; + return { + ...s, + permission_token_on_homepage: + s.permission_token_on_homepage.filter( + (ptrh) => + ptrh.permission_tokens.id !== props.leaflet.id, + ), + }; + }, + { revalidate: false }, + ); + await removeLeafletFromHome([props.leaflet.id]); + mutateIdentity(); + } else { + hideDoc(props.leaflet); + } + toaster({ + content: ( +
+ Doc removed!{" "} + +
+ ), + type: "success", + }); + }} + > + + Remove from Home +
+ + ) : state === "template" ? ( + setOpen(false)} + /> + ) : null} +
+ + ); +}; + +const UndoRemoveFromHomeButton = (props: { + leaflet: PermissionToken; + added_at: string | undefined; +}) => { + let toaster = useToaster(); + let { mutate } = useIdentityData(); + return ( + + ); +}; + +const AddTemplateForm = (props: { + leaflet: PermissionToken; + close: () => void; +}) => { + let [name, setName] = useState(""); + let smoker = useSmoker(); + return ( +
+ + + { + useTemplateState.getState().addTemplate({ + name, + id: props.leaflet.id, + }); + let newLeafletButton = document.getElementById("new-leaflet-button"); + if (!newLeafletButton) return; + let rect = newLeafletButton.getBoundingClientRect(); + smoker({ + static: true, + text: Added {name}!, + position: { + y: rect.top, + x: rect.right + 5, + }, + }); + props.close(); + }} + className="place-self-end" + > + Add Template + +
+ ); +}; diff --git a/app/(home-pages)/home/LeafletList/LeafletPreview.module.css b/app/(home-pages)/home/LeafletList/LeafletPreview.module.css new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/LeafletList/LeafletPreview.module.css @@ -0,0 +1,16 @@ +.scaleLeafletDocPreview { + transform: scale(calc(160 / var(--page-width-unitless))); +} + +.scaleLeafletCanvasPreview { + transform: scale(calc(160 / 1272)); +} + +@media (min-width: 640px) { + .scaleLeafletDocPreview { + transform: scale(calc(192 / var(--page-width-unitless))); + } + .scaleLeafletCanvasPreview { + transform: scale(calc(192 / 1272)); + } +} diff --git a/app/(home-pages)/home/LeafletList/LeafletPreview.tsx b/app/(home-pages)/home/LeafletList/LeafletPreview.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/home/LeafletList/LeafletPreview.tsx @@ -0,0 +1,190 @@ +"use client"; +import { + ThemeBackgroundProvider, + ThemeProvider, +} from "components/ThemeManager/ThemeProvider"; +import { + PermissionToken, + useEntity, + useReferenceToEntity, +} from "src/replicache"; +import { useTemplateState } from "../Actions/CreateNewButton"; +import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; +import { LeafletContent } from "./LeafletContent"; +import { Tooltip } from "components/Tooltip"; +import { useState } from "react"; +import Link from "next/link"; +import { SpeedyLink } from "components/SpeedyLink"; + +export const LeafletListPreview = (props: { + draft?: boolean; + published?: boolean; + isVisible: boolean; + token: PermissionToken; + leaflet_id: string; + loggedIn: boolean; +}) => { + let root = + useReferenceToEntity("root/page", props.leaflet_id)[0]?.entity || + props.leaflet_id; + let firstPage = useEntity(root, "root/page")[0]; + let page = firstPage?.data.value || root; + + let cardBorderHidden = useCardBorderHidden(root); + let rootBackgroundImage = useEntity(root, "theme/card-background-image"); + let rootBackgroundRepeat = useEntity( + root, + "theme/card-background-image-repeat", + ); + let rootBackgroundOpacity = useEntity( + root, + "theme/card-background-image-opacity", + ); + + return ( + +
+ + +
+
+
+ + +
+
+ } + className="p-1!" + > + + +
+
+ +
+
+
+
+
+ ); +}; + +export const LeafletGridPreview = (props: { + draft?: boolean; + published?: boolean; + token: PermissionToken; + leaflet_id: string; + loggedIn: boolean; + isVisible: boolean; +}) => { + let root = + useReferenceToEntity("root/page", props.leaflet_id)[0]?.entity || + props.leaflet_id; + let firstPage = useEntity(root, "root/page")[0]; + let page = firstPage?.data.value || root; + + let cardBorderHidden = useCardBorderHidden(root); + let rootBackgroundImage = useEntity(root, "theme/card-background-image"); + let rootBackgroundRepeat = useEntity( + root, + "theme/card-background-image-repeat", + ); + let rootBackgroundOpacity = useEntity( + root, + "theme/card-background-image-opacity", + ); + return ( + +
+
+ +
+
+ +
+
+
+
+ +
+
+ ); +}; + +const LeafletPreviewLink = (props: { id: string }) => { + return ( + + ); +}; diff --git a/app/lish/[did]/[publication]/dashboard/DraftList.tsx b/app/lish/[did]/[publication]/dashboard/DraftList.tsx --- a/app/lish/[did]/[publication]/dashboard/DraftList.tsx +++ b/app/lish/[did]/[publication]/dashboard/DraftList.tsx @@ -3,7 +3,7 @@ import { NewDraftSecondaryButton } from "./NewDraftButton"; import React from "react"; import { usePublicationData } from "./PublicationSWRProvider"; -import { LeafletList } from "app/home/HomeLayout"; +import { LeafletList } from "app/(home-pages)/home/HomeLayout"; export function DraftList(props: { searchValue: string;