From aa875e36adb095818366d0ed1df778ee330e5c2a Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 07 May 2026 21:43:08 +0000 Subject: [PATCH] Refactor/route nav (#294) * move tabs into sidebar * move dashboard pages into new layout, tweak mobile layout * renaming and componentizing things in the Dashboard Layouts for clarity * componentize the search input on pages * added sidebar on mobile, refactored some stuff * added sticky scroll behavior to mobile header * animations for the mobile pageHeader * fix mobile nav trigger and component structure * futzing * more futzing * Added logged out state to Reader Inbox, refactored reader to use new layout * fix a z-index issue * use route based navigation * remove logs and small fixes * simplify * small fixes * updates to leaflet to accomodate sidebar changes * dynamic notification page * fix search when signed out * prevent default and add stop propogration to email submit * added new tab to the reader, decodeds urls in pickActiveTab function * tweaks to publish banner in sidebar to avoid redundancy * bubble up notifactions, scroll very long pub lists * rm interaction drawer in reader, add a layout component to reader pages * added leaflet about * move mobile nav to the bottom * added icons to the tabs * handle signed out users side bar actions --------- Co-authored-by: celine --- CLAUDE.md | 2 +- app/globals.css | 37 +++++++++++++++++++++++++++++++++++++ components/IdentityProvider.tsx | 2 +- components/LoginButton.tsx | 24 +++++++++++++++++++----- components/Media.tsx | 4 +++- components/Modal.tsx | 32 +++++++++++++++++++++++++------- components/PageHeader.tsx | 8 +++++--- components/PostListing.tsx | 9 +++++---- components/SpeedyLink.tsx | 2 ++ components/ToggleGroup.tsx | 6 +++++- components/ViewportSizeLayout.tsx | 122 ++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------------------------- app/(home-pages)/page.tsx | 26 +++++--------------------- app/[leaflet_id]/Footer.tsx | 27 +++++++++++++++++++++++---- app/[leaflet_id]/Leaflet.tsx | 2 -- app/[leaflet_id]/Sidebar.tsx | 25 ++++++++++++++++++++++--- components/ActionBar/ActionButton.tsx | 40 ++++++++++++++++++++++------------------ components/ActionBar/DesktopNavigation.tsx | 276 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------- components/ActionBar/MobileNavigation.tsx | 217 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------- components/ActionBar/NavigationButtons.tsx | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------- components/ActionBar/ProfileButton.tsx | 6 +++--- components/ActionBar/PublicationNavigation.tsx | 148 ---------------------------------------------------------------------------------------------------------------------------------------------------- components/ActionBar/Publications.tsx | 172 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------------------------------------------- components/ActionBar/Sidebar.tsx | 33 +++++++++++++++++++++++++++------ components/Icons/AnalyticsSmall.tsx | 19 +++++++++++++++++++ components/Icons/GoToArrowLined.tsx | 22 ++++++++++++++++++++++ components/Icons/LeafletTiny.tsx | 19 +++++++++++++++++++ components/Icons/MenuSmall.tsx | 1 + components/Icons/NewSmall.tsx | 19 +++++++++++++++++++ components/Icons/SortSmall.tsx | 1 + components/Icons/SubscribersSmall.tsx | 21 +++++++++++++++++++++ components/Icons/TrendingSmall.tsx | 19 +++++++++++++++++++ components/PageLayouts/DashboardLayout.tsx | 580 ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- components/PageLayouts/DashboardPageLayout.tsx | 39 +++++++++++++++++++++++++++++++++++++++ components/PageLayouts/DashboardShell.tsx | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ components/PageLayouts/NotificationContent.tsx | 32 ++++++++++++++++++++++++++++++++ components/PageLayouts/PageSearch.tsx | 333 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ components/PageLayouts/dashboardState.ts | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ components/Popover/index.tsx | 11 +++++++++-- components/Subscribe/HandleSubscribe.tsx | 6 +++--- components/ThemeManager/ThemeSetter.tsx | 26 +++++++++----------------- components/utils/AddLeafletToHomepage.tsx | 20 -------------------- components/utils/DotLoader.tsx | 1 + src/hooks/isMobile.ts | 1 + app/(home-pages)/(writer)/WriterShell.tsx | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/layout.tsx | 5 +++++ app/(home-pages)/home/HomeLayout.tsx | 309 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- app/(home-pages)/home/IdentitySetter.tsx | 13 ------------- app/(home-pages)/home/icon.tsx | 105 --------------------------------------------------------------------------------------------------------- app/(home-pages)/home/page.tsx | 44 -------------------------------------------- app/(home-pages)/home/storage.ts | 68 -------------------------------------------------------------------- app/(home-pages)/looseleafs/LooseleafsLayout.tsx | 111 --------------------------------------------------------------------------------------------------------------- app/(home-pages)/looseleafs/page.tsx | 47 ----------------------------------------------- app/(home-pages)/notifications/BskyPostEmbedNotification.tsx | 41 ----------------------------------------- app/(home-pages)/notifications/CommentMentionNotification.tsx | 89 ----------------------------------------------------------------------------------------- app/(home-pages)/notifications/CommentNotication.tsx | 59 ----------------------------------------------------------- app/(home-pages)/notifications/FollowNotification.tsx | 34 ---------------------------------- app/(home-pages)/notifications/MentionNotification.tsx | 62 -------------------------------------------------------------- app/(home-pages)/notifications/Notification.tsx | 112 ---------------------------------------------------------------------------------------------------------------- app/(home-pages)/notifications/NotificationList.tsx | 71 ----------------------------------------------------------------------- app/(home-pages)/notifications/QuoteNotification.tsx | 42 ------------------------------------------ app/(home-pages)/notifications/RecommendNotification.tsx | 45 --------------------------------------------- app/(home-pages)/notifications/ReplyNotification.tsx | 83 ----------------------------------------------------------------------------------- app/(home-pages)/notifications/getNotifications.ts | 28 ---------------------------- app/(home-pages)/notifications/page.tsx | 35 ----------------------------------- app/(home-pages)/reader/FeedLayout.tsx | 7 +++++++ app/(home-pages)/reader/GlobalContent.tsx | 26 +++++++++----------------- app/(home-pages)/reader/InboxContent.tsx | 67 +++++++++++++++++++++++++++++++++++-------------------------------- app/(home-pages)/reader/InteractionDrawers.tsx | 117 --------------------------------------------------------------------------------------------------------------------- app/(home-pages)/reader/NewContent.tsx | 44 ++++++++++++++++++-------------------------- app/(home-pages)/reader/layout.tsx | 88 +++++++++++++++++++++------------------------------------------------------------------- app/(home-pages)/reader/loading.tsx | 4 ---- app/(home-pages)/reader/page.tsx | 36 ++++++++++++++++++++++++++---------- app/[leaflet_id]/actions/HomeButton.tsx | 55 +++++++++++++++++++++++++++++-------------------------- app/[leaflet_id]/actions/PublishButton.tsx | 8 +++++++- app/[leaflet_id]/publish/PublishPost.tsx | 2 +- app/(home-pages)/(writer)/home/HomeLayout.tsx | 303 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/home/IdentitySetter.tsx | 13 +++++++++++++ app/(home-pages)/(writer)/home/icon.tsx | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/home/page.tsx | 44 ++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/home/storage.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/looseleafs/LooseleafsLayout.tsx | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/looseleafs/page.tsx | 43 +++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/notifications/BskyPostEmbedNotification.tsx | 41 +++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/notifications/CommentNotication.tsx | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/notifications/FollowNotification.tsx | 34 ++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/notifications/MentionNotification.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/notifications/Notification.tsx | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/notifications/NotificationList.tsx | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/notifications/QuoteNotification.tsx | 42 ++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/notifications/RecommendNotification.tsx | 45 +++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/notifications/ReplyNotification.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/notifications/getNotifications.ts | 28 ++++++++++++++++++++++++++++ app/(home-pages)/(writer)/notifications/page.tsx | 29 +++++++++++++++++++++++++++++ app/(home-pages)/home/Actions/AccountSettings.tsx | 24 ------------------------ app/(home-pages)/home/Actions/Actions.tsx | 18 ------------------ app/(home-pages)/home/Actions/CreateNewButton.tsx | 71 ----------------------------------------------------------------------- app/(home-pages)/home/Actions/HomeHelp.tsx | 27 --------------------------- app/(home-pages)/home/HomeEmpty/DiscoverIllo.tsx | 26 -------------------------- app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx | 84 ------------------------------------------------------------------------------------ app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx | 24 ------------------------ app/(home-pages)/home/LeafletList/LeafletContent.tsx | 68 -------------------------------------------------------------------- app/(home-pages)/home/LeafletList/LeafletInfo.tsx | 60 ------------------------------------------------------------ app/(home-pages)/home/LeafletList/LeafletListItem.tsx | 113 ----------------------------------------------------------------------------------------------------------------- app/(home-pages)/home/LeafletList/LeafletOptions.tsx | 351 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- app/(home-pages)/home/LeafletList/LeafletPreview.module.css | 16 ---------------- app/(home-pages)/home/LeafletList/LeafletPreview.tsx | 138 ------------------------------------------------------------------------------------------------------------------------------------------ app/(home-pages)/p/[didOrHandle]/layout.tsx | 44 ++++++++++++++++++++++++-------------------- app/(home-pages)/reader/hot/page.tsx | 8 +++----- app/(home-pages)/reader/new/page.tsx | 21 ++++++++++++++++++--- app/(home-pages)/reader/trending/page.tsx | 22 ++++++++++++++++++++++ app/(home-pages)/tag/[tag]/layout.tsx | 24 ++++++++++++++++++++++++ app/(home-pages)/tag/[tag]/page.tsx | 21 ++++++++------------- app/[leaflet_id]/actions/ShareOptions/index.tsx | 2 ++ app/lish/[did]/[publication]/UpgradeModal.tsx | 1 + app/(home-pages)/(writer)/home/Actions/Actions.tsx | 16 ++++++++++++++++ app/(home-pages)/(writer)/home/Actions/CreateNewButton.tsx | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/home/Actions/HomeThemeSetter.tsx | 30 ++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/home/HomeEmpty/DiscoverIllo.tsx | 26 ++++++++++++++++++++++++++ app/(home-pages)/(writer)/home/HomeEmpty/HomeEmpty.tsx | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/home/HomeEmpty/WelcomeToLeafletIllo.tsx | 24 ++++++++++++++++++++++++ app/(home-pages)/(writer)/home/LeafletList/LeafletContent.tsx | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/home/LeafletList/LeafletInfo.tsx | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/home/LeafletList/LeafletListItem.tsx | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx | 351 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/(home-pages)/(writer)/home/LeafletList/LeafletPreview.module.css | 16 ++++++++++++++++ app/(home-pages)/(writer)/home/LeafletList/LeafletPreview.tsx | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/lish/[did]/[publication]/dashboard/Actions.tsx | 9 ++------- app/lish/[did]/[publication]/dashboard/DraftList.tsx | 2 +- app/lish/[did]/[publication]/dashboard/DraftsTab.tsx | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx | 27 +++++++++++++++++++-------- app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx | 109 ------------------------------------------------------------------------------------------------------------- app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx | 2 +- app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx | 4 ++-- app/lish/[did]/[publication]/dashboard/layout.tsx | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/lish/[did]/[publication]/dashboard/page.tsx | 109 ++++++++++++++----------------------------------------------------------------------------------------------- app/lish/[did]/[publication]/theme-settings/ThemeSettingsContent.tsx | 2 +- app/lish/[did]/[publication]/dashboard/analytics/page.tsx | 39 +++++++++++++++++++++++++++++++++++++++ app/lish/[did]/[publication]/dashboard/posts/page.tsx | 42 ++++++++++++++++++++++++++++++++++++++++++ app/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx | 10 +++++++--- app/lish/[did]/[publication]/dashboard/settings/page.tsx | 28 ++++++++++++++++++++++++++++ app/lish/[did]/[publication]/dashboard/subs/page.tsx | 32 ++++++++++++++++++++++++++++++++ 142 file(s) changed, 4563 insertion(s)(+), 4173 deletion(s)(-) diff --git a/CLAUDE.md b/CLAUDE.md --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,4 +71,4 @@ - **React contexts**: `DocumentProvider`, `LeafletContentProvider` for page-level data - **Inngest functions**: Async jobs in `app/api/inngest/functions/` - **Icons**: Icon components live in `components/Icons/`. Each icon is a named export in its own file (e.g. `RefreshSmall.tsx`), imports `Props` from `./Props`, spreads `{...props}` on the `` element, and uses `fill="currentColor"` instead of hardcoded colors like `fill="black"`. -- **Popovers and menus**: Use the existing `Popover` (`components/Popover`), `Menu`, and `MenuItem` (`components/Menu`) components — do not create new popover/menu primitives +- **Popovers and menus**: Use the existing `Popover` (`components/Popover`), `Menu`, and `MenuItem` (`components/Menu`) components — do not create new popover/menu primitives. Avoid using buttons inside Popover or Modal triggers unless using a specific button component. If button is being used as trigger, always add an "asChild" prop to the Menu or Popover diff --git a/app/globals.css b/app/globals.css --- a/app/globals.css +++ b/app/globals.css @@ -486,6 +486,13 @@ @apply rounded-md; } +.light-container { + background: color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 95%); + @apply border; + @apply border-border-light; + @apply rounded-md; +} + .frosted-container { background: rgba(var(--bg-page), 0.75); @apply border; @@ -536,6 +543,13 @@ } .pwa-padding-bottom { padding-bottom: var(--safe-padding-bottom); +} + +.pwa-padding-x { + @media (display-mode: standalone) { + margin-left: 1rem; + margin-right: 1rem; + } } /* animation for atmosphere logo carousel */ @@ -617,6 +631,29 @@ opacity: 1; transform: translateX(0); } +} + +@keyframes mobile-sidebar-slide-in { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} +@keyframes mobile-sidebar-slide-out { + from { + transform: translateX(0); + } + to { + transform: translateX(-100%); + } +} +.mobile-sidebar-content[data-state="open"] { + animation: mobile-sidebar-slide-in 80ms ease-out; +} +.mobile-sidebar-content[data-state="closed"] { + animation: mobile-sidebar-slide-out 80ms ease-in; } .footnote-side-item { diff --git a/components/IdentityProvider.tsx b/components/IdentityProvider.tsx --- a/components/IdentityProvider.tsx +++ b/components/IdentityProvider.tsx @@ -2,7 +2,7 @@ import { getIdentityData } from "actions/getIdentityData"; import { createContext, useContext, useEffect } from "react"; import useSWR, { KeyedMutator, mutate } from "swr"; -import { DashboardState } from "./PageLayouts/DashboardLayout"; +import type { DashboardState } from "./PageLayouts/dashboardState"; import { supabaseBrowserClient } from "supabase/browserClient"; import { produce, Draft } from "immer"; diff --git a/components/LoginButton.tsx b/components/LoginButton.tsx --- a/components/LoginButton.tsx +++ b/components/LoginButton.tsx @@ -19,7 +19,7 @@ confirmEmailAuthToken, } from "actions/emailAuth"; import { loginWithEmailToken } from "actions/login"; -import { getHomeDocs } from "app/(home-pages)/home/storage"; +import { getHomeDocs } from "app/(home-pages)/(writer)/home/storage"; import { mutate } from "swr"; export const LoginModal = (props: { @@ -43,6 +43,7 @@ trigger={props.trigger} open={open} onOpenChange={setOpen} + className="w-full!" > void; + className?: string; }) => { let identityData = useIdentityData(); let [state, setState] = useState< @@ -125,7 +127,7 @@ }; return ( -
+
} @@ -215,7 +227,9 @@
+ ); }; diff --git a/components/PostListing.tsx b/components/PostListing.tsx --- a/components/PostListing.tsx +++ b/components/PostListing.tsx @@ -138,10 +138,11 @@ {postRecord.title} )} - -

- {postRecord.description || getFirstParagraph(postRecord)} -

+ {postRecord.description && postRecord.description !== "" && ( +

+ {postRecord.description || getFirstParagraph(postRecord)} +

+ )}
{props.publication && pubRecord && ( {props.children} diff --git a/components/ToggleGroup.tsx b/components/ToggleGroup.tsx --- a/components/ToggleGroup.tsx +++ b/components/ToggleGroup.tsx @@ -23,7 +23,11 @@ } ${props.optionClassName} `} - onClick={() => props.onChange(option.value)} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + props.onChange(option.value); + }} > {option.label} diff --git a/components/ViewportSizeLayout.tsx b/components/ViewportSizeLayout.tsx --- a/components/ViewportSizeLayout.tsx +++ b/components/ViewportSizeLayout.tsx @@ -2,90 +2,64 @@ import { useEffect, useState } from "react"; import { isIOS } from "src/utils/isDevice"; +type VisualViewportState = { + width: number; + height: number; + offsetTop: number; + difference: number; +}; + +function getVisualViewport(): VisualViewportState { + if (typeof window === "undefined") { + return { width: 0, height: 0, offsetTop: 0, difference: 0 }; + } + let height = visualViewport?.height || window.innerHeight; + return { + width: visualViewport?.width || window.innerWidth, + height, + offsetTop: visualViewport?.offsetTop || 0, + difference: window.innerHeight - height, + }; +} + +export function useVisualViewport(): VisualViewportState { + let [state, setState] = useState(getVisualViewport); + + useEffect(() => { + let onResize = () => { + setState((prev) => { + let next = getVisualViewport(); + if ( + prev.width === next.width && + prev.height === next.height && + prev.offsetTop === next.offsetTop + ) { + return prev; + } + return next; + }); + }; + + let target: VisualViewport | Window = visualViewport || window; + target.addEventListener("resize", onResize); + return () => target.removeEventListener("resize", onResize); + }, []); + + return state; +} + export function ViewportSizeLayout(props: { children: React.ReactNode }) { - let viewheight = useViewportSize().height; - let difference = useViewportDifference(); + let { height, difference } = useVisualViewport(); return (
{props.children}
); -} - -function useViewportDifference(): number { - let [difference, setDifference] = useState(0); - - useEffect(() => { - // Use visualViewport api to track available height even on iOS virtual keyboard opening - let onResize = () => { - setDifference(window.innerHeight - getViewportSize().height); - }; - - if (!visualViewport) { - window.addEventListener("resize", onResize); - } else { - visualViewport.addEventListener("resize", onResize); - } - - return () => { - if (!visualViewport) { - window.removeEventListener("resize", onResize); - } else { - visualViewport.removeEventListener("resize", onResize); - } - }; - }, []); - - return difference; -} - -function getViewportSize() { - if (typeof window === "undefined") return { width: 0, height: 0 }; - return { - width: visualViewport?.width || window?.innerWidth, - height: visualViewport?.height || window?.innerHeight, - }; -} - -export function useViewportSize(): { - width: number; - height: number; -} { - let [size, setSize] = useState(() => getViewportSize()); - - useEffect(() => { - // Use visualViewport api to track available height even on iOS virtual keyboard opening - let onResize = () => { - setSize((size) => { - let newSize = getViewportSize(); - if (newSize.width === size.width && newSize.height === size.height) { - return size; - } - return newSize; - }); - }; - - if (!visualViewport) { - window.addEventListener("resize", onResize); - } else { - visualViewport.addEventListener("resize", onResize); - } - - return () => { - if (!visualViewport) { - window.removeEventListener("resize", onResize); - } else { - visualViewport.removeEventListener("resize", onResize); - } - }; - }, []); - - return size; } diff --git a/app/(home-pages)/page.tsx b/app/(home-pages)/page.tsx --- a/app/(home-pages)/page.tsx +++ b/app/(home-pages)/page.tsx @@ -1,31 +1,15 @@ import { cookies } from "next/headers"; -import ReaderLayout from "./reader/layout"; -import ReaderPage from "./reader/page"; -import HomePage from "./home/page"; +import { redirect } from "next/navigation"; export default async function RootPage() { const cookieStore = await cookies(); const hasAuth = - cookieStore.has("auth_token") || - cookieStore.has("external_auth_token"); + cookieStore.has("auth_token") || cookieStore.has("external_auth_token"); - if (!hasAuth) { - return ( - - - - ); - } + if (!hasAuth) redirect("/reader"); const navState = cookieStore.get("nav-state")?.value; + if (navState === "reader") redirect("/reader"); - if (navState === "reader") { - return ( - - - - ); - } - - return ; + redirect("/home"); } diff --git a/app/[leaflet_id]/Footer.tsx b/app/[leaflet_id]/Footer.tsx --- a/app/[leaflet_id]/Footer.tsx +++ b/app/[leaflet_id]/Footer.tsx @@ -6,16 +6,21 @@ import { Toolbar } from "components/Toolbar"; import { FootnoteToolbar } from "components/Toolbar/FootnoteToolbarWrapper"; import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; -import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; +import { + AddToHomeButton, + HomeButton, +} from "app/[leaflet_id]/actions/HomeButton"; import { PublishButton } from "./actions/PublishButton"; import { useEntitySetContext } from "components/EntitySetProvider"; import { Watermark } from "components/Watermark"; import { BackToPubButton } from "./actions/BackToPubButton"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; import { useIdentityData } from "components/IdentityProvider"; -import { useEntity } from "src/replicache"; +import { useEntity, useReplicache } from "src/replicache"; import { block } from "sharp"; import { PostSettings } from "components/PostSettings"; +import useSWR from "swr"; +import { getHomeDocs } from "app/(home-pages)/(writer)/home/storage"; export function hasBlockToolbar(blockType: string | null | undefined) { return ( @@ -29,12 +34,20 @@ } export function LeafletFooter(props: { entityID: string }) { let focusedBlock = useUIState((s) => s.focusedEntity); - let entity_set = useEntitySetContext(); let { identity } = useIdentityData(); + let { permission_token } = useReplicache(); let { data: pub } = useLeafletPublicationData(); + let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { + fallbackData: [], + }); let blockType = useEntity(focusedBlock?.entityID || null, "block/type")?.data .value; + let isOnHome = identity + ? !!identity.permission_token_on_homepage.find( + (pth) => pth.permission_tokens.id === permission_token.id, + ) + : !!localLeaflets.find((f) => f.token.id === permission_token.id); return ( )} +
- + {!isOnHome ? ( + + ) : ( + + )} + diff --git a/app/[leaflet_id]/Leaflet.tsx b/app/[leaflet_id]/Leaflet.tsx --- a/app/[leaflet_id]/Leaflet.tsx +++ b/app/[leaflet_id]/Leaflet.tsx @@ -9,7 +9,6 @@ } from "components/ThemeManager/ThemeProvider"; import { LeafletFooter } from "./Footer"; import { EntitySetProvider } from "components/EntitySetProvider"; -import { AddLeafletToHomepage } from "components/utils/AddLeafletToHomepage"; import { UpdateLeafletTitle } from "components/utils/UpdateLeafletTitle"; import { useUIState } from "src/useUIState"; import { LeafletLayout } from "components/LeafletLayout"; @@ -38,7 +37,6 @@ > - {/* we need the padding bottom here because if we don't have it the mobile footer will cut off... the dropshadow on the page... the padding is compensated by a negative top margin in mobile footer */} diff --git a/app/[leaflet_id]/Sidebar.tsx b/app/[leaflet_id]/Sidebar.tsx --- a/app/[leaflet_id]/Sidebar.tsx +++ b/app/[leaflet_id]/Sidebar.tsx @@ -2,7 +2,10 @@ import { Sidebar } from "components/ActionBar/Sidebar"; import { useEntitySetContext } from "components/EntitySetProvider"; import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; -import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; +import { + AddToHomeButton, + HomeButton, +} from "app/[leaflet_id]/actions/HomeButton"; import { Media } from "components/Media"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; @@ -13,12 +16,23 @@ import { BackToPubButton } from "./actions/BackToPubButton"; import { useIdentityData } from "components/IdentityProvider"; import { useReplicache } from "src/replicache"; +import useSWR from "swr"; +import { getHomeDocs } from "app/(home-pages)/(writer)/home/storage"; export function LeafletSidebar() { let entity_set = useEntitySetContext(); let { rootEntity } = useReplicache(); let { data: pub } = useLeafletPublicationData(); let { identity } = useIdentityData(); + let { permission_token } = useReplicache(); + let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { + fallbackData: [], + }); + let isOnHome = identity + ? !!identity.permission_token_on_homepage.find( + (pth) => pth.permission_tokens.id === permission_token.id, + ) + : !!localLeaflets.find((f) => f.token.id === permission_token.id); return ( @@ -28,8 +42,13 @@ >
{entity_set.permissions.write && ( - - + + {!isOnHome ? ( + + ) : ( + + )} + diff --git a/components/ActionBar/ActionButton.tsx b/components/ActionBar/ActionButton.tsx --- a/components/ActionBar/ActionButton.tsx +++ b/components/ActionBar/ActionButton.tsx @@ -15,11 +15,10 @@ label: React.ReactNode; primary?: boolean; secondary?: boolean; - nav?: boolean; + active?: boolean; className?: string; subtext?: string; labelOnMobile?: boolean; - smallOnMobile?: boolean; z?: boolean; } >((_props, ref) => { @@ -29,9 +28,8 @@ label, primary, secondary, - nav, + active, labelOnMobile, - smallOnMobile, subtext, className, ...buttonProps @@ -47,40 +45,46 @@ } }, [sidebar, inOpenPopover]); - let showLabelOnMobile = - labelOnMobile !== false && (primary || secondary || nav); + // let showLabelOnMobile = labelOnMobile !== false && (primary || secondary); return ( + } + > +
+

Welcome to Leaflet!

+
+ An expressive tool for publishing blogs and newsletters +
+ +
+
+ + Terms + + {/* + THIS SHOULD GO TO THE LANDING PAGE + + Learn More + + */} + + Contact + +
+
+ + ); +}; + +export const PageTitle = (props: { + pageTitle: string; + showBackButton?: boolean; +}) => { + return ( +
+ {props.showBackButton && ( + + + + )} +
+ {props.pageTitle} +
+ ); +}; + +export const DesktopNavigation = (props: NavigationProps) => { + return ( + + + ); }; diff --git a/components/ActionBar/MobileNavigation.tsx b/components/ActionBar/MobileNavigation.tsx --- a/components/ActionBar/MobileNavigation.tsx +++ b/components/ActionBar/MobileNavigation.tsx @@ -1,74 +1,169 @@ -import { useIdentityData } from "components/IdentityProvider"; +"use client"; +import { useEffect, useRef, useState } from "react"; + +import { MediaContents } from "components/Media"; import { Separator } from "components/Layout"; -import { - navPages, - NotificationButton, - ReaderButton, - WriterButton, -} from "./NavigationButtons"; -import { PublicationNavigation } from "./PublicationNavigation"; -import { LoginModal } from "components/LoginButton"; -import { ProfileButton } from "./ProfileButton"; -import { ActionButton } from "./ActionButton"; -import { AccountSmall } from "components/Icons/AccountSmall"; +import { MenuSmall } from "components/Icons/MenuSmall"; +import { CloseTiny } from "components/Icons/CloseTiny"; +import { useSidebarStore } from "./Sidebar"; +import { SearchTiny } from "components/Icons/SearchTiny"; +import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; +import { useIdentityData } from "components/IdentityProvider"; export const MobileNavigation = (props: { - currentPage: navPages; - currentPublicationUri?: string; - currentProfileDid?: string; + search?: React.ReactNode; + mobileActions?: React.ReactNode; + pageTitle: string; + hiddenOnScroll?: boolean; }) => { - let { identity } = useIdentityData(); + let [state, setState] = useState<"search" | "default">("default"); + let [hidden, setHidden] = useState(false); + let [distFromBottom, setDistFromBottom] = useState(1000); + let lastScrollY = useRef(0); + let cardBorderHidden = useCardBorderHidden(); + let hiddenOnScroll = props.hiddenOnScroll; - let compactOnMobile = - props.currentPage === "home" || - props.currentPage === "looseleafs" || - props.currentPage === "pub"; + useEffect(() => { + const homeContent = document.getElementById("home-content"); + if (!homeContent) return; + + const computeDist = () => + Math.max( + 0, + homeContent.scrollHeight - + homeContent.clientHeight - + homeContent.scrollTop, + ); + + setDistFromBottom(computeDist()); + + const handleScroll = () => { + const currentScrollY = homeContent.scrollTop; + const dist = computeDist(); + const delta = currentScrollY - lastScrollY.current; + lastScrollY.current = currentScrollY; + setDistFromBottom(dist); + + if (!hiddenOnScroll) { + setHidden(false); + return; + } + + if (dist <= 0) { + setHidden(false); + return; + } + + if (delta > 8) { + setHidden(true); + } else if (delta < -1) { + setHidden(false); + } + }; + + homeContent.addEventListener("scroll", handleScroll, { passive: true }); + return () => homeContent.removeEventListener("scroll", handleScroll); + }, [hiddenOnScroll]); + + let headerBGColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; + let atBottom = distFromBottom < 20; return ( -
-
- +
- + > + {state === "default" ? ( + <> + +
- {compactOnMobile && ( - <> - - - )} -
- {identity ? ( -
- {identity.atp_did && } - + {props.search && ( + + )} +
+ {props.mobileActions} +
+ + ) : ( + <> + {props.search} + + + + )}
- ) : ( - } - label="Log In/Sign Up" - /> - } - /> - )} -
+
+ + ); +}; + +const MobileSidebarTrigger = (props: { pageTitle: string }) => { + let setOpen = useSidebarStore((s) => s.setOpen); + let { identity } = useIdentityData(); + let unreads = identity?.notifications[0]?.count; + return ( + ); }; diff --git a/components/ActionBar/NavigationButtons.tsx b/components/ActionBar/NavigationButtons.tsx --- a/components/ActionBar/NavigationButtons.tsx +++ b/components/ActionBar/NavigationButtons.tsx @@ -1,14 +1,14 @@ +"use client"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { HomeSmall } from "components/Icons/HomeSmall"; import { ActionButton } from "./ActionButton"; import { useIdentityData } from "components/IdentityProvider"; -import { PublicationButtons } from "./Publications"; import { ReaderUnreadSmall } from "components/Icons/ReaderSmall"; import { NotificationsReadSmall, NotificationsUnreadSmall, } from "components/Icons/NotificationSmall"; import { SpeedyLink } from "components/SpeedyLink"; -import { Popover } from "components/Popover"; import { WriterSmall } from "components/Icons/WriterSmall"; export type navPages = | "home" @@ -20,82 +20,106 @@ | "profile" | "discover"; -export const HomeButton = (props: { - current?: boolean; - className?: string; -}) => { +function useIsActive(href: string) { + let pathname = usePathname(); + return pathname === href || pathname.startsWith(href + "/"); +} + +export const WRITER_PATHS = ["/home", "/looseleafs", "/notifications"] as const; + +export function useIsOnWriterPage() { + let pathname = usePathname(); + return WRITER_PATHS.some((p) => pathname.startsWith(p)); +} + +export const HomeButton = (props: { className?: string }) => { + let current = useIsActive("/home"); return ( } label="Home" - className={`${props.current ? "bg-bg-page! border-border-light!" : ""} w-full! ${props.className}`} + active={current} + className={`w-full! ${props.className}`} /> ); }; -export const WriterButton = (props: { - currentPage: navPages; - currentPubUri?: string; - compactOnMobile?: boolean; -}) => { - let current = - props.currentPage === "home" || - props.currentPage === "looseleafs" || - props.currentPage === "pub"; - +export const WriterButton = () => { + let current = useIsOnWriterPage(); return ( } label="Write" - className={`${current ? "bg-bg-page! border-border-light!" : ""}`} + active={current} /> ); }; -export const ReaderButton = (props: { - current?: boolean; - subs: boolean; - compactOnMobile?: boolean; -}) => { +export const ReaderButton = (props: { subs: boolean }) => { + let current = useIsActive("/reader"); return ( } label="Read" - className={props.current ? "bg-bg-page! border-border-light!" : ""} + active={current} /> ); }; -export function NotificationButton(props: { current?: boolean }) { +export function NotificationButton() { let { identity } = useIdentityData(); let unreads = identity?.notifications[0]?.count; + let pathname = usePathname(); + let searchParams = useSearchParams(); + let router = useRouter(); + + let isOnPage = + pathname === "/notifications" || pathname.startsWith("/notifications/"); + let isOpen = searchParams.get("notifications") === "open"; + let active = isOnPage || isOpen; + + function handleClick() { + if (isOnPage || isOpen) return; + let params = new URLSearchParams(searchParams.toString()); + params.set("notifications", "open"); + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + } + return ( - - - ) : ( - - ) - } - label="Notifications" - className={`${props.current ? "bg-bg-page! border-border-light!" : ""} ${unreads ? "text-accent-contrast!" : ""}`} - /> - + + ) : ( + + ) + } + label={ + unreads ? ( + + Notifications + + {unreads} + + + ) : ( + "Notifications" + ) + } + active={active} + className={unreads ? "text-accent-contrast! font-bold w-full!" : "w-full"} + /> ); } diff --git a/components/ActionBar/ProfileButton.tsx b/components/ActionBar/ProfileButton.tsx --- a/components/ActionBar/ProfileButton.tsx +++ b/components/ActionBar/ProfileButton.tsx @@ -1,3 +1,4 @@ +"use client"; import { Avatar } from "components/Avatar"; import { ActionButton } from "./ActionButton"; import { useIdentityData } from "components/IdentityProvider"; @@ -29,10 +30,9 @@ asChild side={isMobile ? "top" : "right"} align={isMobile ? "center" : "start"} - className="w-xs py-1!" + className="w-xs py-1! z-[60]!" trigger={ } > diff --git a/components/ActionBar/PublicationNavigation.tsx b/components/ActionBar/PublicationNavigation.tsx deleted file mode 100644 --- a/components/ActionBar/PublicationNavigation.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; -import { useIdentityData } from "components/IdentityProvider"; -import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL"; -import { - normalizePublicationRecord, - type NormalizedPublication, -} from "src/utils/normalizeRecords"; -import { SpeedyLink } from "components/SpeedyLink"; -import { Popover } from "components/Popover"; -import { ButtonPrimary } from "components/Buttons"; -import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; -import { HomeButton, type navPages } from "./NavigationButtons"; -import { HomeSmall } from "components/Icons/HomeSmall"; -import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; -import { PubIcon, PublicationButtons } from "./Publications"; -import { HomeTiny } from "components/Icons/HomeTiny"; -import { LooseleafTiny } from "components/Icons/LooseleafTiny"; -import { Separator } from "components/Layout"; -import { Menu, MenuItem } from "components/Menu"; -import { AddTiny } from "components/Icons/AddTiny"; - -export const PublicationNavigation = (props: { - currentPage: navPages; - currentPubUri?: string; -}) => { - let { identity } = useIdentityData(); - - if (!identity) return; - - let hasLooseleafs = !!identity?.permission_token_on_homepage.find( - (f) => - f.permission_tokens.leaflets_to_documents && - f.permission_tokens.leaflets_to_documents[0]?.document, - ); - - let pubCount = identity?.publications.length ?? 0; - let onlyOnePub = pubCount === 1 && hasLooseleafs; - let onlyLooseleafs = pubCount === 0 && hasLooseleafs; - let className = - "font-bold text-secondary flex gap-2 items-center grow min-w-0 text-sm h-[34px] px-2 accent-container"; - - // if not publications or looseleafs - if (!identity.publications && !hasLooseleafs) { - return ( - - - Create a Publication! - - - ); - } - - switch (props.currentPage) { - case "looseleafs": - case "pub": - if (onlyLooseleafs || onlyOnePub) - return ( - <> - - - Home - - - ); - break; - case "home": { - if (onlyLooseleafs || onlyOnePub) { - let pub = identity.publications[0]; - return ( -
- }> - - {}}> - - Create New Publication - - - - - {onlyLooseleafs ? ( - - Looseleafs - - ) : ( - - -
{pub.name}
-
- )} -
- ); - } - break; - } - } - - return ( - - ({ - record: normalizePublicationRecord(pub.record), - uri: pub.uri, - }))} - />{" "} - Publications -
- } - className="pt-1 px-2!" - > - -
- - - ); -}; - -function PubIcons(props: { - publications: { record: NormalizedPublication | null; uri: string }[]; -}) { - if (props.publications.length < 1) return null; - return ( -
- {props.publications.map((pub, index) => { - if (index <= 2) - return ( -
- -
- ); - })} -
- ); -} diff --git a/components/ActionBar/Publications.tsx b/components/ActionBar/Publications.tsx --- a/components/ActionBar/Publications.tsx +++ b/components/ActionBar/Publications.tsx @@ -12,78 +12,88 @@ type NormalizedPublication, } from "src/utils/normalizeRecords"; import { SpeedyLink } from "components/SpeedyLink"; -import { PublishSmall } from "components/Icons/PublishSmall"; -import { Popover } from "components/Popover"; import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; -import { useIsMobile } from "src/hooks/isMobile"; -import { useState } from "react"; import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; -import { type navPages } from "./NavigationButtons"; import { LoginModal } from "components/LoginButton"; +import useSWR from "swr"; +import { getHomeDocs } from "app/(home-pages)/(writer)/home/storage"; -export const PublicationButtons = (props: { - currentPage: navPages; - currentPubUri: string | undefined; - className?: string; - optionClassName?: string; -}) => { +export const PublicationButtons = (props: { className?: string }) => { let { identity } = useIdentityData(); let hasLooseleafs = !!identity?.permission_token_on_homepage.find( (f) => f.permission_tokens.leaflets_to_documents && f.permission_tokens.leaflets_to_documents[0]?.document, ); + let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { + fallbackData: [], + }); + let hasDocs = identity + ? identity.permission_token_on_homepage.length > 0 + : localLeaflets.filter((d) => !d.hidden).length > 0; - // don't show pub list button if not logged in or no pub list + // don't show pub list button if + // no pubs or looseleafs but has docs + // if they don't have docs, the empty state of the homepage prompts them to make publications // we show a "start a pub" banner instead - if (!identity || !identity.atp_did || identity.publications.length === 0) - return ; + console.log(hasDocs); + if ( + !hasLooseleafs && + hasDocs && + (!identity || identity.publications.length === 0) + ) + return ; + + if ( + !hasLooseleafs && + !hasDocs && + (!identity || identity.publications.length === 0) + ) + return null; return ( -
- {hasLooseleafs && ( - <> - - {/*TODO How should i get if this is the current page or not? - theres not "pub" to check the uri for. Do i need to add it as an option to NavPages? thats kinda annoying*/} - } - nav - className={`w-full! ${ - props.currentPage === "looseleafs" - ? "bg-bg-page! border-border!" - : "" - } - ${props.optionClassName}`} - /> - - - )} + <> +
- {identity.publications?.map((d) => { - return ( - - ); - })} - -
- New Publication - -
+
PUBLICATIONS
+ {hasLooseleafs && ( + <> + + {/*TODO How should i get if this is the current page or not? + theres not "pub" to check the uri for. Do i need to add it as an option to NavPages? thats kinda annoying*/} + } + /> + +
+ + )} + {identity?.publications?.map((d) => { + return ; + })} + + + + label="New Publication" + className="text-tertiary!" + /> + +
+ ); }; @@ -91,7 +101,6 @@ uri: string; name: string; record: Json; - current?: boolean; className?: string; }) => { let record = normalizePublicationRecord(props.record); @@ -100,55 +109,16 @@ return ( } - nav - className={`w-full! ${props.current ? "bg-bg-page! border-border!" : ""} ${props.className}`} + className={` ${props.className}`} /> ); -}; - -const PubListEmpty = () => { - let isMobile = useIsMobile(); - - let [state, setState] = useState<"default" | "info">("default"); - if (isMobile && state == "default") - return ( - } - nav - subtext="Start a blog on ATProto!" - onClick={() => { - setState("info"); - }} - /> - ); - - if (isMobile && state === "info") return ; - else - return ( - } - nav - subtext="Start a blog on ATProto!" - /> - } - > - - - ); }; export const PubListEmptyContent = (props: { compact?: boolean }) => { @@ -156,7 +126,7 @@ return (
@@ -185,7 +155,7 @@ noEmailLogin asChild trigger={ - + {identity ? "Link to" : "Log in with"} Atmosphere } diff --git a/components/ActionBar/Sidebar.tsx b/components/ActionBar/Sidebar.tsx --- a/components/ActionBar/Sidebar.tsx +++ b/components/ActionBar/Sidebar.tsx @@ -1,23 +1,34 @@ "use client"; -import { uv } from "colorjs.io/fn"; import { Media } from "components/Media"; +import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; import { createContext, useState } from "react"; +import { create } from "zustand"; export const SidebarContext = createContext({ open: false, setChildForceOpen: (b: boolean) => {}, }); +export const useSidebarStore = create<{ + open: boolean; + setOpen: (open: boolean) => void; +}>((set) => ({ + open: false, + setOpen: (open) => set({ open }), +})); + export function Sidebar(props: { children?: React.ReactNode; alwaysOpen?: boolean; + mobile?: boolean; className?: string; }) { let [sidebarExpanded, setSidebarExpanded] = useState(false); let [childForceOpen, setChildForceOpen] = useState(false); let open = sidebarExpanded || childForceOpen; + let cardBorderHidden = useCardBorderHidden(); return ( - +
{ diff --git a/components/Icons/AnalyticsSmall.tsx b/components/Icons/AnalyticsSmall.tsx new file mode 100644 --- /dev/null +++ b/components/Icons/AnalyticsSmall.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const AnalyticsSmall = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/Icons/GoToArrowLined.tsx b/components/Icons/GoToArrowLined.tsx new file mode 100644 --- /dev/null +++ b/components/Icons/GoToArrowLined.tsx @@ -0,0 +1,22 @@ +import { Props } from "./Props"; + +export const GoToArrowLined = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/Icons/LeafletTiny.tsx b/components/Icons/LeafletTiny.tsx new file mode 100644 --- /dev/null +++ b/components/Icons/LeafletTiny.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const LeafletTiny = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/Icons/MenuSmall.tsx b/components/Icons/MenuSmall.tsx --- a/components/Icons/MenuSmall.tsx +++ b/components/Icons/MenuSmall.tsx @@ -8,6 +8,7 @@ viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" + {...props} > { + return ( + + + + ); +}; diff --git a/components/Icons/SortSmall.tsx b/components/Icons/SortSmall.tsx --- a/components/Icons/SortSmall.tsx +++ b/components/Icons/SortSmall.tsx @@ -8,6 +8,7 @@ viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" + {...props} > { + return ( + + + + ); +}; diff --git a/components/Icons/TrendingSmall.tsx b/components/Icons/TrendingSmall.tsx new file mode 100644 --- /dev/null +++ b/components/Icons/TrendingSmall.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const TrendingSmall = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/PageLayouts/DashboardLayout.tsx b/components/PageLayouts/DashboardLayout.tsx deleted file mode 100644 --- a/components/PageLayouts/DashboardLayout.tsx +++ /dev/null @@ -1,580 +0,0 @@ -"use client"; -import { useState, createContext, useContext, useEffect } from "react"; -import { useSearchParams } from "next/navigation"; -import { Header } from "../PageHeader"; -import { Footer } from "components/ActionBar/Footer"; -import { Sidebar } from "components/ActionBar/Sidebar"; -import { DesktopNavigation } from "components/ActionBar/DesktopNavigation"; - -import { MobileNavigation } from "components/ActionBar/MobileNavigation"; -import { - navPages, - NotificationButton, -} from "components/ActionBar/NavigationButtons"; -import { create } from "zustand"; -import { Popover } from "components/Popover"; -import { Checkbox } from "components/Checkbox"; -import { Separator } from "components/Layout"; -import { CloseTiny } from "components/Icons/CloseTiny"; -import { MediaContents } from "components/Media"; -import { SortSmall } from "components/Icons/SortSmall"; -import { TabsSmall } from "components/Icons/TabsSmall"; -import { Input } from "components/Input"; -import { SearchTiny } from "components/Icons/SearchTiny"; -import { InterfaceState, useIdentityData } from "components/IdentityProvider"; -import { updateIdentityInterfaceState } from "actions/updateIdentityInterfaceState"; -import Link from "next/link"; -import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; -import { usePreserveScroll } from "src/hooks/usePreserveScroll"; -import { Tab } from "components/Tab"; -import { PubIcon, PublicationButtons } from "components/ActionBar/Publications"; - -export type DashboardState = { - display?: "grid" | "list"; - sort?: "created" | "alphabetical"; - filter: { - drafts: boolean; - published: boolean; - docs: boolean; - archived: boolean; - }; - subscriberStatus: { - unconfirmed: boolean; - subscribed: boolean; - unsubscribed: boolean; - }; -}; - -type DashboardStore = { - dashboards: { [id: string]: DashboardState }; - setDashboard: (id: string, partial: Partial) => void; -}; - -const defaultDashboardState: DashboardState = { - display: undefined, - sort: undefined, - filter: { - drafts: false, - published: false, - docs: false, - archived: false, - }, - subscriberStatus: { - unconfirmed: false, - subscribed: true, - unsubscribed: false, - }, -}; - -// Existing identities have stored interface_state without newer fields -// (e.g. subscriberStatus). Merge so callers always see a complete shape. -function withDefaults(stored: DashboardState | undefined): DashboardState { - if (!stored) return defaultDashboardState; - return { - ...defaultDashboardState, - ...stored, - filter: { ...defaultDashboardState.filter, ...stored.filter }, - subscriberStatus: { - ...defaultDashboardState.subscriberStatus, - ...stored.subscriberStatus, - }, - }; -} - -export const useDashboardStore = create((set, get) => ({ - dashboards: {}, - setDashboard: (id: string, partial: Partial) => { - set((state) => ({ - dashboards: { - ...state.dashboards, - [id]: { - ...(state.dashboards[id] || defaultDashboardState), - ...partial, - }, - }, - })); - }, -})); - -export const DashboardIdContext = createContext(null); - -export const useDashboardId = () => { - const id = useContext(DashboardIdContext); - if (!id) { - throw new Error("useDashboardId must be used within a DashboardLayout"); - } - return id; -}; - -export const useDashboardState = () => { - const id = useDashboardId(); - let { identity } = useIdentityData(); - let localState = useDashboardStore((state) => state.dashboards[id]); - if (!identity) return withDefaults(localState); - let metadata = identity.interface_state as InterfaceState; - return withDefaults(metadata?.dashboards?.[id]); -}; - -export const useSetDashboardState = () => { - const id = useDashboardId(); - let { identity, mutate } = useIdentityData(); - const setDashboard = useDashboardStore((state) => state.setDashboard); - return async (partial: Partial) => { - if (!identity) return setDashboard(id, partial); - - let interface_state = (identity.interface_state as InterfaceState) || {}; - let newDashboardState = { - ...defaultDashboardState, - ...interface_state.dashboards?.[id], - ...partial, - }; - mutate( - { - ...identity, - interface_state: { - ...interface_state, - dashboards: { - ...interface_state.dashboards, - [id]: newDashboardState, - }, - }, - }, - { revalidate: false }, - ); - await updateIdentityInterfaceState({ - ...interface_state, - dashboards: { - [id]: newDashboardState, - }, - }); - }; -}; - -export function DashboardLayout< - T extends { - [name: string]: { - content: React.ReactNode; - controls: React.ReactNode; - icon?: React.ReactNode; - }; - }, ->(props: { - id: string; - tabs: T; - defaultTab: keyof T; - currentPage: navPages; - publication?: string; - profileDid?: string; - actions?: React.ReactNode; - pageTitle?: string; - onTabHover?: (tabName: string) => void; -}) { - const searchParams = useSearchParams(); - const tabParam = searchParams.get("tab"); - - // Initialize tab from search param if valid, otherwise use default - const initialTab = - tabParam && props.tabs[tabParam] ? tabParam : props.defaultTab; - let [tab, setTab] = useState(initialTab); - - // Custom setter that updates both state and URL - const setTabWithUrl = (newTab: keyof T) => { - setTab(newTab); - const params = new URLSearchParams(searchParams.toString()); - params.set("tab", newTab as string); - const newUrl = `${window.location.pathname}?${params.toString()}`; - window.history.replaceState(null, "", newUrl); - }; - - let { content, controls } = props.tabs[tab]; - let { ref } = usePreserveScroll( - `dashboard-${props.id}-${tab as string}`, - ); - - let [headerState, setHeaderState] = useState<"default" | "controls">( - "default", - ); - - return ( - -
- -
- - {props.actions && {props.actions}} -
-
-
- {props.pageTitle && ( - - )} - - {Object.keys(props.tabs).length <= 1 && !controls ? null : ( - <> -
- {headerState === "default" ? ( - <> - {Object.keys(props.tabs).length > 1 && ( -
- {Object.keys(props.tabs).map((t) => { - return ( - setTabWithUrl(t)} - onMouseEnter={() => props.onTabHover?.(t)} - onPointerDown={() => props.onTabHover?.(t)} - /> - ); - })} -
- )} - {props.publication && controls && ( - - )} -
- {controls} -
- - ) : ( - <> - {controls} - - - )} -
- - )} - {content} -
-
- -
-
-
- ); -} - -export const PageTitle = (props: { - pageTitle: string; - actions: React.ReactNode; -}) => { - return ( - -

{props.pageTitle}

-
{props.actions}
- {/*
{props.controls}
*/} -
- ); -}; - -export const HomeDashboardControls = (props: { - searchValue: string; - setSearchValueAction: (searchValue: string) => void; - hasBackgroundImage: boolean; - defaultDisplay: Exclude; - hasPubs: boolean; - hasArchived: boolean; -}) => { - let { display, sort } = useDashboardState(); - display = display || props.defaultDisplay; - let setState = useSetDashboardState(); - - let { identity } = useIdentityData(); - - return ( -
- {identity && ( - - )} -
- - - - {props.hasPubs || props.hasArchived ? ( - <> - - {" "} - - ) : null} - -
-
- ); -}; - -export const PublicationDashboardControls = (props: { - searchValue: string; - setSearchValueAction: (searchValue: string) => void; - hasBackgroundImage: boolean; - defaultDisplay: Exclude; -}) => { - let { display, sort } = useDashboardState(); - display = display || props.defaultDisplay; - let setState = useSetDashboardState(); - return ( -
- -
- - - -
-
- ); -}; - -const SortToggle = (props: { - setState: (partial: Partial) => Promise; - sort: string | undefined; -}) => { - return ( - - ); -}; - -const DisplayToggle = (props: { - setState: (partial: Partial) => Promise; - display: string | undefined; -}) => { - return ( - - ); -}; - -const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => { - let { filter } = useDashboardState(); - let setState = useSetDashboardState(); - let filterCount = Object.values(filter).filter(Boolean).length; - - return ( - Filter {filterCount > 0 && `(${filterCount})`}
} - > - {props.hasPubs && ( - <> - - setState({ - filter: { ...filter, drafts: !!e.target.checked }, - }) - } - > - Drafts - - - setState({ - filter: { ...filter, published: !!e.target.checked }, - }) - } - > - Published - - - )} - - {props.hasArchived && ( - - setState({ - filter: { ...filter, archived: !!e.target.checked }, - }) - } - > - Archived - - )} - - setState({ - filter: { ...filter, docs: !!e.target.checked }, - }) - } - > - Docs - -
- - - ); -}; - -export const SubscriberStatusFilter = () => { - let { subscriberStatus } = useDashboardState(); - let setState = useSetDashboardState(); - let count = Object.values(subscriberStatus).filter(Boolean).length; - - return ( - Status {count > 0 && `(${count})`}
} - > - - setState({ - subscriberStatus: { - ...subscriberStatus, - subscribed: !!e.target.checked, - }, - }) - } - > - Subscribed - - - setState({ - subscriberStatus: { - ...subscriberStatus, - unconfirmed: !!e.target.checked, - }, - }) - } - > - Unconfirmed - - - setState({ - subscriberStatus: { - ...subscriberStatus, - unsubscribed: !!e.target.checked, - }, - }) - } - > - Unsubscribed - - - ); -}; - -export const SubscriberDashboardControls = () => { - return ( -
-
- -
-
- ); -}; - -const SearchInput = (props: { - searchValue: string; - setSearchValue: (searchValue: string) => void; - hasBackgroundImage: boolean; -}) => { - return ( -
- { - props.setSearchValue(e.currentTarget.value); - }} - /> -
- -
-
- ); -}; diff --git a/components/PageLayouts/DashboardPageLayout.tsx b/components/PageLayouts/DashboardPageLayout.tsx new file mode 100644 --- /dev/null +++ b/components/PageLayouts/DashboardPageLayout.tsx @@ -0,0 +1,39 @@ +"use client"; +import { PageHeader } from "components/PageHeader"; +import { MobileNavigation } from "../ActionBar/MobileNavigation"; +import { usePreserveScroll } from "src/hooks/usePreserveScroll"; + +export function DashboardPageLayout(props: { + scrollKey: string; + pageTitle: string; + mobileActions?: React.ReactNode; + search?: React.ReactNode; + publication?: string; + showHeader?: boolean; + children: React.ReactNode; +}) { + let { ref } = usePreserveScroll(props.scrollKey); + + return ( +
+ {props.showHeader && ( + +
+ {props.search} +
+
+ )} + {props.children} + + +
+ ); +} diff --git a/components/PageLayouts/DashboardShell.tsx b/components/PageLayouts/DashboardShell.tsx new file mode 100644 --- /dev/null +++ b/components/PageLayouts/DashboardShell.tsx @@ -0,0 +1,61 @@ +"use client"; +import * as Dialog from "@radix-ui/react-dialog"; +import { useSearchParams } from "next/navigation"; +import { + DesktopNavigation, + NavigationContent, +} from "components/ActionBar/DesktopNavigation"; +import { Sidebar, useSidebarStore } from "components/ActionBar/Sidebar"; +import { DashboardIdContext } from "./dashboardState"; +import { NotificationContent } from "./NotificationContent"; + +export type DashboardShellProps = { + id: string; + pageTitle: React.ReactNode; + publication?: string; + actions?: React.ReactNode; + tabs?: { [name: string]: { href: string; icon?: React.ReactNode } }; + children: React.ReactNode; +}; + +export function DashboardShell(props: DashboardShellProps) { + let { id, children, ...navigationProps } = props; + let { open, setOpen } = useSidebarStore(); + let searchParams = useSearchParams(); + let showNotifications = searchParams.get("notifications") === "open"; + + return ( + +
+ + + + + { + const target = e.target as HTMLElement; + const interactive = target.closest("a, button"); + if (!interactive) return; + if (interactive.getAttribute("aria-haspopup")) return; + setOpen(false); + }} + > + Navigation + + + + + + + {showNotifications ? : children} +
+
+ ); +} diff --git a/components/PageLayouts/NotificationContent.tsx b/components/PageLayouts/NotificationContent.tsx new file mode 100644 --- /dev/null +++ b/components/PageLayouts/NotificationContent.tsx @@ -0,0 +1,32 @@ +"use client"; +import { useEffect, useState } from "react"; +import { DashboardPageLayout } from "./DashboardPageLayout"; +import { NotificationList } from "app/(home-pages)/(writer)/notifications/NotificationList"; +import { getNotifications } from "app/(home-pages)/(writer)/notifications/getNotifications"; +import { HydratedNotification } from "src/notifications"; + +export function NotificationContent() { + let [notifications, setNotifications] = useState< + HydratedNotification[] | null + >(null); + + useEffect(() => { + getNotifications().then(setNotifications); + }, []); + + return ( + + {notifications === null ? ( +
+
+
+ ) : ( + + )} + + ); +} diff --git a/components/PageLayouts/PageSearch.tsx b/components/PageLayouts/PageSearch.tsx new file mode 100644 --- /dev/null +++ b/components/PageLayouts/PageSearch.tsx @@ -0,0 +1,333 @@ +"use client"; +import { Popover } from "components/Popover"; +import { Checkbox } from "components/Checkbox"; +import { Separator } from "components/Layout"; +import { CloseTiny } from "components/Icons/CloseTiny"; +import { Input } from "components/Input"; +import { SearchTiny } from "components/Icons/SearchTiny"; +import { SortSmall } from "components/Icons/SortSmall"; +import { + DashboardState, + useDashboardState, + useSetDashboardState, +} from "./dashboardState"; + +export const PageSearch = (props: { + searchValue: string; + setSearchValueAction: (searchValue: string) => void; + hasBackgroundImage: boolean; + defaultDisplay: Exclude; + hasPubs?: boolean; + hasArchived?: boolean; +}) => { + let { display, sort } = useDashboardState(); + display = display || props.defaultDisplay; + let setState = useSetDashboardState(); + + return ( +
+ +
+ + + {props.hasPubs || props.hasArchived ? ( + <> + + {" "} + + ) : null} + +
+
+ +
+
+ ); +}; + +const SortToggle = (props: { + setState: (partial: Partial) => Promise; + sort: string | undefined; +}) => { + return ( + + ); +}; + +const DisplayToggle = (props: { + setState: (partial: Partial) => Promise; + display: string | undefined; +}) => { + return ( + + ); +}; + +const FilterCheckboxes = (props: { + hasPubs: boolean; + hasArchived: boolean; +}) => { + let { filter } = useDashboardState(); + let setState = useSetDashboardState(); + let filterCount = Object.values(filter).filter(Boolean).length; + + return ( + <> + {props.hasPubs && ( + <> + + setState({ filter: { ...filter, drafts: !!e.target.checked } }) + } + > + Drafts + + + setState({ + filter: { ...filter, published: !!e.target.checked }, + }) + } + > + Published + + + )} + {props.hasArchived && ( + + setState({ filter: { ...filter, archived: !!e.target.checked } }) + } + > + Archived + + )} + + setState({ filter: { ...filter, docs: !!e.target.checked } }) + } + > + Docs + + +
+ + + ); +}; + +export const SubscriberStatusFilter = () => { + let { subscriberStatus } = useDashboardState(); + let setState = useSetDashboardState(); + let count = Object.values(subscriberStatus).filter(Boolean).length; + + return ( + Status {count > 0 && `(${count})`}
} + > + + setState({ + subscriberStatus: { + ...subscriberStatus, + subscribed: !!e.target.checked, + }, + }) + } + > + Subscribed + + + setState({ + subscriberStatus: { + ...subscriberStatus, + unconfirmed: !!e.target.checked, + }, + }) + } + > + Unconfirmed + + + setState({ + subscriberStatus: { + ...subscriberStatus, + unsubscribed: !!e.target.checked, + }, + }) + } + > + Unsubscribed + + + ); +}; + +const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => { + let { filter } = useDashboardState(); + let filterCount = Object.values(filter).filter(Boolean).length; + + return ( + Filter {filterCount > 0 && `(${filterCount})`}
} + > + + + ); +}; + +const MobileSearchControls = (props: { + display: string; + sort: string | undefined; + setState: (partial: Partial) => Promise; + hasPubs?: boolean; + hasArchived?: boolean; +}) => { + let { filter } = useDashboardState(); + let filterCount = Object.values(filter).filter(Boolean).length; + + return ( + 0 ? "text-accent-2 bg-accent-1 rounded-md" : "text-secondary"}`} + > + +
+ } + > + +
+ + {(props.hasPubs || props.hasArchived) && ( + <> +
+
+ Filter +
+ + + )} + + ); +}; + +const SearchInput = (props: { + searchValue: string; + setSearchValue: (searchValue: string) => void; + hasBackgroundImage: boolean; +}) => { + return ( +
+ { + props.setSearchValue(e.currentTarget.value); + }} + /> +
+ +
+
+ ); +}; diff --git a/components/PageLayouts/dashboardState.ts b/components/PageLayouts/dashboardState.ts new file mode 100644 --- /dev/null +++ b/components/PageLayouts/dashboardState.ts @@ -0,0 +1,126 @@ +"use client"; +import { createContext, useContext } from "react"; +import { create } from "zustand"; +import { InterfaceState, useIdentityData } from "components/IdentityProvider"; +import { updateIdentityInterfaceState } from "actions/updateIdentityInterfaceState"; + +export type DashboardState = { + display?: "grid" | "list"; + sort?: "created" | "alphabetical"; + filter: { + drafts: boolean; + published: boolean; + docs: boolean; + archived: boolean; + }; + subscriberStatus: { + unconfirmed: boolean; + subscribed: boolean; + unsubscribed: boolean; + }; +}; + +type DashboardStore = { + dashboards: { [id: string]: DashboardState }; + setDashboard: (id: string, partial: Partial) => void; +}; + +const defaultDashboardState: DashboardState = { + display: undefined, + sort: undefined, + filter: { + drafts: false, + published: false, + docs: false, + archived: false, + }, + subscriberStatus: { + unconfirmed: false, + subscribed: true, + unsubscribed: false, + }, +}; + +// Existing identities have stored interface_state without newer fields +// (e.g. subscriberStatus). Merge so callers always see a complete shape. +function withDefaults(stored: DashboardState | undefined): DashboardState { + if (!stored) return defaultDashboardState; + return { + ...defaultDashboardState, + ...stored, + filter: { ...defaultDashboardState.filter, ...stored.filter }, + subscriberStatus: { + ...defaultDashboardState.subscriberStatus, + ...stored.subscriberStatus, + }, + }; +} + +export const useDashboardStore = create((set) => ({ + dashboards: {}, + setDashboard: (id: string, partial: Partial) => { + set((state) => ({ + dashboards: { + ...state.dashboards, + [id]: { + ...(state.dashboards[id] || defaultDashboardState), + ...partial, + }, + }, + })); + }, +})); + +export const DashboardIdContext = createContext(null); + +export const useDashboardId = () => { + const id = useContext(DashboardIdContext); + if (!id) { + throw new Error("useDashboardId must be used within a DashboardLayout"); + } + return id; +}; + +export const useDashboardState = () => { + const id = useDashboardId(); + let { identity } = useIdentityData(); + let localState = useDashboardStore((state) => state.dashboards[id]); + if (!identity) return withDefaults(localState); + let metadata = identity.interface_state as InterfaceState; + return withDefaults(metadata?.dashboards?.[id]); +}; + +export const useSetDashboardState = () => { + const id = useDashboardId(); + let { identity, mutate } = useIdentityData(); + const setDashboard = useDashboardStore((state) => state.setDashboard); + return async (partial: Partial) => { + if (!identity) return setDashboard(id, partial); + + let interface_state = (identity.interface_state as InterfaceState) || {}; + let newDashboardState = { + ...defaultDashboardState, + ...interface_state.dashboards?.[id], + ...partial, + }; + mutate( + { + ...identity, + interface_state: { + ...interface_state, + dashboards: { + ...interface_state.dashboards, + [id]: newDashboardState, + }, + }, + }, + { revalidate: false }, + ); + await updateIdentityInterfaceState({ + ...interface_state, + dashboards: { + [id]: newDashboardState, + }, + }); + }; +}; diff --git a/components/Popover/index.tsx b/components/Popover/index.tsx --- a/components/Popover/index.tsx +++ b/components/Popover/index.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import { PopoverArrow } from "../Icons/PopoverArrow"; import { PopoverOpenContext } from "./PopoverContext"; +import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; export const Popover = (props: { trigger: React.ReactNode; disabled?: boolean; @@ -28,6 +29,7 @@ useEffect(() => { if (props.open !== undefined) setOpen(props.open); }, [props.open]); + let cardBorderHidden = useCardBorderHidden(); return ( - + {props.trigger} { return ( -
+
The Atmosphere is a growing ecosystem of social apps, like Leaflet and Bluesky.
-
+
One account gets you into all of them.
diff --git a/components/ThemeManager/ThemeSetter.tsx b/components/ThemeManager/ThemeSetter.tsx --- a/components/ThemeManager/ThemeSetter.tsx +++ b/components/ThemeManager/ThemeSetter.tsx @@ -50,25 +50,10 @@ }); } export const ThemePopover = (props: { entityID: string; home?: boolean }) => { - let { rep } = useReplicache(); let { data: pub } = useLeafletPublicationData(); let isMobile = useIsMobile(); - // I need to get these variables from replicache and then write them to the DB. I also need to parse them into a state that can be used here. let permission = useEntitySetContext().permissions.write; - let leafletBGImage = useEntity(props.entityID, "theme/background-image"); - let leafletBGRepeat = useEntity( - props.entityID, - "theme/background-image-repeat", - ); - - let [openPicker, setOpenPicker] = useState( - props.home === true ? "leaflet" : "null", - ); - let set = useMemo(() => { - return setColorAttribute(rep, props.entityID); - }, [rep, props.entityID]); - if (!permission) return null; if (pub?.publications) return null; @@ -80,7 +65,13 @@ asChild side={isMobile ? "top" : "right"} align={isMobile ? "center" : "start"} - trigger={} label="Theme" />} + trigger={ + } + label="Theme" + className="sm:w-full! w-fit!" + /> + } > @@ -260,7 +251,8 @@ ?.data.value; // Read font values directly since the popover is portalled outside .leafletWrapper - let headingFontId = useEntity(props.entityID, "theme/heading-font")?.data.value; + let headingFontId = useEntity(props.entityID, "theme/heading-font")?.data + .value; let bodyFontId = useEntity(props.entityID, "theme/body-font")?.data.value; let bodyFontFamily = getFontFamilyValue(getFontConfig(bodyFontId)); let headingFontFamily = getFontFamilyValue(getFontConfig(headingFontId)); diff --git a/components/utils/AddLeafletToHomepage.tsx b/components/utils/AddLeafletToHomepage.tsx deleted file mode 100644 --- a/components/utils/AddLeafletToHomepage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import { addDocToHome } from "app/(home-pages)/home/storage"; -import { useIdentityData } from "components/IdentityProvider"; -import { useEffect } from "react"; -import { useReplicache } from "src/replicache"; - -export function AddLeafletToHomepage() { - let { permission_token } = useReplicache(); - let { identity } = useIdentityData(); - useEffect(() => { - if (identity) return; - if (permission_token.permission_token_rights[0].write) { - try { - addDocToHome(permission_token); - } catch (e) {} - } - }, [permission_token, identity]); - return null; -} diff --git a/components/utils/DotLoader.tsx b/components/utils/DotLoader.tsx --- a/components/utils/DotLoader.tsx +++ b/components/utils/DotLoader.tsx @@ -1,3 +1,4 @@ +"use client"; import { useEffect, useState } from "react"; export function DotLoader(props: { className?: string }) { diff --git a/src/hooks/isMobile.ts b/src/hooks/isMobile.ts --- a/src/hooks/isMobile.ts +++ b/src/hooks/isMobile.ts @@ -1,3 +1,4 @@ +"use client"; import { useState, useEffect } from "react"; export function useIsMobile() { diff --git a/app/(home-pages)/(writer)/WriterShell.tsx b/app/(home-pages)/(writer)/WriterShell.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/(writer)/WriterShell.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { DashboardShell } from "components/PageLayouts/DashboardShell"; +import { PageTitle } from "components/ActionBar/DesktopNavigation"; +import { HomeSmall } from "components/Icons/HomeSmall"; +import { LooseleafTiny } from "components/Icons/LooseleafTiny"; +import { NotificationsUnreadSmall } from "components/Icons/NotificationSmall"; +import { Actions } from "./home/Actions/Actions"; + +const PAGE_META = [ + { + prefix: "/home", + id: "home", + title: "Home", + }, + { + prefix: "/looseleafs", + id: "looseleafs", + title: "Looseleafs", + showBackButton: true, + }, + { + prefix: "/notifications", + id: "notifications", + title: "Notifications", + }, +]; + +export function WriterShell(props: { children: React.ReactNode }) { + const pathname = usePathname(); + const meta = PAGE_META.find((m) => pathname.startsWith(m.prefix)); + + return ( + + ) + } + actions={} + > + {props.children} + + ); +} diff --git a/app/(home-pages)/(writer)/layout.tsx b/app/(home-pages)/(writer)/layout.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/(writer)/layout.tsx @@ -0,0 +1,5 @@ +import { WriterShell } from "./WriterShell"; + +export default function WriterLayout(props: { children: React.ReactNode }) { + return {props.children}; +} diff --git a/app/(home-pages)/home/HomeLayout.tsx b/app/(home-pages)/home/HomeLayout.tsx deleted file mode 100644 --- a/app/(home-pages)/home/HomeLayout.tsx +++ /dev/null @@ -1,309 +0,0 @@ -"use client"; - -import { getHomeDocs } 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 { - HomeDashboardControls, - DashboardLayout, - DashboardState, - useDashboardState, -} from "components/PageLayouts/DashboardLayout"; -import { Actions } from "./Actions/Actions"; -import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; -import { useState } from "react"; -import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; -import { HomeEmptyState } from "./HomeEmpty/HomeEmpty"; - -export type Leaflet = { - added_at: string; - archived?: boolean | null; - token: PermissionToken & { - leaflets_in_publications?: Exclude< - GetLeafletDataReturnType["result"]["data"], - null - >["leaflets_in_publications"]; - leaflets_to_documents?: Exclude< - GetLeafletDataReturnType["result"]["data"], - null - >["leaflets_to_documents"]; - }; -}; - -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 [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 hasArchived = - identity && - identity.permission_token_on_homepage.filter( - (leaflet) => leaflet.archived === true, - ).length > 0; - - return ( - } - tabs={{ - home: { - controls: ( - - ), - content: ( - - ), - }, - }} - pageTitle={"Home"} - /> - ); -}; - -export function HomeLeafletList(props: { - titles: { [root_entity: string]: string }; - initialFacts: { - [root_entity: string]: Fact[]; - }; - searchValue: string; -}) { - 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 || - tok.permission_tokens.leaflets_to_documents[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 }, - revalidateOnFocus: false, - revalidateOnReconnect: false, - }, - ); - - 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, - archived: ptoh.archived, - })) - : localLeaflets - .sort((a, b) => (a.added_at > b.added_at ? -1 : 1)) - .filter((d) => !d.hidden) - .map((ll) => ll); - - return leaflets.length === 0 ? ( - - ) : ( - <> - -
- - ); -} - -export function LeafletList(props: { - leaflets: Leaflet[]; - titles: { [root_entity: string]: string }; - defaultDisplay: Exclude; - initialFacts: { - [root_entity: string]: Fact[]; - }; - searchValue: string; - 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, archived }, index) => ( - - - 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") { - let titleA = titles[a.token.root_entity] ?? "Untitled"; - let titleB = titles[b.token.root_entity] ?? "Untitled"; - - if (titleA === titleB) { - return a.added_at > b.added_at ? -1 : 1; - } else { - return titleA.toLocaleLowerCase() > titleB.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 filteredLeaflets = sortedLeaflets.filter( - ({ token: leaflet, archived: archived }) => { - let published = - !!leaflet.leaflets_in_publications?.find((l) => l.doc) || - !!leaflet.leaflets_to_documents?.find((l) => l.document); - let drafts = !!leaflet.leaflets_in_publications?.length && !published; - let docs = !leaflet.leaflets_in_publications?.length && !archived; - - // If no filters are active, show everything that is not archived - if ( - !filter.drafts && - !filter.published && - !filter.docs && - !filter.archived - ) - return archived === false || archived === null || archived == undefined; - - //if a filter is on, return itemsd of that filter that are also NOT archived - return ( - (filter.drafts && drafts && !archived) || - (filter.published && published && !archived) || - (filter.docs && docs && !archived) || - (filter.archived && archived) - ); - }, - ); - 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 deleted file mode 100644 --- a/app/(home-pages)/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-pages)/home/icon.tsx b/app/(home-pages)/home/icon.tsx deleted file mode 100644 --- a/app/(home-pages)/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-pages)/home/page.tsx b/app/(home-pages)/home/page.tsx deleted file mode 100644 --- a/app/(home-pages)/home/page.tsx +++ /dev/null @@ -1,44 +0,0 @@ -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 || - tok.permission_tokens.leaflets_to_documents[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 deleted file mode 100644 --- a/app/(home-pages)/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/(home-pages)/looseleafs/LooseleafsLayout.tsx b/app/(home-pages)/looseleafs/LooseleafsLayout.tsx deleted file mode 100644 --- a/app/(home-pages)/looseleafs/LooseleafsLayout.tsx +++ /dev/null @@ -1,111 +0,0 @@ -"use client"; -import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; -import { useState } from "react"; -import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; -import { Fact, PermissionToken } from "src/replicache"; -import { Attribute } from "src/replicache/attributes"; -import { Actions } from "../home/Actions/Actions"; -import { callRPC } from "app/api/rpc/client"; -import { useIdentityData } from "components/IdentityProvider"; -import useSWR from "swr"; -import { getHomeDocs } from "../home/storage"; -import { Leaflet, LeafletList } from "../home/HomeLayout"; - -export const LooseleafsLayout = (props: { - entityID: string | null; - titles: { [root_entity: string]: string }; - initialFacts: { - [root_entity: string]: Fact[]; - }; -}) => { - let [searchValue, setSearchValue] = useState(""); - let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); - - useDebouncedEffect( - () => { - setDebouncedSearchValue(searchValue); - }, - 200, - [searchValue], - ); - - return ( - } - tabs={{ - home: { - controls: null, - content: ( - - ), - }, - }} - pageTitle="Looseleafs" - /> - ); -}; - -export const LooseleafList = (props: { - titles: { [root_entity: string]: string }; - initialFacts: { - [root_entity: string]: Fact[]; - }; - searchValue: string; -}) => { - 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 || - tok.permission_tokens.leaflets_to_documents[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 leaflets: Leaflet[] = identity - ? identity.permission_token_on_homepage - .filter( - (ptoh) => ptoh.permission_tokens.leaflets_to_documents.length > 0, - ) - .map((ptoh) => ({ - added_at: ptoh.created_at, - token: ptoh.permission_tokens as PermissionToken, - })) - : []; - return ( - - ); -}; diff --git a/app/(home-pages)/looseleafs/page.tsx b/app/(home-pages)/looseleafs/page.tsx deleted file mode 100644 --- a/app/(home-pages)/looseleafs/page.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { getIdentityData } from "actions/getIdentityData"; -import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; -import { Actions } from "../home/Actions/Actions"; -import { Fact } from "src/replicache"; -import { Attribute } from "src/replicache/attributes"; -import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; -import { supabaseServerClient } from "supabase/serverClient"; -import { LooseleafsLayout } from "./LooseleafsLayout"; - -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 || - tok.permission_tokens.leaflets_to_documents[0]?.title; - if (title) acc[tok.permission_tokens.root_entity] = title; - return acc; - }, - {} as { [k: string]: string }, - ), - }} - initialFacts={home_docs_initialFacts.facts || {}} - /> - ); -} diff --git a/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx b/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx deleted file mode 100644 --- a/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { BlueskyTiny } from "components/Icons/BlueskyTiny"; -import { ContentLayout, Notification } from "./Notification"; -import { HydratedBskyPostEmbedNotification } from "src/notifications"; -import { AtUri } from "@atproto/api"; -import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; - -export const BskyPostEmbedNotification = ( - props: HydratedBskyPostEmbedNotification, -) => { - const docRecord = props.normalizedDocument; - const pubRecord = props.normalizedPublication; - - if (!docRecord) return null; - - const href = getDocumentURL(docRecord, props.document.uri, pubRecord); - - const embedder = props.documentCreatorHandle - ? `@${props.documentCreatorHandle}` - : "Someone"; - - return ( - } - actionText={<>{embedder} embedded your Bluesky post} - content={ - - {props.bskyPostText && ( -
-              {props.bskyPostText}
-            
- )} -
- } - /> - ); -}; diff --git a/app/(home-pages)/notifications/CommentMentionNotification.tsx b/app/(home-pages)/notifications/CommentMentionNotification.tsx deleted file mode 100644 --- a/app/(home-pages)/notifications/CommentMentionNotification.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; -import { HydratedCommentMentionNotification } from "src/notifications"; -import { blobRefToSrc } from "src/utils/blobRefToSrc"; -import { MentionTiny } from "components/Icons/MentionTiny"; -import { - CommentInNotification, - ContentLayout, - Notification, -} from "./Notification"; -import { AtUri } from "@atproto/api"; -import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; - -export const CommentMentionNotification = ( - props: HydratedCommentMentionNotification, -) => { - const docRecord = props.normalizedDocument; - if (!docRecord) return null; - - const commentRecord = props.commentData.record as PubLeafletComment.Record; - const profileRecord = props.commentData.bsky_profiles - ?.record as AppBskyActorProfile.Record; - const pubRecord = props.normalizedPublication; - - const href = - getDocumentURL(docRecord, props.commentData.documents?.uri!, pubRecord) + - "?interactionDrawer=comments"; - - const commenter = props.commenterHandle - ? `@${props.commenterHandle}` - : "Someone"; - - let actionText: React.ReactNode; - const mentionedDocRecord = props.normalizedMentionedDocument; - - if (props.mention_type === "did") { - actionText = <>{commenter} mentioned you in a comment; - } else if ( - props.mention_type === "publication" && - props.mentionedPublication - ) { - const mentionedPubRecord = props.normalizedMentionedPublication; - actionText = ( - <> - {commenter} mentioned your publication{" "} - {mentionedPubRecord?.name} in a comment - - ); - } else if (props.mention_type === "document" && mentionedDocRecord) { - actionText = ( - <> - {commenter} mentioned your post{" "} - {mentionedDocRecord.title} in a comment - - ); - } else { - actionText = <>{commenter} mentioned you in a comment; - } - - return ( - } - actionText={actionText} - content={ - - - - } - /> - ); -}; diff --git a/app/(home-pages)/notifications/CommentNotication.tsx b/app/(home-pages)/notifications/CommentNotication.tsx deleted file mode 100644 --- a/app/(home-pages)/notifications/CommentNotication.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; -import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; -import { HydratedCommentNotification } from "src/notifications"; -import { blobRefToSrc } from "src/utils/blobRefToSrc"; -import { Avatar } from "components/Avatar"; -import { CommentTiny } from "components/Icons/CommentTiny"; -import { - CommentInNotification, - ContentLayout, - Notification, -} from "./Notification"; -import { AtUri } from "@atproto/api"; -import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; - -export const CommentNotification = (props: HydratedCommentNotification) => { - const docRecord = props.normalizedDocument; - const commentRecord = props.commentData.record as PubLeafletComment.Record; - const profileRecord = props.commentData.bsky_profiles - ?.record as AppBskyActorProfile.Record; - - if (!docRecord) return null; - - const displayName = - profileRecord?.displayName || - props.commentData.bsky_profiles?.handle || - "Someone"; - const pubRecord = props.normalizedPublication; - - const href = - getDocumentURL(docRecord, props.commentData.documents?.uri!, pubRecord) + - "?interactionDrawer=comments"; - - return ( - } - actionText={<>{displayName} commented on your post} - content={ - - - - } - /> - ); -}; diff --git a/app/(home-pages)/notifications/FollowNotification.tsx b/app/(home-pages)/notifications/FollowNotification.tsx deleted file mode 100644 --- a/app/(home-pages)/notifications/FollowNotification.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Avatar } from "components/Avatar"; -import { Notification } from "./Notification"; -import { HydratedSubscribeNotification } from "src/notifications"; -import { blobRefToSrc } from "src/utils/blobRefToSrc"; -import { AppBskyActorProfile } from "lexicons/api"; - -export const FollowNotification = (props: HydratedSubscribeNotification) => { - const profileRecord = props.subscriptionData?.identities?.bsky_profiles - ?.record as AppBskyActorProfile.Record; - const displayName = - profileRecord?.displayName || - props.subscriptionData?.identities?.bsky_profiles?.handle || - "Someone"; - const pubRecord = props.normalizedPublication; - const avatarSrc = - profileRecord?.avatar?.ref && - blobRefToSrc( - profileRecord.avatar.ref, - props.subscriptionData?.identity || "", - ); - - return ( - } - actionText={ - <> - {displayName} subscribed to {pubRecord?.name}! - - } - /> - ); -}; diff --git a/app/(home-pages)/notifications/MentionNotification.tsx b/app/(home-pages)/notifications/MentionNotification.tsx deleted file mode 100644 --- a/app/(home-pages)/notifications/MentionNotification.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { MentionTiny } from "components/Icons/MentionTiny"; -import { ContentLayout, Notification } from "./Notification"; -import { HydratedMentionNotification } from "src/notifications"; -import { AtUri } from "@atproto/api"; -import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; - -export const MentionNotification = (props: HydratedMentionNotification) => { - const docRecord = props.normalizedDocument; - const pubRecord = props.normalizedPublication; - - if (!docRecord) return null; - - const href = getDocumentURL(docRecord, props.document.uri, pubRecord); - - let actionText: React.ReactNode; - let mentionedItemName: string | undefined; - const mentionedDocRecord = props.normalizedMentionedDocument; - - const mentioner = props.documentCreatorHandle - ? `@${props.documentCreatorHandle}` - : "Someone"; - - if (props.mention_type === "did") { - actionText = <>{mentioner} mentioned you; - } else if ( - props.mention_type === "publication" && - props.mentionedPublication - ) { - const mentionedPubRecord = props.normalizedMentionedPublication; - mentionedItemName = mentionedPubRecord?.name; - actionText = ( - <> - {mentioner} mentioned your publication{" "} - {mentionedItemName} - - ); - } else if (props.mention_type === "document" && mentionedDocRecord) { - mentionedItemName = mentionedDocRecord.title; - actionText = ( - <> - {mentioner} mentioned your post{" "} - {mentionedItemName} - - ); - } else { - actionText = <>{mentioner} mentioned you; - } - - return ( - } - actionText={actionText} - content={ - - {docRecord.description && docRecord.description} - - } - /> - ); -}; diff --git a/app/(home-pages)/notifications/Notification.tsx b/app/(home-pages)/notifications/Notification.tsx deleted file mode 100644 --- a/app/(home-pages)/notifications/Notification.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; -import { Avatar } from "components/Avatar"; -import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; -import { PubLeafletRichtextFacet } from "lexicons/api"; -import { timeAgo } from "src/utils/timeAgo"; -import { useReplicache, useEntity } from "src/replicache"; -import type { NormalizedPublication } from "src/utils/normalizeRecords"; - -export const Notification = (props: { - icon: React.ReactNode; - actionText: React.ReactNode; - content?: React.ReactNode; - timestamp: string; - href: string; -}) => { - let { rootEntity } = useReplicache(); - let cardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden")?.data - .value; - - // If compact mode, always hide border - - return ( -
- -
-
-
{props.icon}
-
- {props.actionText} -
-
-
- {timeAgo(props.timestamp)} -
-
- {props.content && ( -
-
- {props.content} -
- )} -
- ); -}; - -export const ContentLayout = (props: { - children: React.ReactNode; - postTitle: string | undefined; - pubRecord?: NormalizedPublication | null; -}) => { - let { rootEntity } = useReplicache(); - let cardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden")?.data - .value; - - return ( -
- ); -}; - -type Facet = PubLeafletRichtextFacet.Main; -export const CommentInNotification = (props: { - avatar: string | undefined; - displayName: string; - plaintext: string; - facets?: Facet[]; - index: number[]; - className?: string; -}) => { - return ( -
- -
-        
-      
-
- ); -}; diff --git a/app/(home-pages)/notifications/NotificationList.tsx b/app/(home-pages)/notifications/NotificationList.tsx deleted file mode 100644 --- a/app/(home-pages)/notifications/NotificationList.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { HydratedNotification } from "src/notifications"; -import { CommentNotification } from "./CommentNotication"; -import { useEffect, createContext } from "react"; -import { markAsRead } from "./getNotifications"; -import { ReplyNotification } from "./ReplyNotification"; -import { useIdentityData } from "components/IdentityProvider"; -import { FollowNotification } from "./FollowNotification"; -import { QuoteNotification } from "./QuoteNotification"; -import { BskyPostEmbedNotification } from "./BskyPostEmbedNotification"; -import { MentionNotification } from "./MentionNotification"; -import { CommentMentionNotification } from "./CommentMentionNotification"; -import { RecommendNotification } from "./RecommendNotification"; -import { EmptyState } from "components/EmptyState"; - -export function NotificationList({ - notifications, - compact, -}: { - notifications: HydratedNotification[]; - compact?: boolean; -}) { - let { mutate } = useIdentityData(); - useEffect(() => { - setTimeout(async () => { - await markAsRead(); - mutate(); - }, 500); - }, []); - - if (notifications.length === 0) - return ( - - ); - return ( -
-
-

Notifications

- - {notifications.map((n) => { - if (n.type === "comment") { - if (n.parentData) return ; - return ; - } - if (n.type === "subscribe") { - return ; - } - if (n.type === "quote") { - return ; - } - if (n.type === "bsky_post_embed") { - return ; - } - if (n.type === "mention") { - return ; - } - if (n.type === "comment_mention") { - return ; - } - if (n.type === "recommend") { - return ; - } - })} -
-
- ); -} diff --git a/app/(home-pages)/notifications/QuoteNotification.tsx b/app/(home-pages)/notifications/QuoteNotification.tsx deleted file mode 100644 --- a/app/(home-pages)/notifications/QuoteNotification.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { QuoteTiny } from "components/Icons/QuoteTiny"; -import { ContentLayout, Notification } from "./Notification"; -import { HydratedQuoteNotification } from "src/notifications"; -import { AtUri } from "@atproto/api"; -import { Avatar } from "components/Avatar"; -import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; - -export const QuoteNotification = (props: HydratedQuoteNotification) => { - const postView = props.bskyPost.post_view as any; - const author = postView.author; - const displayName = author.displayName || author.handle || "Someone"; - const docRecord = props.normalizedDocument; - const pubRecord = props.normalizedPublication; - - if (!docRecord) return null; - - const postText = postView.record?.text || ""; - - const href = getDocumentURL(docRecord, props.document.uri, pubRecord); - - return ( - } - actionText={<>{displayName} quoted your post} - content={ - -
- -
-              {postText}
-            
-
-
- } - /> - ); -}; diff --git a/app/(home-pages)/notifications/RecommendNotification.tsx b/app/(home-pages)/notifications/RecommendNotification.tsx deleted file mode 100644 --- a/app/(home-pages)/notifications/RecommendNotification.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { ContentLayout, Notification } from "./Notification"; -import { HydratedRecommendNotification } from "src/notifications"; -import { blobRefToSrc } from "src/utils/blobRefToSrc"; -import { AppBskyActorProfile } from "lexicons/api"; -import { Avatar } from "components/Avatar"; -import { AtUri } from "@atproto/api"; -import { RecommendTinyFilled } from "components/Icons/RecommendTiny"; -import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; - -export const RecommendNotification = ( - props: HydratedRecommendNotification, -) => { - const profileRecord = props.recommendData?.identities?.bsky_profiles - ?.record as AppBskyActorProfile.Record; - const displayName = - profileRecord?.displayName || - props.recommendData?.identities?.bsky_profiles?.handle || - "Someone"; - const docRecord = props.normalizedDocument; - const pubRecord = props.normalizedPublication; - const avatarSrc = - profileRecord?.avatar?.ref && - blobRefToSrc( - profileRecord.avatar.ref, - props.recommendData?.recommender_did || "", - ); - - if (!docRecord) return null; - - const href = getDocumentURL(docRecord, props.document.uri, pubRecord); - - return ( - } - actionText={<>{displayName} recommended your post} - content={ - - {null} - - } - /> - ); -}; diff --git a/app/(home-pages)/notifications/ReplyNotification.tsx b/app/(home-pages)/notifications/ReplyNotification.tsx deleted file mode 100644 --- a/app/(home-pages)/notifications/ReplyNotification.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Avatar } from "components/Avatar"; -import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; -import { ReplyTiny } from "components/Icons/ReplyTiny"; -import { - CommentInNotification, - ContentLayout, - Notification, -} from "./Notification"; -import { HydratedCommentNotification } from "src/notifications"; -import { PubLeafletComment } from "lexicons/api"; -import { AppBskyActorProfile, AtUri } from "@atproto/api"; -import { blobRefToSrc } from "src/utils/blobRefToSrc"; -import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; - -export const ReplyNotification = (props: HydratedCommentNotification) => { - const docRecord = props.normalizedDocument; - const commentRecord = props.commentData.record as PubLeafletComment.Record; - const profileRecord = props.commentData.bsky_profiles - ?.record as AppBskyActorProfile.Record; - - if (!docRecord) return null; - - const displayName = - profileRecord?.displayName || - props.commentData.bsky_profiles?.handle || - "Someone"; - - const parentRecord = props.parentData?.record as PubLeafletComment.Record; - const parentProfile = props.parentData?.bsky_profiles - ?.record as AppBskyActorProfile.Record; - const parentDisplayName = - parentProfile?.displayName || - props.parentData?.bsky_profiles?.handle || - "Someone"; - - const pubRecord = props.normalizedPublication; - - const href = - getDocumentURL(docRecord, props.commentData.documents?.uri!, pubRecord) + - "?interactionDrawer=comments"; - - return ( - } - actionText={`${displayName} replied to your comment`} - content={ - - -
- - - } - /> - ); -}; diff --git a/app/(home-pages)/notifications/getNotifications.ts b/app/(home-pages)/notifications/getNotifications.ts deleted file mode 100644 --- a/app/(home-pages)/notifications/getNotifications.ts +++ /dev/null @@ -1,28 +0,0 @@ -"use server"; -import { getIdentityData } from "actions/getIdentityData"; -import { hydrateNotifications } from "src/notifications"; -import { supabaseServerClient } from "supabase/serverClient"; - -export async function getNotifications(limit?: number) { - let identity = await getIdentityData(); - if (!identity?.atp_did) return []; - let query = supabaseServerClient - .from("notifications") - .select("*") - .eq("recipient", identity.atp_did) - .order("created_at", { ascending: false }); - if (limit) query.limit(limit); - let { data } = await query; - let notifications = await hydrateNotifications(data || []); - return notifications; -} - -export async function markAsRead() { - let identity = await getIdentityData(); - if (!identity?.atp_did) return []; - await supabaseServerClient - .from("notifications") - .update({ read: true }) - .eq("recipient", identity.atp_did); - return; -} diff --git a/app/(home-pages)/notifications/page.tsx b/app/(home-pages)/notifications/page.tsx deleted file mode 100644 --- a/app/(home-pages)/notifications/page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { getIdentityData } from "actions/getIdentityData"; -import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; -import { redirect } from "next/navigation"; -import { hydrateNotifications } from "src/notifications"; -import { supabaseServerClient } from "supabase/serverClient"; -import { CommentNotification } from "./CommentNotication"; -import { NotificationList } from "./NotificationList"; - -export default async function Notifications() { - return ( - , - }, - }} - /> - ); -} - -const NotificationContent = async () => { - let identity = await getIdentityData(); - if (!identity?.atp_did) return redirect("/home"); - let { data } = await supabaseServerClient - .from("notifications") - .select("*") - .eq("recipient", identity.atp_did); - let notifications = await hydrateNotifications(data || []); - return ; -}; diff --git a/app/(home-pages)/reader/FeedLayout.tsx b/app/(home-pages)/reader/FeedLayout.tsx new file mode 100644 --- /dev/null +++ b/app/(home-pages)/reader/FeedLayout.tsx @@ -0,0 +1,7 @@ +export const FeedLayout = (props: { children: React.ReactNode }) => { + return ( +
+ {props.children} +
+ ); +}; diff --git a/app/(home-pages)/reader/GlobalContent.tsx b/app/(home-pages)/reader/GlobalContent.tsx --- a/app/(home-pages)/reader/GlobalContent.tsx +++ b/app/(home-pages)/reader/GlobalContent.tsx @@ -5,10 +5,6 @@ import { EmptyState } from "components/EmptyState"; import { PostListing } from "components/PostListing"; import type { Post } from "./getReaderFeed"; -import { - DesktopInteractionPreviewDrawer, - MobileInteractionPreviewDrawer, -} from "./InteractionDrawers"; import { useSelectedPostListing } from "src/useSelectedPostState"; export const GlobalContent = (props: { @@ -38,18 +34,14 @@ } return ( -
-
- {posts.map((p) => ( - - ))} -
- - -
+ <> + {posts.map((p) => ( + + ))} + ); }; diff --git a/app/(home-pages)/reader/InboxContent.tsx b/app/(home-pages)/reader/InboxContent.tsx --- a/app/(home-pages)/reader/InboxContent.tsx +++ b/app/(home-pages)/reader/InboxContent.tsx @@ -8,16 +8,16 @@ import { useEffect, useRef } from "react"; import Link from "next/link"; import { PostListing } from "components/PostListing"; -import { - DesktopInteractionPreviewDrawer, - MobileInteractionPreviewDrawer, -} from "./InteractionDrawers"; import { useSelectedPostListing } from "src/useSelectedPostState"; +import { useIdentityData } from "components/IdentityProvider"; +import { LoginContent } from "components/LoginButton"; +import { EmptyState } from "components/EmptyState"; export const InboxContent = (props: { promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>; }) => { const { posts, nextCursor } = use(props.promise); + let { identity: identityData } = useIdentityData(); const getKey = ( pageIndex: number, previousPageData: { @@ -77,46 +77,49 @@ new Date(a.documents.data?.publishedAt || 0).getTime(), ); + if (!identityData) { + return ( + + + + ); + } + if (allPosts.length === 0) return ; return ( -
-
- {sortedPosts.map((p) => ( - - ))} - {/* Trigger element for loading more posts */} - - - -
+ ))} + {/* Trigger element for loading more posts */} +