a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

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 <celine@hyperlink.academy>

authored by

Jared Pereira
celine
and committed by
GitHub
(May 7, 2026, 5:43 PM EDT) aa875e36 728b94b9

+2279 -1889
+1 -1
CLAUDE.md
··· 71 71 - **React contexts**: `DocumentProvider`, `LeafletContentProvider` for page-level data 72 72 - **Inngest functions**: Async jobs in `app/api/inngest/functions/` 73 73 - **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 `<svg>` element, and uses `fill="currentColor"` instead of hardcoded colors like `fill="black"`. 74 - - **Popovers and menus**: Use the existing `Popover` (`components/Popover`), `Menu`, and `MenuItem` (`components/Menu`) components — do not create new popover/menu primitives 74 + - **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
+50
app/(home-pages)/(writer)/WriterShell.tsx
··· 1 + "use client"; 2 + 3 + import { usePathname } from "next/navigation"; 4 + import { DashboardShell } from "components/PageLayouts/DashboardShell"; 5 + import { PageTitle } from "components/ActionBar/DesktopNavigation"; 6 + import { HomeSmall } from "components/Icons/HomeSmall"; 7 + import { LooseleafTiny } from "components/Icons/LooseleafTiny"; 8 + import { NotificationsUnreadSmall } from "components/Icons/NotificationSmall"; 9 + import { Actions } from "./home/Actions/Actions"; 10 + 11 + const PAGE_META = [ 12 + { 13 + prefix: "/home", 14 + id: "home", 15 + title: "Home", 16 + }, 17 + { 18 + prefix: "/looseleafs", 19 + id: "looseleafs", 20 + title: "Looseleafs", 21 + showBackButton: true, 22 + }, 23 + { 24 + prefix: "/notifications", 25 + id: "notifications", 26 + title: "Notifications", 27 + }, 28 + ]; 29 + 30 + export function WriterShell(props: { children: React.ReactNode }) { 31 + const pathname = usePathname(); 32 + const meta = PAGE_META.find((m) => pathname.startsWith(m.prefix)); 33 + 34 + return ( 35 + <DashboardShell 36 + id={meta?.id ?? ""} 37 + pageTitle={ 38 + meta && ( 39 + <PageTitle 40 + pageTitle={meta.title} 41 + showBackButton={meta.showBackButton} 42 + /> 43 + ) 44 + } 45 + actions={<Actions />} 46 + > 47 + {props.children} 48 + </DashboardShell> 49 + ); 50 + }
+16
app/(home-pages)/(writer)/home/Actions/Actions.tsx
··· 1 + "use client"; 2 + import { CreateNewLeafletButton } from "./CreateNewButton"; 3 + import { HomeThemeSetter } from "./HomeThemeSetter"; 4 + import { useIdentityData } from "components/IdentityProvider"; 5 + import { useReplicache } from "src/replicache"; 6 + 7 + export const Actions = () => { 8 + let { identity } = useIdentityData(); 9 + let { rootEntity } = useReplicache(); 10 + return ( 11 + <> 12 + <CreateNewLeafletButton /> 13 + {identity && <HomeThemeSetter entityID={rootEntity} />} 14 + </> 15 + ); 16 + };
+5
app/(home-pages)/(writer)/layout.tsx
··· 1 + import { WriterShell } from "./WriterShell"; 2 + 3 + export default function WriterLayout(props: { children: React.ReactNode }) { 4 + return <WriterShell>{props.children}</WriterShell>; 5 + }
+9 -3
app/(home-pages)/home/Actions/AccountSettings.tsx app/(home-pages)/(writer)/home/Actions/HomeThemeSetter.tsx
··· 6 6 import { useIsMobile } from "src/hooks/isMobile"; 7 7 import { PaintSmall } from "components/Icons/PaintSmall"; 8 8 9 - export const AccountSettings = (props: { entityID: string }) => { 9 + export const HomeThemeSetter = (props: { entityID: string }) => { 10 10 let isMobile = useIsMobile(); 11 - 12 11 return ( 13 12 <Popover 14 13 asChild ··· 16 15 align={isMobile ? "center" : "start"} 17 16 className={`w-xs bg-white!`} 18 17 arrowFill="bg-white" 19 - trigger={<ActionButton smallOnMobile icon=<PaintSmall /> label="Theme" />} 18 + trigger={ 19 + <ActionButton 20 + secondary 21 + icon=<PaintSmall /> 22 + label="Theme" 23 + className="sm:flex! hidden" 24 + /> 25 + } 20 26 > 21 27 <ThemeSetterContent entityID={props.entityID} home /> 22 28 </Popover>
-18
app/(home-pages)/home/Actions/Actions.tsx
··· 1 - "use client"; 2 - import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 3 - import { CreateNewLeafletButton } from "./CreateNewButton"; 4 - import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 5 - import { AccountSettings } from "./AccountSettings"; 6 - import { useIdentityData } from "components/IdentityProvider"; 7 - import { useReplicache } from "src/replicache"; 8 - 9 - export const Actions = () => { 10 - let { identity } = useIdentityData(); 11 - let { rootEntity } = useReplicache(); 12 - return ( 13 - <> 14 - <CreateNewLeafletButton /> 15 - {identity && <AccountSettings entityID={rootEntity} />} 16 - </> 17 - ); 18 - };
+16 -8
app/(home-pages)/home/Actions/CreateNewButton.tsx app/(home-pages)/(writer)/home/Actions/CreateNewButton.tsx
··· 2 2 3 3 import { createNewLeaflet } from "actions/createNewLeaflet"; 4 4 import { ActionButton } from "components/ActionBar/ActionButton"; 5 + import { ButtonPrimary } from "components/Buttons"; 5 6 import { AddTiny } from "components/Icons/AddTiny"; 6 7 import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; 7 8 import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 8 9 import { Menu, MenuItem } from "components/Menu"; 9 10 import { useIsMobile } from "src/hooks/isMobile"; 10 11 11 - export const CreateNewLeafletButton = (props: {}) => { 12 + export const CreateNewLeafletButton = (props: { compact?: boolean }) => { 12 13 let isMobile = useIsMobile(); 13 14 let openNewLeaflet = (id: string) => { 14 15 if (isMobile) { ··· 22 23 asChild 23 24 side={isMobile ? "top" : "right"} 24 25 align={isMobile ? "center" : "start"} 26 + className="z-[60]!" 25 27 trigger={ 26 - <ActionButton 27 - id="new-leaflet-button" 28 - primary 29 - icon=<AddTiny className="sm:m-1 shrink-0 sm:scale-100 scale-75" /> 30 - label="New" 31 - smallOnMobile 32 - /> 28 + props.compact ? ( 29 + <ButtonPrimary compact className="text-sm!"> 30 + <AddTiny className="scale-90" /> New Doc 31 + </ButtonPrimary> 32 + ) : ( 33 + <ActionButton 34 + labelOnMobile 35 + id="new-leaflet-button" 36 + primary 37 + icon=<AddTiny className="m-1" /> 38 + label="New Doc" 39 + /> 40 + ) 33 41 } 34 42 > 35 43 <MenuItem
-27
app/(home-pages)/home/Actions/HomeHelp.tsx
··· 1 - "use client"; 2 - import { ActionButton } from "components/ActionBar/ActionButton"; 3 - import { HelpSmall } from "components/Icons/HelpSmall"; 4 - import { Popover } from "components/Popover"; 5 - 6 - export const HomeHelp = () => { 7 - return ( 8 - <Popover 9 - className="max-w-sm" 10 - trigger={<ActionButton icon={<HelpSmall />} label="Info" />} 11 - > 12 - <div className="flex flex-col gap-2"> 13 - <p> 14 - Leaflets are saved to home <strong>per-device / browser</strong> using 15 - cookies. 16 - </p> 17 - <p> 18 - <strong>If you clear your cookies, they&apos;ll disappear.</strong> 19 - </p> 20 - <p> 21 - Please <a href="mailto:contact@leaflet.pub">contact us</a> for help 22 - recovering Leaflets! 23 - </p> 24 - </div> 25 - </Popover> 26 - ); 27 - };
app/(home-pages)/home/HomeEmpty/DiscoverIllo.tsx app/(home-pages)/(writer)/home/HomeEmpty/DiscoverIllo.tsx
app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx app/(home-pages)/(writer)/home/HomeEmpty/HomeEmpty.tsx
app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx app/(home-pages)/(writer)/home/HomeEmpty/WelcomeToLeafletIllo.tsx
+28 -34
app/(home-pages)/home/HomeLayout.tsx app/(home-pages)/(writer)/home/HomeLayout.tsx
··· 14 14 import { callRPC } from "app/api/rpc/client"; 15 15 import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; 16 16 import { 17 - HomeDashboardControls, 18 - DashboardLayout, 19 - DashboardState, 17 + type DashboardState, 20 18 useDashboardState, 21 - } from "components/PageLayouts/DashboardLayout"; 22 - import { Actions } from "./Actions/Actions"; 19 + } from "components/PageLayouts/dashboardState"; 20 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 21 + import { PageSearch } from "components/PageLayouts/PageSearch"; 23 22 import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 24 23 import { useState } from "react"; 25 24 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 26 25 import { HomeEmptyState } from "./HomeEmpty/HomeEmpty"; 26 + import { CreateNewLeafletButton } from "./Actions/CreateNewButton"; 27 27 28 28 export type Leaflet = { 29 29 added_at: string; ··· 40 40 }; 41 41 }; 42 42 43 - export const HomeLayout = (props: { 43 + export const HomeContent = (props: { 44 44 entityID: string | null; 45 45 titles: { [root_entity: string]: string }; 46 46 initialFacts: { ··· 73 73 ).length > 0; 74 74 75 75 return ( 76 - <DashboardLayout 77 - id="home" 78 - currentPage="home" 79 - defaultTab="home" 80 - actions={<Actions />} 81 - tabs={{ 82 - home: { 83 - controls: ( 84 - <HomeDashboardControls 85 - defaultDisplay={"grid"} 86 - searchValue={searchValue} 87 - setSearchValueAction={setSearchValue} 88 - hasBackgroundImage={hasBackgroundImage} 89 - hasPubs={hasPubs} 90 - hasArchived={!!hasArchived} 91 - /> 92 - ), 93 - content: ( 94 - <HomeLeafletList 95 - titles={props.titles} 96 - initialFacts={props.initialFacts} 97 - searchValue={debouncedSearchValue} 98 - /> 99 - ), 100 - }, 101 - }} 102 - pageTitle={"Home"} 103 - /> 76 + <DashboardPageLayout 77 + scrollKey="dashboard-home" 78 + pageTitle="Home" 79 + mobileActions={<CreateNewLeafletButton compact />} 80 + search={ 81 + <PageSearch 82 + defaultDisplay={"grid"} 83 + searchValue={searchValue} 84 + setSearchValueAction={setSearchValue} 85 + hasBackgroundImage={hasBackgroundImage} 86 + hasPubs={hasPubs} 87 + hasArchived={!!hasArchived} 88 + /> 89 + } 90 + showHeader={true} 91 + > 92 + <HomeLeafletList 93 + titles={props.titles} 94 + initialFacts={props.initialFacts} 95 + searchValue={debouncedSearchValue} 96 + /> 97 + </DashboardPageLayout> 104 98 ); 105 99 }; 106 100
app/(home-pages)/home/IdentitySetter.tsx app/(home-pages)/(writer)/home/IdentitySetter.tsx
app/(home-pages)/home/LeafletList/LeafletContent.tsx app/(home-pages)/(writer)/home/LeafletList/LeafletContent.tsx
app/(home-pages)/home/LeafletList/LeafletInfo.tsx app/(home-pages)/(writer)/home/LeafletList/LeafletInfo.tsx
+1 -4
app/(home-pages)/home/LeafletList/LeafletListItem.tsx app/(home-pages)/(writer)/home/LeafletList/LeafletListItem.tsx
··· 70 70 </div> 71 71 {cardBorderHidden && ( 72 72 <hr 73 - className="last:hidden border-border-light" 74 - style={{ 75 - display: props.isHidden ? "none" : "block", 76 - }} 73 + className={`${props.isHidden ? "hidden" : "block last:hidden"} border-border-light`} 77 74 /> 78 75 )} 79 76 </>
app/(home-pages)/home/LeafletList/LeafletOptions.tsx app/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx
app/(home-pages)/home/LeafletList/LeafletPreview.module.css app/(home-pages)/(writer)/home/LeafletList/LeafletPreview.module.css
app/(home-pages)/home/LeafletList/LeafletPreview.tsx app/(home-pages)/(writer)/home/LeafletList/LeafletPreview.tsx
app/(home-pages)/home/icon.tsx app/(home-pages)/(writer)/home/icon.tsx
+2 -2
app/(home-pages)/home/page.tsx app/(home-pages)/(writer)/home/page.tsx
··· 2 2 import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 4 5 - import { HomeLayout } from "./HomeLayout"; 5 + import { HomeContent } from "./HomeLayout"; 6 6 7 7 export default async function Home() { 8 8 let auth_res = await getIdentityData(); ··· 23 23 let home_docs_initialFacts = allLeafletFacts?.result || {}; 24 24 25 25 return ( 26 - <HomeLayout 26 + <HomeContent 27 27 titles={{ 28 28 ...home_docs_initialFacts.titles, 29 29 ...auth_res?.permission_token_on_homepage.reduce(
app/(home-pages)/home/storage.ts app/(home-pages)/(writer)/home/storage.ts
+14 -22
app/(home-pages)/looseleafs/LooseleafsLayout.tsx app/(home-pages)/(writer)/looseleafs/LooseleafsLayout.tsx
··· 1 1 "use client"; 2 - import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 2 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 3 3 import { useState } from "react"; 4 4 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 5 5 import { Fact, PermissionToken } from "src/replicache"; 6 6 import { Attribute } from "src/replicache/attributes"; 7 - import { Actions } from "../home/Actions/Actions"; 8 7 import { callRPC } from "app/api/rpc/client"; 9 8 import { useIdentityData } from "components/IdentityProvider"; 10 9 import useSWR from "swr"; 11 - import { getHomeDocs } from "../home/storage"; 12 10 import { Leaflet, LeafletList } from "../home/HomeLayout"; 11 + import { CreateNewLeafletButton } from "../home/Actions/CreateNewButton"; 13 12 14 - export const LooseleafsLayout = (props: { 13 + export const LooseleafsContent = (props: { 15 14 entityID: string | null; 16 15 titles: { [root_entity: string]: string }; 17 16 initialFacts: { ··· 30 29 ); 31 30 32 31 return ( 33 - <DashboardLayout 34 - id="looseleafs" 35 - currentPage="looseleafs" 36 - defaultTab="home" 37 - actions={<Actions />} 38 - tabs={{ 39 - home: { 40 - controls: null, 41 - content: ( 42 - <LooseleafList 43 - titles={props.titles} 44 - initialFacts={props.initialFacts} 45 - searchValue={debouncedSearchValue} 46 - /> 47 - ), 48 - }, 49 - }} 32 + <DashboardPageLayout 33 + scrollKey="dashboard-looseleafs-home" 50 34 pageTitle="Looseleafs" 51 - /> 35 + mobileActions={<CreateNewLeafletButton compact />} 36 + showHeader={false} 37 + > 38 + <LooseleafList 39 + titles={props.titles} 40 + initialFacts={props.initialFacts} 41 + searchValue={debouncedSearchValue} 42 + /> 43 + </DashboardPageLayout> 52 44 ); 53 45 }; 54 46
+2 -6
app/(home-pages)/looseleafs/page.tsx app/(home-pages)/(writer)/looseleafs/page.tsx
··· 1 1 import { getIdentityData } from "actions/getIdentityData"; 2 - import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 3 - import { Actions } from "../home/Actions/Actions"; 4 - import { Fact } from "src/replicache"; 5 - import { Attribute } from "src/replicache/attributes"; 6 2 import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; 7 3 import { supabaseServerClient } from "supabase/serverClient"; 8 - import { LooseleafsLayout } from "./LooseleafsLayout"; 4 + import { LooseleafsContent } from "./LooseleafsLayout"; 9 5 10 6 export default async function Home() { 11 7 let auth_res = await getIdentityData(); ··· 26 22 let home_docs_initialFacts = allLeafletFacts?.result || {}; 27 23 28 24 return ( 29 - <LooseleafsLayout 25 + <LooseleafsContent 30 26 entityID={auth_res?.home_leaflet?.root_entity || null} 31 27 titles={{ 32 28 ...home_docs_initialFacts.titles,
app/(home-pages)/notifications/BskyPostEmbedNotification.tsx app/(home-pages)/(writer)/notifications/BskyPostEmbedNotification.tsx
app/(home-pages)/notifications/CommentMentionNotification.tsx app/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx
app/(home-pages)/notifications/CommentNotication.tsx app/(home-pages)/(writer)/notifications/CommentNotication.tsx
app/(home-pages)/notifications/FollowNotification.tsx app/(home-pages)/(writer)/notifications/FollowNotification.tsx
app/(home-pages)/notifications/MentionNotification.tsx app/(home-pages)/(writer)/notifications/MentionNotification.tsx
app/(home-pages)/notifications/Notification.tsx app/(home-pages)/(writer)/notifications/Notification.tsx
app/(home-pages)/notifications/NotificationList.tsx app/(home-pages)/(writer)/notifications/NotificationList.tsx
app/(home-pages)/notifications/QuoteNotification.tsx app/(home-pages)/(writer)/notifications/QuoteNotification.tsx
app/(home-pages)/notifications/RecommendNotification.tsx app/(home-pages)/(writer)/notifications/RecommendNotification.tsx
app/(home-pages)/notifications/ReplyNotification.tsx app/(home-pages)/(writer)/notifications/ReplyNotification.tsx
app/(home-pages)/notifications/getNotifications.ts app/(home-pages)/(writer)/notifications/getNotifications.ts
+9 -15
app/(home-pages)/notifications/page.tsx app/(home-pages)/(writer)/notifications/page.tsx
··· 1 1 import { getIdentityData } from "actions/getIdentityData"; 2 - import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 2 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 3 3 import { redirect } from "next/navigation"; 4 4 import { hydrateNotifications } from "src/notifications"; 5 5 import { supabaseServerClient } from "supabase/serverClient"; 6 - import { CommentNotification } from "./CommentNotication"; 7 6 import { NotificationList } from "./NotificationList"; 8 7 9 - export default async function Notifications() { 8 + export default async function NotificationsPage() { 10 9 return ( 11 - <DashboardLayout 12 - id="discover" 13 - currentPage="notifications" 14 - defaultTab="default" 15 - actions={null} 16 - tabs={{ 17 - default: { 18 - controls: null, 19 - content: <NotificationContent />, 20 - }, 21 - }} 22 - /> 10 + <DashboardPageLayout 11 + pageTitle="Notifications" 12 + scrollKey="dashboard-discover-default" 13 + showHeader={false} 14 + > 15 + <NotificationContent /> 16 + </DashboardPageLayout> 23 17 ); 24 18 } 25 19
+24 -20
app/(home-pages)/p/[didOrHandle]/layout.tsx
··· 4 4 import { Json } from "supabase/database.types"; 5 5 import { ProfileHeader } from "./ProfileHeader"; 6 6 import { ProfileTabs } from "./ProfileTabs"; 7 - import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 7 + import { DashboardShell } from "components/PageLayouts/DashboardShell"; 8 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 8 9 import { ProfileLayout } from "./ProfileLayout"; 9 10 import { Agent } from "@atproto/api"; 10 11 import { get_profile_data } from "app/api/rpc/[command]/get_profile_data"; 11 12 import { Metadata } from "next"; 12 13 import { cache } from "react"; 14 + import { PageTitle } from "components/ActionBar/DesktopNavigation"; 15 + import { Avatar } from "components/Avatar"; 13 16 14 17 // Cache the profile data call to prevent concurrent OAuth restores 15 18 const getCachedProfileData = cache(async (did: string) => { ··· 77 80 78 81 if (!profile) return null; 79 82 83 + let displayName = profile.displayName || profile.handle; 84 + 80 85 return ( 81 - <DashboardLayout 86 + <DashboardShell 82 87 id="profile" 83 - defaultTab="default" 84 - currentPage="profile" 85 - profileDid={did} 86 - actions={null} 88 + pageTitle={<PageTitle pageTitle={displayName} />} 87 89 tabs={{ 88 - default: { 89 - controls: null, 90 - content: ( 91 - <ProfileLayout> 92 - <ProfileHeader 93 - profile={profile} 94 - publications={publications || []} 95 - /> 96 - <ProfileTabs didOrHandle={params.didOrHandle} /> 97 - <>{props.children}</> 98 - </ProfileLayout> 99 - ), 100 - }, 90 + Inbox: { href: "/reader" }, 91 + Trending: { href: "/reader/trending" }, 92 + New: { href: "/reader/new" }, 101 93 }} 102 - /> 94 + > 95 + <DashboardPageLayout 96 + pageTitle={displayName} 97 + scrollKey="dashboard-profile-default" 98 + showHeader={false} 99 + > 100 + <ProfileLayout> 101 + <ProfileHeader profile={profile} publications={publications || []} /> 102 + <ProfileTabs didOrHandle={params.didOrHandle} /> 103 + <>{props.children}</> 104 + </ProfileLayout> 105 + </DashboardPageLayout> 106 + </DashboardShell> 103 107 ); 104 108 } 105 109
+5 -21
app/(home-pages)/page.tsx
··· 1 1 import { cookies } from "next/headers"; 2 - import ReaderLayout from "./reader/layout"; 3 - import ReaderPage from "./reader/page"; 4 - import HomePage from "./home/page"; 2 + import { redirect } from "next/navigation"; 5 3 6 4 export default async function RootPage() { 7 5 const cookieStore = await cookies(); 8 6 const hasAuth = 9 - cookieStore.has("auth_token") || 10 - cookieStore.has("external_auth_token"); 7 + cookieStore.has("auth_token") || cookieStore.has("external_auth_token"); 11 8 12 - if (!hasAuth) { 13 - return ( 14 - <ReaderLayout> 15 - <ReaderPage /> 16 - </ReaderLayout> 17 - ); 18 - } 9 + if (!hasAuth) redirect("/reader"); 19 10 20 11 const navState = cookieStore.get("nav-state")?.value; 12 + if (navState === "reader") redirect("/reader"); 21 13 22 - if (navState === "reader") { 23 - return ( 24 - <ReaderLayout> 25 - <ReaderPage /> 26 - </ReaderLayout> 27 - ); 28 - } 29 - 30 - return <HomePage />; 14 + redirect("/home"); 31 15 }
+7
app/(home-pages)/reader/FeedLayout.tsx
··· 1 + export const FeedLayout = (props: { children: React.ReactNode }) => { 2 + return ( 3 + <div className="flex flex-col gap-6 w-full max-w-lg mx-auto"> 4 + {props.children} 5 + </div> 6 + ); 7 + };
+9 -17
app/(home-pages)/reader/GlobalContent.tsx
··· 5 5 import { EmptyState } from "components/EmptyState"; 6 6 import { PostListing } from "components/PostListing"; 7 7 import type { Post } from "./getReaderFeed"; 8 - import { 9 - DesktopInteractionPreviewDrawer, 10 - MobileInteractionPreviewDrawer, 11 - } from "./InteractionDrawers"; 12 8 import { useSelectedPostListing } from "src/useSelectedPostState"; 13 9 14 10 export const GlobalContent = (props: { ··· 38 34 } 39 35 40 36 return ( 41 - <div className="globalReader flex flex-row gap-6 w-full"> 42 - <div className="globalPostListings flex flex-col gap-6 min-w-0 grow w-full"> 43 - {posts.map((p) => ( 44 - <PostListing 45 - {...p} 46 - key={p.documents.uri} 47 - selected={selectedPost?.document_uri === p.documents.uri} 48 - /> 49 - ))} 50 - </div> 51 - <DesktopInteractionPreviewDrawer /> 52 - <MobileInteractionPreviewDrawer /> 53 - </div> 37 + <> 38 + {posts.map((p) => ( 39 + <PostListing 40 + {...p} 41 + key={p.documents.uri} 42 + selected={selectedPost?.document_uri === p.documents.uri} 43 + /> 44 + ))} 45 + </> 54 46 ); 55 47 };
+35 -32
app/(home-pages)/reader/InboxContent.tsx
··· 8 8 import { useEffect, useRef } from "react"; 9 9 import Link from "next/link"; 10 10 import { PostListing } from "components/PostListing"; 11 - import { 12 - DesktopInteractionPreviewDrawer, 13 - MobileInteractionPreviewDrawer, 14 - } from "./InteractionDrawers"; 15 11 import { useSelectedPostListing } from "src/useSelectedPostState"; 12 + import { useIdentityData } from "components/IdentityProvider"; 13 + import { LoginContent } from "components/LoginButton"; 14 + import { EmptyState } from "components/EmptyState"; 16 15 17 16 export const InboxContent = (props: { 18 17 promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>; 19 18 }) => { 20 19 const { posts, nextCursor } = use(props.promise); 20 + let { identity: identityData } = useIdentityData(); 21 21 const getKey = ( 22 22 pageIndex: number, 23 23 previousPageData: { ··· 77 77 new Date(a.documents.data?.publishedAt || 0).getTime(), 78 78 ); 79 79 80 + if (!identityData) { 81 + return ( 82 + <EmptyState container="frosted" title="Log in or sign up"> 83 + <LoginContent className="w-full! sm:min-w-xs" /> 84 + </EmptyState> 85 + ); 86 + } 87 + 80 88 if (allPosts.length === 0) return <ReaderEmpty />; 81 89 82 90 return ( 83 - <div className="inboxReader flex flex-row gap-6 w-full "> 84 - <div className="inboxPostListings flex flex-col gap-6 min-w-0 grow w-full relative"> 85 - {sortedPosts.map((p) => ( 86 - <PostListing 87 - {...p} 88 - key={p.documents.uri} 89 - selected={selectedPost?.document_uri === p.documents.uri} 90 - /> 91 - ))} 92 - {/* Trigger element for loading more posts */} 93 - <div 94 - ref={loadMoreRef} 95 - className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 96 - aria-hidden="true" 91 + <> 92 + {sortedPosts.map((p) => ( 93 + <PostListing 94 + {...p} 95 + key={p.documents.uri} 96 + selected={selectedPost?.document_uri === p.documents.uri} 97 97 /> 98 - {isValidating && allPosts.length > 0 && ( 99 - <div className="text-center text-tertiary py-4"> 100 - Loading more posts... 101 - </div> 102 - )} 103 - </div> 104 - <DesktopInteractionPreviewDrawer /> 105 - <MobileInteractionPreviewDrawer /> 106 - </div> 98 + ))} 99 + {/* Trigger element for loading more posts */} 100 + <div 101 + ref={loadMoreRef} 102 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 103 + aria-hidden="true" 104 + /> 105 + {isValidating && allPosts.length > 0 && ( 106 + <div className="text-center text-tertiary py-4"> 107 + Loading more posts... 108 + </div> 109 + )} 110 + </> 107 111 ); 108 112 }; 109 113 110 114 export const ReaderEmpty = () => { 111 115 return ( 112 - <div className="flex flex-col gap-2 frosted-container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 113 - Nothing to read yet… <br /> 114 - Subscribe to publications and find their posts here! 115 - <Link href={"/reader/hot"}> 116 + <EmptyState container="frosted" title="Nothing to read yet…"> 117 + Subscribe to publications and find their latest posts here! 118 + <Link href={"/reader/trending"}> 116 119 <ButtonPrimary className="mx-auto place-self-center"> 117 120 <DiscoverSmall /> See what posts people are reading! 118 121 </ButtonPrimary> 119 122 </Link> 120 - </div> 123 + </EmptyState> 121 124 ); 122 125 };
-117
app/(home-pages)/reader/InteractionDrawers.tsx
··· 1 - "use client"; 2 - import { ButtonPrimary } from "components/Buttons"; 3 - import { 4 - SelectedPostListing, 5 - useSelectedPostListing, 6 - } from "src/useSelectedPostState"; 7 - import { CommentsDrawerContent } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments"; 8 - import { CloseTiny } from "components/Icons/CloseTiny"; 9 - import { SpeedyLink } from "components/SpeedyLink"; 10 - import { GoToArrow } from "components/Icons/GoToArrow"; 11 - import { DotLoader } from "components/utils/DotLoader"; 12 - import { ReaderMentionsContent } from "./ReaderMentionsContent"; 13 - import { callRPC } from "app/api/rpc/client"; 14 - import useSWR from "swr"; 15 - import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 16 - 17 - export const MobileInteractionPreviewDrawer = () => { 18 - let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 19 - 20 - return ( 21 - <div 22 - className={`mobileInteractionPreview shrink-0 z-20 fixed bottom-0 left-0 right-0 border border-border-light w-screen h-[90vh] px-3 bg-bg-leaflet rounded-t-lg overflow-auto ${selectedPost === null ? "hidden" : "block md:hidden "}`} 23 - > 24 - <PreviewDrawerContent selectedPost={selectedPost} /> 25 - </div> 26 - ); 27 - }; 28 - 29 - export const DesktopInteractionPreviewDrawer = () => { 30 - let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 31 - 32 - return ( 33 - <div 34 - className={`desktopInteractionPreview shrink-0 hidden md:block border border-border-light w-96 mr-2 px-3 h-[calc(100vh-100px)] sticky top-11 bottom-4 right-0 rounded-lg overflow-auto ${selectedPost === null ? "shadow-none border-dashed bg-transparent" : "shadow-md border-border bg-bg-page "}`} 35 - > 36 - <PreviewDrawerContent selectedPost={selectedPost} /> 37 - </div> 38 - ); 39 - }; 40 - 41 - const PreviewDrawerContent = (props: { 42 - selectedPost: SelectedPostListing | null; 43 - }) => { 44 - const documentUri = props.selectedPost?.document_uri || null; 45 - const drawer = props.selectedPost?.drawer || null; 46 - 47 - const { data, isLoading } = useSWR( 48 - documentUri ? ["get_document_interactions", documentUri] : null, 49 - async () => { 50 - const res = await callRPC("get_document_interactions", { 51 - document_uri: documentUri!, 52 - }); 53 - return res; 54 - }, 55 - { keepPreviousData: false }, 56 - ); 57 - 58 - if (!props.selectedPost || !props.selectedPost.document) 59 - return ( 60 - <div className="italic text-tertiary pt-4 text-center"> 61 - Click a post's comments or mentions to preview them here! 62 - </div> 63 - ); 64 - 65 - const postUrl = getDocumentURL( 66 - props.selectedPost.document, 67 - props.selectedPost.document_uri, 68 - props.selectedPost.publication, 69 - ); 70 - 71 - const drawerTitle = 72 - drawer === "quotes" 73 - ? `Mentions of ${props.selectedPost.document.title}` 74 - : `Comments for ${props.selectedPost.document.title}`; 75 - 76 - return ( 77 - <> 78 - <div className="sticky top-0 bg-bg-page z-10"> 79 - <div className=" w-full text-sm text-tertiary flex justify-between pt-3 gap-3"> 80 - <div className="truncate min-w-0 grow">{drawerTitle}</div> 81 - <button 82 - className="text-tertiary" 83 - onClick={() => 84 - useSelectedPostListing.getState().setSelectedPostListing(null) 85 - } 86 - > 87 - <CloseTiny /> 88 - </button> 89 - </div> 90 - <SpeedyLink className="shrink-0 flex gap-1 items-center" href={postUrl}> 91 - <ButtonPrimary fullWidth compact className="text-sm! mt-1"> 92 - See Full Post <GoToArrow /> 93 - </ButtonPrimary> 94 - </SpeedyLink> 95 - <hr className="mt-2 border-border-light" /> 96 - </div> 97 - {isLoading ? ( 98 - <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm mt-8"> 99 - <span>loading</span> 100 - <DotLoader /> 101 - </div> 102 - ) : drawer === "quotes" ? ( 103 - <div className="mt-3"> 104 - <ReaderMentionsContent 105 - quotesAndMentions={data?.quotesAndMentions || []} 106 - /> 107 - </div> 108 - ) : ( 109 - <CommentsDrawerContent 110 - noCommentBox 111 - document_uri={props.selectedPost.document_uri} 112 - comments={data?.comments || []} 113 - /> 114 - )} 115 - </> 116 - ); 117 - };
+18 -26
app/(home-pages)/reader/NewContent.tsx
··· 7 7 import { getNewFeed } from "./getNewFeed"; 8 8 import { useEffect, useRef } from "react"; 9 9 import { PostListing } from "components/PostListing"; 10 - import { 11 - DesktopInteractionPreviewDrawer, 12 - MobileInteractionPreviewDrawer, 13 - } from "./InteractionDrawers"; 14 10 import { useSelectedPostListing } from "src/useSelectedPostState"; 15 11 16 12 export const NewContent = (props: { ··· 70 66 } 71 67 72 68 return ( 73 - <div className="flex flex-row gap-6 w-full"> 74 - <div className="flex flex-col gap-6 w-full grow min-w-0 relative"> 75 - {allPosts.map((p) => ( 76 - <PostListing 77 - {...p} 78 - key={p.documents.uri} 79 - selected={selectedPost?.document_uri === p.documents.uri} 80 - /> 81 - ))} 82 - <div 83 - ref={loadMoreRef} 84 - className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 85 - aria-hidden="true" 69 + <> 70 + {allPosts.map((p) => ( 71 + <PostListing 72 + {...p} 73 + key={p.documents.uri} 74 + selected={selectedPost?.document_uri === p.documents.uri} 86 75 /> 87 - {isValidating && allPosts.length > 0 && ( 88 - <div className="text-center text-tertiary py-4"> 89 - Loading more posts... 90 - </div> 91 - )} 92 - </div> 93 - <DesktopInteractionPreviewDrawer /> 94 - <MobileInteractionPreviewDrawer /> 95 - </div> 76 + ))} 77 + <div 78 + ref={loadMoreRef} 79 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 80 + aria-hidden="true" 81 + /> 82 + {isValidating && allPosts.length > 0 && ( 83 + <div className="text-center text-tertiary py-4"> 84 + Loading more posts... 85 + </div> 86 + )} 87 + </> 96 88 ); 97 89 };
+3 -5
app/(home-pages)/reader/hot/page.tsx
··· 1 - import { getHotFeed } from "../getHotFeed"; 2 - import { GlobalContent } from "../GlobalContent"; 1 + import { redirect } from "next/navigation"; 3 2 4 - export default async function HotPage() { 5 - const feedPromise = getHotFeed(); 6 - return <GlobalContent promise={feedPromise} />; 3 + export default function HotPage() { 4 + redirect("/reader/trending"); 7 5 }
+21 -67
app/(home-pages)/reader/layout.tsx
··· 1 - "use client"; 2 - 3 - import { usePathname, useRouter } from "next/navigation"; 4 - import { Header } from "components/PageHeader"; 5 - import { Footer } from "components/ActionBar/Footer"; 6 - import { DesktopNavigation } from "components/ActionBar/DesktopNavigation"; 7 - import { MobileNavigation } from "components/ActionBar/MobileNavigation"; 8 - import { MediaContents } from "components/Media"; 9 - import { DashboardIdContext } from "components/PageLayouts/DashboardLayout"; 10 - import { useIdentityData } from "components/IdentityProvider"; 11 - import { Tab } from "components/Tab"; 12 - 13 - const allTabs = [ 14 - { name: "Subs", href: "/reader", requiresAuth: true }, 15 - { name: "Trending", href: "/reader/hot", requiresAuth: false }, 16 - { name: "New", href: "/reader/new", requiresAuth: false }, 17 - ]; 1 + import { getIdentityData } from "actions/getIdentityData"; 2 + import { PageTitle } from "components/ActionBar/DesktopNavigation"; 3 + import { DashboardShell } from "components/PageLayouts/DashboardShell"; 4 + import { ReaderUnreadSmall } from "components/Icons/ReaderSmall"; 5 + import { NewSmall } from "components/Icons/NewSmall"; 6 + import { TrendingSmall } from "components/Icons/TrendingSmall"; 7 + import { BlockMailboxSmall } from "components/Icons/BlockMailboxSmall"; 18 8 19 - export default function ReaderLayout({ 20 - children, 21 - }: { 9 + export default async function ReaderLayout(props: { 22 10 children: React.ReactNode; 23 11 }) { 24 - const pathname = usePathname(); 25 - const router = useRouter(); 26 - const { identity } = useIdentityData(); 27 - const isLoggedIn = !!identity?.atp_did; 28 - const tabs = allTabs.filter((tab) => !tab.requiresAuth || isLoggedIn); 29 - 30 - const isActive = (href: string) => { 31 - if (href === "/reader") return pathname === "/reader" || pathname === "/"; 32 - if ( 33 - href === "/reader/hot" && 34 - !isLoggedIn && 35 - (pathname === "/reader" || pathname === "/") 36 - ) 37 - return true; 38 - return pathname.startsWith(href); 39 - }; 12 + const identity = await getIdentityData(); 13 + const tabs: { [name: string]: { href: string; icon: React.ReactNode } } = {}; 14 + if (identity?.atp_did) 15 + tabs.Inbox = { href: "/reader", icon: <BlockMailboxSmall /> }; 16 + tabs.Trending = { href: "/reader/trending", icon: <TrendingSmall /> }; 17 + tabs.New = { href: "/reader/new", icon: <NewSmall /> }; 40 18 41 19 return ( 42 - <DashboardIdContext.Provider value="reader"> 43 - <div className="dashboard pwa-padding relative max-w-(--breakpoint-lg) w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6"> 44 - <MediaContents mobile={false}> 45 - <div className="flex flex-col gap-3 my-6"> 46 - <DesktopNavigation currentPage="reader" /> 47 - </div> 48 - </MediaContents> 49 - <div 50 - className="w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-3 px-3 sm:pt-8 sm:pb-3 sm:pl-6 sm:pr-4" 51 - id="home-content" 52 - > 53 - <Header> 54 - <div className="pubDashTabs flex flex-row gap-1"> 55 - {tabs.map((tab) => ( 56 - <Tab 57 - key={tab.name} 58 - name={tab.name} 59 - selected={isActive(tab.href)} 60 - onSelect={() => router.push(tab.href)} 61 - /> 62 - ))} 63 - </div> 64 - <div className="sm:block grow" /> 65 - </Header> 66 - {children} 67 - </div> 68 - <Footer> 69 - <MobileNavigation currentPage="reader" /> 70 - </Footer> 71 - </div> 72 - </DashboardIdContext.Provider> 20 + <DashboardShell 21 + id="reader" 22 + pageTitle={<PageTitle pageTitle="Reader" />} 23 + tabs={tabs} 24 + > 25 + {props.children} 26 + </DashboardShell> 73 27 ); 74 28 }
-4
app/(home-pages)/reader/loading.tsx
··· 1 - import { FeedSkeleton } from "./FeedSkeleton"; 2 - export default function Loading() { 3 - return <FeedSkeleton />; 4 - }
+18 -3
app/(home-pages)/reader/new/page.tsx
··· 1 + import { Suspense } from "react"; 2 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 1 3 import { getNewFeed } from "../getNewFeed"; 2 4 import { NewContent } from "../NewContent"; 5 + import { FeedSkeleton } from "../FeedSkeleton"; 6 + import { FeedLayout } from "../FeedLayout"; 3 7 4 - export default async function NewPage() { 5 - const feedPromise = getNewFeed(); 6 - return <NewContent promise={feedPromise} />; 8 + export default function NewPage() { 9 + return ( 10 + <DashboardPageLayout 11 + scrollKey="dashboard-reader-new" 12 + pageTitle="New" 13 + showHeader={false} 14 + > 15 + <Suspense fallback={<FeedSkeleton />}> 16 + <FeedLayout> 17 + <NewContent promise={getNewFeed()} /> 18 + </FeedLayout> 19 + </Suspense> 20 + </DashboardPageLayout> 21 + ); 7 22 }
+26 -10
app/(home-pages)/reader/page.tsx
··· 1 + import { Suspense } from "react"; 2 + import { redirect } from "next/navigation"; 1 3 import { getIdentityData } from "actions/getIdentityData"; 4 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 2 5 import { getReaderFeed } from "./getReaderFeed"; 3 - import { getHotFeed } from "./getHotFeed"; 4 6 import { InboxContent } from "./InboxContent"; 5 - import { GlobalContent } from "./GlobalContent"; 7 + import { FeedSkeleton } from "./FeedSkeleton"; 8 + import { FeedLayout } from "./FeedLayout"; 6 9 7 - export default async function Reader() { 8 - let identityData = await getIdentityData(); 9 - if (!identityData?.atp_did) { 10 - const feedPromise = getHotFeed(); 11 - return <GlobalContent promise={feedPromise} />; 12 - } 10 + export default async function ReaderPage(props: { 11 + searchParams: Promise<{ tab?: string }>; 12 + }) { 13 + const { tab } = await props.searchParams; 14 + if (tab === "Trending") redirect("/reader/trending"); 13 15 14 - const feedPromise = getReaderFeed(); 15 - return <InboxContent promise={feedPromise} />; 16 + const identity = await getIdentityData(); 17 + if (!identity?.atp_did) redirect("/reader/trending"); 18 + 19 + return ( 20 + <DashboardPageLayout 21 + scrollKey="dashboard-reader-inbox" 22 + pageTitle="Inbox" 23 + showHeader={false} 24 + > 25 + <Suspense fallback={<FeedSkeleton />}> 26 + <FeedLayout> 27 + <InboxContent promise={getReaderFeed()} /> 28 + </FeedLayout> 29 + </Suspense> 30 + </DashboardPageLayout> 31 + ); 16 32 }
+22
app/(home-pages)/reader/trending/page.tsx
··· 1 + import { Suspense } from "react"; 2 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 3 + import { getHotFeed } from "../getHotFeed"; 4 + import { GlobalContent } from "../GlobalContent"; 5 + import { FeedSkeleton } from "../FeedSkeleton"; 6 + import { FeedLayout } from "../FeedLayout"; 7 + 8 + export default function ReaderTrendingPage() { 9 + return ( 10 + <DashboardPageLayout 11 + scrollKey="dashboard-reader-trending" 12 + pageTitle="Trending" 13 + showHeader={false} 14 + > 15 + <Suspense fallback={<FeedSkeleton />}> 16 + <FeedLayout> 17 + <GlobalContent promise={getHotFeed()} /> 18 + </FeedLayout> 19 + </Suspense> 20 + </DashboardPageLayout> 21 + ); 22 + }
+24
app/(home-pages)/tag/[tag]/layout.tsx
··· 1 + import { DashboardShell } from "components/PageLayouts/DashboardShell"; 2 + import { PageTitle } from "components/ActionBar/DesktopNavigation"; 3 + 4 + export default async function TagLayout(props: { 5 + children: React.ReactNode; 6 + params: Promise<{ tag: string }>; 7 + }) { 8 + const params = await props.params; 9 + const decodedTag = decodeURIComponent(params.tag); 10 + 11 + return ( 12 + <DashboardShell 13 + id="tag" 14 + pageTitle={<PageTitle pageTitle={decodedTag} />} 15 + tabs={{ 16 + Inbox: { href: "/reader" }, 17 + Trending: { href: "/reader/trending" }, 18 + New: { href: "/reader/new" }, 19 + }} 20 + > 21 + {props.children} 22 + </DashboardShell> 23 + ); 24 + }
+8 -13
app/(home-pages)/tag/[tag]/page.tsx
··· 1 - import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 1 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 2 2 import { PostListing } from "components/PostListing"; 3 3 import { getDocumentsByTag } from "./getDocumentsByTag"; 4 4 import { Metadata } from "next"; ··· 19 19 const { posts } = await getDocumentsByTag(decodedTag); 20 20 21 21 return ( 22 - <DashboardLayout 23 - id="tag" 24 - currentPage="tag" 25 - defaultTab="default" 26 - actions={null} 27 - tabs={{ 28 - default: { 29 - controls: null, 30 - content: <TagContent tag={decodedTag} posts={posts} />, 31 - }, 32 - }} 33 - /> 22 + <DashboardPageLayout 23 + pageTitle={decodedTag} 24 + scrollKey="dashboard-tag-default" 25 + showHeader={false} 26 + > 27 + <TagContent tag={decodedTag} posts={posts} /> 28 + </DashboardPageLayout> 34 29 ); 35 30 } 36 31
+23 -4
app/[leaflet_id]/Footer.tsx
··· 6 6 import { Toolbar } from "components/Toolbar"; 7 7 import { FootnoteToolbar } from "components/Toolbar/FootnoteToolbarWrapper"; 8 8 import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; 9 - import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 9 + import { 10 + AddToHomeButton, 11 + HomeButton, 12 + } from "app/[leaflet_id]/actions/HomeButton"; 10 13 import { PublishButton } from "./actions/PublishButton"; 11 14 import { useEntitySetContext } from "components/EntitySetProvider"; 12 15 import { Watermark } from "components/Watermark"; 13 16 import { BackToPubButton } from "./actions/BackToPubButton"; 14 17 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 15 18 import { useIdentityData } from "components/IdentityProvider"; 16 - import { useEntity } from "src/replicache"; 19 + import { useEntity, useReplicache } from "src/replicache"; 17 20 import { block } from "sharp"; 18 21 import { PostSettings } from "components/PostSettings"; 22 + import useSWR from "swr"; 23 + import { getHomeDocs } from "app/(home-pages)/(writer)/home/storage"; 19 24 20 25 export function hasBlockToolbar(blockType: string | null | undefined) { 21 26 return ( ··· 29 34 } 30 35 export function LeafletFooter(props: { entityID: string }) { 31 36 let focusedBlock = useUIState((s) => s.focusedEntity); 32 - 33 37 let entity_set = useEntitySetContext(); 34 38 let { identity } = useIdentityData(); 39 + let { permission_token } = useReplicache(); 35 40 let { data: pub } = useLeafletPublicationData(); 41 + let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { 42 + fallbackData: [], 43 + }); 36 44 let blockType = useEntity(focusedBlock?.entityID || null, "block/type")?.data 37 45 .value; 46 + let isOnHome = identity 47 + ? !!identity.permission_token_on_homepage.find( 48 + (pth) => pth.permission_tokens.id === permission_token.id, 49 + ) 50 + : !!localLeaflets.find((f) => f.token.id === permission_token.id); 38 51 39 52 return ( 40 53 <Media ··· 77 90 ) : ( 78 91 <HomeButton /> 79 92 )} 93 + 80 94 <div className="mobileLeafletActions flex gap-2 shrink-0"> 81 - <PublishButton entityID={props.entityID} /> 95 + {!isOnHome ? ( 96 + <AddToHomeButton primary /> 97 + ) : ( 98 + <PublishButton entityID={props.entityID} /> 99 + )} 100 + 82 101 <ShareOptions /> 83 102 <PostSettings /> 84 103 <ThemePopover entityID={props.entityID} />
-2
app/[leaflet_id]/Leaflet.tsx
··· 9 9 } from "components/ThemeManager/ThemeProvider"; 10 10 import { LeafletFooter } from "./Footer"; 11 11 import { EntitySetProvider } from "components/EntitySetProvider"; 12 - import { AddLeafletToHomepage } from "components/utils/AddLeafletToHomepage"; 13 12 import { UpdateLeafletTitle } from "components/utils/UpdateLeafletTitle"; 14 13 import { useUIState } from "src/useUIState"; 15 14 import { LeafletLayout } from "components/LeafletLayout"; ··· 38 37 > 39 38 <ThemeBackgroundProvider entityID={props.leaflet_id}> 40 39 <UpdateLeafletTitle entityID={props.leaflet_id} /> 41 - <AddLeafletToHomepage /> 42 40 <SelectionManager /> 43 41 {/* we need the padding bottom here because if we don't have it the mobile footer will cut off... 44 42 the dropshadow on the page... the padding is compensated by a negative top margin in mobile footer */}
+22 -3
app/[leaflet_id]/Sidebar.tsx
··· 2 2 import { Sidebar } from "components/ActionBar/Sidebar"; 3 3 import { useEntitySetContext } from "components/EntitySetProvider"; 4 4 import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 5 - import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 5 + import { 6 + AddToHomeButton, 7 + HomeButton, 8 + } from "app/[leaflet_id]/actions/HomeButton"; 6 9 import { Media } from "components/Media"; 7 10 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 11 import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; ··· 13 16 import { BackToPubButton } from "./actions/BackToPubButton"; 14 17 import { useIdentityData } from "components/IdentityProvider"; 15 18 import { useReplicache } from "src/replicache"; 19 + import useSWR from "swr"; 20 + import { getHomeDocs } from "app/(home-pages)/(writer)/home/storage"; 16 21 17 22 export function LeafletSidebar() { 18 23 let entity_set = useEntitySetContext(); 19 24 let { rootEntity } = useReplicache(); 20 25 let { data: pub } = useLeafletPublicationData(); 21 26 let { identity } = useIdentityData(); 27 + let { permission_token } = useReplicache(); 28 + let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { 29 + fallbackData: [], 30 + }); 31 + let isOnHome = identity 32 + ? !!identity.permission_token_on_homepage.find( 33 + (pth) => pth.permission_tokens.id === permission_token.id, 34 + ) 35 + : !!localLeaflets.find((f) => f.token.id === permission_token.id); 22 36 23 37 return ( 24 38 <Media mobile={false} className="w-0 h-full relative"> ··· 28 42 > 29 43 <div className="sidebarContainer flex flex-col justify-end h-full w-16 relative"> 30 44 {entity_set.permissions.write && ( 31 - <Sidebar> 32 - <PublishButton entityID={rootEntity} /> 45 + <Sidebar className="my-0!"> 46 + {!isOnHome ? ( 47 + <AddToHomeButton /> 48 + ) : ( 49 + <PublishButton entityID={rootEntity} /> 50 + )} 51 + 33 52 <ShareOptions /> 34 53 <PostSettings /> 35 54 <ThemePopover entityID={rootEntity} />
+29 -26
app/[leaflet_id]/actions/HomeButton.tsx
··· 10 10 import { AddToHomeSmall } from "../../../components/Icons/AddToHomeSmall"; 11 11 import { HomeSmall } from "../../../components/Icons/HomeSmall"; 12 12 import { produce } from "immer"; 13 + import { addDocToHome } from "app/(home-pages)/(writer)/home/storage"; 14 + import { mutate as swrMutate } from "swr"; 13 15 14 16 export function HomeButton() { 15 17 let { permissions } = useEntitySetContext(); ··· 23 25 className="hover:no-underline" 24 26 style={{ textDecorationLine: "none !important" }} 25 27 > 26 - <ActionButton icon={<HomeSmall />} label="Go Home" /> 28 + <ActionButton icon={<HomeSmall />} label="Back Home" /> 27 29 </Link> 28 - {<AddToHomeButton />} 29 30 </> 30 31 ); 31 32 } 32 33 33 - const AddToHomeButton = (props: {}) => { 34 + export const AddToHomeButton = (props: { primary?: boolean }) => { 34 35 let { permission_token } = useReplicache(); 35 36 let { identity, mutate } = useIdentityData(); 36 37 let smoker = useSmoker(); 37 - if ( 38 - identity?.permission_token_on_homepage.find( 39 - (pth) => pth.permission_tokens.id === permission_token.id, 40 - ) || 41 - !identity 42 - ) 43 - return null; 38 + 44 39 return ( 45 40 <ActionButton 41 + primary 42 + labelOnMobile 43 + className="sm:w-full! w-fit!" 46 44 onClick={async (e) => { 47 - await addLeafletToHome(permission_token.id); 48 - mutate((identity) => { 49 - if (!identity) return; 50 - return produce<typeof identity>((draft) => { 51 - draft.permission_token_on_homepage.push({ 52 - created_at: new Date().toISOString(), 53 - archived: null, 54 - permission_tokens: { 55 - ...permission_token, 56 - leaflets_to_documents: [], 57 - leaflets_in_publications: [], 58 - }, 59 - }); 60 - })(identity); 61 - }); 45 + if (identity) { 46 + await addLeafletToHome(permission_token.id); 47 + mutate((identity) => { 48 + if (!identity) return; 49 + return produce<typeof identity>((draft) => { 50 + draft.permission_token_on_homepage.push({ 51 + created_at: new Date().toISOString(), 52 + archived: null, 53 + permission_tokens: { 54 + ...permission_token, 55 + leaflets_to_documents: [], 56 + leaflets_in_publications: [], 57 + }, 58 + }); 59 + })(identity); 60 + }); 61 + } else { 62 + addDocToHome(permission_token); 63 + swrMutate("leaflets"); 64 + } 62 65 smoker({ 63 66 position: { 64 67 x: e.clientX + 64, ··· 68 71 }); 69 72 }} 70 73 icon={<AddToHomeSmall />} 71 - label="Add to Home" 74 + label="Save to Home" 72 75 /> 73 76 ); 74 77 };
+7 -1
app/[leaflet_id]/actions/PublishButton.tsx
··· 52 52 return ( 53 53 <ActionButton 54 54 primary 55 + labelOnMobile 56 + className="sm:w-full! w-fit!" 55 57 icon={<PublishSmall className="shrink-0" />} 56 58 label={"Publish!"} 57 59 onClick={() => { ··· 116 118 return ( 117 119 <ActionButton 118 120 primary 121 + className="w-fit!" 122 + labelOnMobile 119 123 icon={<PublishSmall className="shrink-0" />} 120 124 label={isLoading ? <DotLoader /> : "Update!"} 121 125 onClick={async () => { ··· 192 196 trigger={ 193 197 <ActionButton 194 198 primary 199 + className="sm:w-full! w-fit!" 200 + labelOnMobile 195 201 icon={<PublishSmall className="shrink-0" />} 196 - label={"Publish"} 202 + label={"Publish!"} 197 203 /> 198 204 } 199 205 >
+2
app/[leaflet_id]/actions/ShareOptions/index.tsx
··· 63 63 trigger={ 64 64 <ActionButton 65 65 icon=<ShareSmall /> 66 + className="sm:w-full! w-fit!" 66 67 secondary 67 68 label={`Share ${pub ? "Draft" : ""}`} 69 + labelOnMobile 68 70 /> 69 71 } 70 72 >
+1 -1
app/[leaflet_id]/publish/PublishPost.tsx
··· 34 34 PublicationBackgroundProvider, 35 35 } from "components/ThemeManager/PublicationThemeProvider"; 36 36 import { useEntity } from "src/replicache"; 37 - import { LeafletContent } from "app/(home-pages)/home/LeafletList/LeafletContent"; 37 + import { LeafletContent } from "app/(home-pages)/(writer)/home/LeafletList/LeafletContent"; 38 38 39 39 type Props = { 40 40 title: string;
+37
app/globals.css
··· 486 486 @apply rounded-md; 487 487 } 488 488 489 + .light-container { 490 + background: color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 95%); 491 + @apply border; 492 + @apply border-border-light; 493 + @apply rounded-md; 494 + } 495 + 489 496 .frosted-container { 490 497 background: rgba(var(--bg-page), 0.75); 491 498 @apply border; ··· 536 543 } 537 544 .pwa-padding-bottom { 538 545 padding-bottom: var(--safe-padding-bottom); 546 + } 547 + 548 + .pwa-padding-x { 549 + @media (display-mode: standalone) { 550 + margin-left: 1rem; 551 + margin-right: 1rem; 552 + } 539 553 } 540 554 541 555 /* animation for atmosphere logo carousel */ ··· 617 631 opacity: 1; 618 632 transform: translateX(0); 619 633 } 634 + } 635 + 636 + @keyframes mobile-sidebar-slide-in { 637 + from { 638 + transform: translateX(-100%); 639 + } 640 + to { 641 + transform: translateX(0); 642 + } 643 + } 644 + @keyframes mobile-sidebar-slide-out { 645 + from { 646 + transform: translateX(0); 647 + } 648 + to { 649 + transform: translateX(-100%); 650 + } 651 + } 652 + .mobile-sidebar-content[data-state="open"] { 653 + animation: mobile-sidebar-slide-in 80ms ease-out; 654 + } 655 + .mobile-sidebar-content[data-state="closed"] { 656 + animation: mobile-sidebar-slide-out 80ms ease-in; 620 657 } 621 658 622 659 .footnote-side-item {
+1
app/lish/[did]/[publication]/UpgradeModal.tsx
··· 1 + "use client"; 1 2 import { ButtonPrimary } from "components/Buttons"; 2 3 import { Modal } from "components/Modal"; 3 4 import { useState } from "react";
+2 -7
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 9 9 import { useSmoker } from "components/Toast"; 10 10 import { useIsMobile } from "src/hooks/isMobile"; 11 11 import { SpeedyLink } from "components/SpeedyLink"; 12 - import { ButtonSecondary, ButtonTertiary } from "components/Buttons"; 13 - import { UpgradeModal } from "../UpgradeModal"; 14 - import { LeafletPro } from "components/Icons/LeafletPro"; 15 - import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; 16 12 17 13 export const Actions = (props: { publication: string }) => { 18 - let isPro = useIsPro(); 19 - let canSeePro = useCanSeePro(); 20 14 return ( 21 15 <> 22 16 <NewDraftActionButton publication={props.publication} /> ··· 38 32 asChild 39 33 trigger={ 40 34 <ActionButton 35 + secondary 41 36 id="pub-share-button" 42 37 icon=<ShareSmall /> 43 38 label="Share" 44 39 onClick={() => {}} 45 - smallOnMobile 40 + className="w-full" 46 41 /> 47 42 } 48 43 >
+1 -1
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 5 5 usePublicationData, 6 6 useNormalizedPublicationRecord, 7 7 } from "./PublicationSWRProvider"; 8 - import { LeafletList } from "app/(home-pages)/home/HomeLayout"; 8 + import { LeafletList } from "app/(home-pages)/(writer)/home/HomeLayout"; 9 9 10 10 export function DraftList(props: { 11 11 searchValue: string;
+51
app/lish/[did]/[publication]/dashboard/DraftsTab.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 5 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 6 + import { DraftList } from "./DraftList"; 7 + import { NewDraftActionButton } from "./NewDraftButton"; 8 + import { PageSearch } from "components/PageLayouts/PageSearch"; 9 + import { 10 + usePublicationData, 11 + useNormalizedPublicationRecord, 12 + } from "./PublicationSWRProvider"; 13 + 14 + export function DraftsTab() { 15 + let { data } = usePublicationData(); 16 + let record = useNormalizedPublicationRecord(); 17 + let pubUri = data?.publication?.uri || ""; 18 + 19 + let [searchValue, setSearchValue] = useState(""); 20 + let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); 21 + useDebouncedEffect( 22 + () => setDebouncedSearchValue(searchValue), 23 + 200, 24 + [searchValue], 25 + ); 26 + 27 + const showPageBackground = !!record?.theme?.showPageBackground; 28 + 29 + return ( 30 + <DashboardPageLayout 31 + scrollKey={`dashboard-${pubUri}-Drafts`} 32 + pageTitle="Drafts" 33 + mobileActions={<NewDraftActionButton publication={pubUri} compact />} 34 + search={ 35 + <PageSearch 36 + defaultDisplay="list" 37 + hasBackgroundImage={!!record?.theme?.backgroundImage} 38 + searchValue={searchValue} 39 + setSearchValueAction={setSearchValue} 40 + /> 41 + } 42 + publication={pubUri} 43 + showHeader={true} 44 + > 45 + <DraftList 46 + searchValue={debouncedSearchValue} 47 + showPageBackground={showPageBackground} 48 + /> 49 + </DashboardPageLayout> 50 + ); 51 + }
+19 -8
app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
··· 1 1 "use client"; 2 2 import { createPublicationDraft } from "actions/createPublicationDraft"; 3 3 import { ActionButton } from "components/ActionBar/ActionButton"; 4 - import { ButtonSecondary } from "components/Buttons"; 4 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 5 5 import { AddTiny } from "components/Icons/AddTiny"; 6 6 import { useRouter } from "next/navigation"; 7 7 8 - export function NewDraftActionButton(props: { publication: string }) { 8 + export function NewDraftActionButton(props: { 9 + publication: string; 10 + compact?: boolean; 11 + }) { 9 12 let router = useRouter(); 10 13 14 + async function handleOnClick() { 15 + let newLeaflet = await createPublicationDraft(props.publication); 16 + router.push(`/${newLeaflet}`); 17 + } 18 + 19 + if (props.compact) 20 + return ( 21 + <ButtonPrimary compact className="text-sm!" onClick={handleOnClick}> 22 + <AddTiny className="scale-90" /> Draft 23 + </ButtonPrimary> 24 + ); 11 25 return ( 12 26 <ActionButton 13 27 id="new-leaflet-button" 14 28 primary 15 - onClick={async () => { 16 - let newLeaflet = await createPublicationDraft(props.publication); 17 - router.push(`/${newLeaflet}`); 18 - }} 19 - icon=<AddTiny className="sm:m-1 shrink-0 sm:scale-100 scale-75" /> 20 - smallOnMobile 29 + onClick={handleOnClick} 30 + icon=<AddTiny className="m-1" /> 31 + className="w-full" 21 32 label="Draft" 22 33 /> 23 34 );
-109
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 1 - "use client"; 2 - 3 - import { DraftList } from "./DraftList"; 4 - import { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; 5 - import { Actions } from "./Actions"; 6 - import React, { useState } from "react"; 7 - import { PublishedPostsList } from "./PublishedPostsLists"; 8 - import { PublicationSubscribers } from "./PublicationSubscribers"; 9 - import { 10 - DashboardLayout, 11 - PublicationDashboardControls, 12 - SubscriberDashboardControls, 13 - } from "components/PageLayouts/DashboardLayout"; 14 - import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 15 - import { type NormalizedPublication } from "src/utils/normalizeRecords"; 16 - import { PublicationAnalytics } from "./PublicationAnalytics"; 17 - import { useCanSeePro } from "src/hooks/useEntitlement"; 18 - import { SettingsContent } from "./settings/SettingsContent"; 19 - import { SettingsTiny } from "components/Icons/SettingsTiny"; 20 - 21 - export default function PublicationDashboard({ 22 - publication, 23 - record, 24 - }: { 25 - record: NormalizedPublication; 26 - publication: Exclude< 27 - GetPublicationDataReturnType["result"]["publication"], 28 - null 29 - >; 30 - }) { 31 - let canSeePro = useCanSeePro(); 32 - let [searchValue, setSearchValue] = useState(""); 33 - let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); 34 - 35 - useDebouncedEffect( 36 - () => { 37 - setDebouncedSearchValue(searchValue); 38 - }, 39 - 200, 40 - [searchValue], 41 - ); 42 - 43 - return ( 44 - <DashboardLayout 45 - id={publication.uri} 46 - defaultTab="Drafts" 47 - tabs={{ 48 - Drafts: { 49 - content: ( 50 - <DraftList 51 - searchValue={debouncedSearchValue} 52 - showPageBackground={!!record.theme?.showPageBackground} 53 - /> 54 - ), 55 - controls: ( 56 - <PublicationDashboardControls 57 - defaultDisplay={"list"} 58 - hasBackgroundImage={!!record?.theme?.backgroundImage} 59 - searchValue={searchValue} 60 - setSearchValueAction={setSearchValue} 61 - /> 62 - ), 63 - }, 64 - Posts: { 65 - content: ( 66 - <PublishedPostsList 67 - searchValue={debouncedSearchValue} 68 - showPageBackground={!!record.theme?.showPageBackground} 69 - /> 70 - ), 71 - controls: null, 72 - }, 73 - Subs: { 74 - content: ( 75 - <PublicationSubscribers 76 - showPageBackground={!!record.theme?.showPageBackground} 77 - /> 78 - ), 79 - controls: <SubscriberDashboardControls />, 80 - }, 81 - ...(canSeePro 82 - ? { 83 - Analytics: { 84 - content: ( 85 - <PublicationAnalytics 86 - showPageBackground={!!record.theme?.showPageBackground} 87 - /> 88 - ), 89 - controls: null, 90 - }, 91 - } 92 - : {}), 93 - Settings: { 94 - icon: <SettingsTiny />, 95 - content: ( 96 - <SettingsContent 97 - showPageBackground={!!record.theme?.showPageBackground} 98 - /> 99 - ), 100 - controls: null, 101 - }, 102 - }} 103 - actions={<Actions publication={publication.uri} />} 104 - currentPage="pub" 105 - publication={publication.uri} 106 - pageTitle={record.name} 107 - /> 108 - ); 109 - }
+1 -1
app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
··· 7 7 import { Separator } from "components/Layout"; 8 8 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 9 9 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 - import { useDashboardState } from "components/PageLayouts/DashboardLayout"; 10 + import { useDashboardState } from "components/PageLayouts/dashboardState"; 11 11 12 12 type subscriber = { email: string | undefined; did: string | undefined }; 13 13
+2 -2
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 17 17 import { SpeedyLink } from "components/SpeedyLink"; 18 18 import { InteractionPreview } from "components/InteractionsPreview"; 19 19 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 20 - import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; 20 + import { LeafletOptions } from "app/(home-pages)/(writer)/home/LeafletList/LeafletOptions"; 21 21 import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; 22 22 23 23 export function PublishedPostsList(props: { ··· 43 43 }); 44 44 45 45 return ( 46 - <div className="publishedList w-full flex flex-col gap-2 pb-4"> 46 + <div className="publishedList w-full flex flex-col gap-2 pt-3 pb-6"> 47 47 {sortedDocuments.map((doc) => ( 48 48 <PublishedPostItem 49 49 key={doc.uri}
+39
app/lish/[did]/[publication]/dashboard/analytics/page.tsx
··· 1 + "use client"; 2 + 3 + import { useRouter } from "next/navigation"; 4 + import { useEffect } from "react"; 5 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 6 + import { PublicationAnalytics } from "../PublicationAnalytics"; 7 + import { NewDraftActionButton } from "../NewDraftButton"; 8 + import { 9 + usePublicationData, 10 + useNormalizedPublicationRecord, 11 + } from "../PublicationSWRProvider"; 12 + import { useCanSeePro } from "src/hooks/useEntitlement"; 13 + 14 + export default function AnalyticsPage() { 15 + let canSeePro = useCanSeePro(); 16 + let router = useRouter(); 17 + let { data } = usePublicationData(); 18 + let record = useNormalizedPublicationRecord(); 19 + let pubUri = data?.publication?.uri || ""; 20 + const showPageBackground = !!record?.theme?.showPageBackground; 21 + 22 + useEffect(() => { 23 + if (canSeePro === false) router.replace("../"); 24 + }, [canSeePro, router]); 25 + 26 + if (!canSeePro) return null; 27 + 28 + return ( 29 + <DashboardPageLayout 30 + scrollKey={`dashboard-${pubUri}-Analytics`} 31 + pageTitle="Analytics" 32 + mobileActions={<NewDraftActionButton publication={pubUri} compact />} 33 + publication={pubUri} 34 + showHeader={false} 35 + > 36 + <PublicationAnalytics showPageBackground={showPageBackground} /> 37 + </DashboardPageLayout> 38 + ); 39 + }
+133
app/lish/[did]/[publication]/dashboard/layout.tsx
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { Metadata } from "next"; 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 5 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 6 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 7 + import { LoginModal } from "components/LoginButton"; 8 + import { AtUri } from "@atproto/syntax"; 9 + import { PageTitle } from "components/ActionBar/DesktopNavigation"; 10 + import { SettingsSmall } from "components/Icons/SettingsSmall"; 11 + import { DashboardShell } from "components/PageLayouts/DashboardShell"; 12 + import { PublicationThemeProviderDashboard } from "components/ThemeManager/PublicationThemeProvider"; 13 + import { Actions } from "./Actions"; 14 + import { PublicationSWRDataProvider } from "./PublicationSWRProvider"; 15 + import { AnalyticsSmall } from "components/Icons/AnalyticsSmall"; 16 + import { SubscribersSmall } from "components/Icons/SubscribersSmall"; 17 + import { PublishSmall } from "components/Icons/PublishSmall"; 18 + import { ArchiveSmall } from "components/Icons/ArchiveSmall"; 19 + 20 + export async function generateMetadata(props: { 21 + params: Promise<{ publication: string; did: string }>; 22 + }): Promise<Metadata> { 23 + let did = decodeURIComponent((await props.params).did); 24 + if (!did) return { title: "Publication 404" }; 25 + 26 + let { result: publication_data } = await get_publication_data.handler( 27 + { 28 + did, 29 + publication_name: decodeURIComponent((await props.params).publication), 30 + }, 31 + { supabase: supabaseServerClient }, 32 + ); 33 + let { publication } = publication_data; 34 + const record = normalizePublicationRecord(publication?.record); 35 + if (!publication) return { title: "404 Publication" }; 36 + return { title: record?.name || "Untitled Publication" }; 37 + } 38 + 39 + export default async function PublicationDashboardLayout(props: { 40 + children: React.ReactNode; 41 + params: Promise<{ publication: string; did: string }>; 42 + }) { 43 + let params = await props.params; 44 + let identity = await getIdentityData(); 45 + if (!identity || !identity.atp_did) { 46 + return ( 47 + <NotFoundLayout> 48 + <p> 49 + Looks like you&apos;re not logged in.{" "} 50 + <LoginModal 51 + redirectRoute={`/lish/${params.did}/${params.publication}/dashboard`} 52 + trigger={ 53 + <div className="text-accent-contrast font-bold">Log in here</div> 54 + } 55 + /> 56 + ! 57 + </p> 58 + <p> 59 + If the issue persists please{" "} 60 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 61 + </p> 62 + </NotFoundLayout> 63 + ); 64 + } 65 + 66 + let did = decodeURIComponent(params.did); 67 + if (!did) return <PubNotFound />; 68 + 69 + let { result: publication_data } = await get_publication_data.handler( 70 + { 71 + did, 72 + publication_name: decodeURIComponent(params.publication), 73 + }, 74 + { supabase: supabaseServerClient }, 75 + ); 76 + let { publication } = publication_data; 77 + const record = normalizePublicationRecord(publication?.record); 78 + 79 + if ( 80 + !publication || 81 + identity.atp_did !== publication.identity_did || 82 + !record 83 + ) { 84 + return <PubNotFound />; 85 + } 86 + 87 + let uri = new AtUri(publication.uri); 88 + let baseHref = `/lish/${params.did}/${params.publication}/dashboard`; 89 + 90 + return ( 91 + <PublicationSWRDataProvider 92 + publication_did={uri.host} 93 + publication_rkey={uri.rkey} 94 + publication_data={publication_data} 95 + > 96 + <PublicationThemeProviderDashboard> 97 + <DashboardShell 98 + id={publication.uri} 99 + publication={publication.uri} 100 + pageTitle={<PageTitle pageTitle={record.name} showBackButton />} 101 + actions={<Actions publication={publication.uri} />} 102 + tabs={{ 103 + Drafts: { href: baseHref, icon: <ArchiveSmall /> }, 104 + Posts: { href: `${baseHref}/posts`, icon: <PublishSmall /> }, 105 + Subs: { href: `${baseHref}/subs`, icon: <SubscribersSmall /> }, 106 + Analytics: { 107 + href: `${baseHref}/analytics`, 108 + icon: <AnalyticsSmall />, 109 + }, 110 + Settings: { 111 + href: `${baseHref}/settings`, 112 + icon: <SettingsSmall />, 113 + }, 114 + }} 115 + > 116 + {props.children} 117 + </DashboardShell> 118 + </PublicationThemeProviderDashboard> 119 + </PublicationSWRDataProvider> 120 + ); 121 + } 122 + 123 + const PubNotFound = () => { 124 + return ( 125 + <NotFoundLayout> 126 + <p className="font-bold">Sorry, we can&apos;t find this publication!</p> 127 + <p> 128 + This may be a glitch on our end. If the issue persists please{" "} 129 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 130 + </p> 131 + </NotFoundLayout> 132 + ); 133 + };
+14 -95
app/lish/[did]/[publication]/dashboard/page.tsx
··· 1 - import { supabaseServerClient } from "supabase/serverClient"; 2 - import { Metadata } from "next"; 3 - import { getIdentityData } from "actions/getIdentityData"; 4 - import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 5 - import { PublicationSWRDataProvider } from "./PublicationSWRProvider"; 6 - import { PublicationThemeProviderDashboard } from "components/ThemeManager/PublicationThemeProvider"; 7 - import { AtUri } from "@atproto/syntax"; 8 - import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 9 - import PublicationDashboard from "./PublicationDashboard"; 10 - import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 11 - import { LoginModal } from "components/LoginButton"; 1 + import { redirect } from "next/navigation"; 2 + import { DraftsTab } from "./DraftsTab"; 12 3 13 - export async function generateMetadata(props: { 4 + export default async function DashboardIndex(props: { 14 5 params: Promise<{ publication: string; did: string }>; 15 - }): Promise<Metadata> { 16 - let did = decodeURIComponent((await props.params).did); 17 - if (!did) return { title: "Publication 404" }; 18 - 19 - let { result: publication_data } = await get_publication_data.handler( 20 - { 21 - did, 22 - publication_name: decodeURIComponent((await props.params).publication), 23 - }, 24 - { supabase: supabaseServerClient }, 25 - ); 26 - let { publication } = publication_data; 27 - const record = normalizePublicationRecord(publication?.record); 28 - if (!publication) return { title: "404 Publication" }; 29 - return { title: record?.name || "Untitled Publication" }; 30 - } 31 - 32 - //This is the admin dashboard of the publication 33 - export default async function Publication(props: { 34 - params: Promise<{ publication: string; did: string }>; 6 + searchParams: Promise<{ tab?: string }>; 35 7 }) { 36 - let params = await props.params; 37 - let identity = await getIdentityData(); 38 - if (!identity || !identity.atp_did) 39 - return ( 40 - <NotFoundLayout> 41 - <p> 42 - Looks like you&apos;re not logged in.{" "} 43 - <LoginModal 44 - redirectRoute={`/lish/${params.did}/${params.publication}/dashboard`} 45 - trigger={ 46 - <div className="text-accent-contrast font-bold">Log in here</div> 47 - } 48 - /> 49 - ! 50 - </p> 51 - <p> 52 - If the issue persists please{" "} 53 - <a href="mailto:contact@leaflet.pub">send us a note</a>. 54 - </p> 55 - </NotFoundLayout> 56 - ); 57 - let did = decodeURIComponent(params.did); 58 - if (!did) return <PubNotFound />; 59 - let { result: publication_data } = await get_publication_data.handler( 60 - { 61 - did, 62 - publication_name: decodeURIComponent((await props.params).publication), 63 - }, 64 - { supabase: supabaseServerClient }, 65 - ); 66 - let { publication, leaflet_data } = publication_data; 67 - const record = normalizePublicationRecord(publication?.record); 8 + const { tab } = await props.searchParams; 9 + if (tab && tab !== "Drafts") { 10 + const params = await props.params; 11 + const base = `/lish/${params.did}/${params.publication}/dashboard`; 12 + if (tab === "Posts") redirect(`${base}/posts`); 13 + if (tab === "Subs") redirect(`${base}/subs`); 14 + if (tab === "Analytics") redirect(`${base}/analytics`); 15 + if (tab === "Settings") redirect(`${base}/settings`); 16 + } 68 17 69 - if (!publication || identity.atp_did !== publication.identity_did || !record) 70 - return <PubNotFound />; 71 - let uri = new AtUri(publication.uri); 72 - 73 - try { 74 - return ( 75 - <PublicationSWRDataProvider 76 - publication_did={did} 77 - publication_rkey={uri.rkey} 78 - publication_data={publication_data} 79 - > 80 - <PublicationThemeProviderDashboard> 81 - <PublicationDashboard publication={publication} record={record} /> 82 - </PublicationThemeProviderDashboard> 83 - </PublicationSWRDataProvider> 84 - ); 85 - } catch (e) { 86 - return <pre>{JSON.stringify(e, undefined, 2)}</pre>; 87 - } 18 + return <DraftsTab />; 88 19 } 89 - 90 - const PubNotFound = () => { 91 - return ( 92 - <NotFoundLayout> 93 - <p className="font-bold">Sorry, we can't find this publication!</p> 94 - <p> 95 - This may be a glitch on our end. If the issue persists please{" "} 96 - <a href="mailto:contact@leaflet.pub">send us a note</a>. 97 - </p> 98 - </NotFoundLayout> 99 - ); 100 - };
+42
app/lish/[did]/[publication]/dashboard/posts/page.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 5 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 6 + import { PublishedPostsList } from "../PublishedPostsLists"; 7 + import { NewDraftActionButton } from "../NewDraftButton"; 8 + import { 9 + usePublicationData, 10 + useNormalizedPublicationRecord, 11 + } from "../PublicationSWRProvider"; 12 + 13 + export default function PostsPage() { 14 + let { data } = usePublicationData(); 15 + let record = useNormalizedPublicationRecord(); 16 + let pubUri = data?.publication?.uri || ""; 17 + 18 + let [searchValue, setSearchValue] = useState(""); 19 + let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); 20 + useDebouncedEffect( 21 + () => setDebouncedSearchValue(searchValue), 22 + 200, 23 + [searchValue], 24 + ); 25 + 26 + const showPageBackground = !!record?.theme?.showPageBackground; 27 + 28 + return ( 29 + <DashboardPageLayout 30 + scrollKey={`dashboard-${pubUri}-Posts`} 31 + pageTitle="Posts" 32 + mobileActions={<NewDraftActionButton publication={pubUri} compact />} 33 + publication={pubUri} 34 + showHeader={false} 35 + > 36 + <PublishedPostsList 37 + searchValue={debouncedSearchValue} 38 + showPageBackground={showPageBackground} 39 + /> 40 + </DashboardPageLayout> 41 + ); 42 + }
+7 -3
app/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx
··· 1 + "use client"; 1 2 import { useEffect, useMemo, useState } from "react"; 2 3 import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 3 4 import { createBillingPortalSession } from "actions/createBillingPortalSession"; ··· 171 172 </div> 172 173 173 174 <div className="flex flex-col gap-1"> 174 - <label className="text-secondary font-bold" htmlFor="newsletterReplyTo"> 175 + <label 176 + className="text-secondary font-bold" 177 + htmlFor="newsletterReplyTo" 178 + > 175 179 Reply-to Email{" "} 176 180 <span className="font-normal text-tertiary">(optional)</span> 177 181 </label> 178 182 <p className="text-tertiary text-sm leading-snug"> 179 - Where subscriber replies are sent. Leave blank to use the 180 - no-reply address ({NO_REPLY_EMAIL}). 183 + Where subscriber replies are sent. Leave blank to use the no-reply 184 + address ({NO_REPLY_EMAIL}). 181 185 </p> 182 186 <div className="flex gap-2 items-stretch max-w-prose"> 183 187 <Input
+28
app/lish/[did]/[publication]/dashboard/settings/page.tsx
··· 1 + "use client"; 2 + 3 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 4 + import { SettingsContent } from "./SettingsContent"; 5 + import { NewDraftActionButton } from "../NewDraftButton"; 6 + import { 7 + usePublicationData, 8 + useNormalizedPublicationRecord, 9 + } from "../PublicationSWRProvider"; 10 + 11 + export default function SettingsPage() { 12 + let { data } = usePublicationData(); 13 + let record = useNormalizedPublicationRecord(); 14 + let pubUri = data?.publication?.uri || ""; 15 + const showPageBackground = !!record?.theme?.showPageBackground; 16 + 17 + return ( 18 + <DashboardPageLayout 19 + scrollKey={`dashboard-${pubUri}-Settings`} 20 + pageTitle="Settings" 21 + mobileActions={<NewDraftActionButton publication={pubUri} compact />} 22 + publication={pubUri} 23 + showHeader={false} 24 + > 25 + <SettingsContent showPageBackground={showPageBackground} /> 26 + </DashboardPageLayout> 27 + ); 28 + }
+32
app/lish/[did]/[publication]/dashboard/subs/page.tsx
··· 1 + "use client"; 2 + 3 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 4 + import { SubscriberStatusFilter } from "components/PageLayouts/PageSearch"; 5 + import { PublicationSubscribers } from "../PublicationSubscribers"; 6 + import { NewDraftActionButton } from "../NewDraftButton"; 7 + import { 8 + usePublicationData, 9 + useNormalizedPublicationRecord, 10 + } from "../PublicationSWRProvider"; 11 + 12 + export default function SubsPage() { 13 + let { data } = usePublicationData(); 14 + let record = useNormalizedPublicationRecord(); 15 + let pubUri = data?.publication?.uri || ""; 16 + const showPageBackground = !!record?.theme?.showPageBackground; 17 + 18 + return ( 19 + <DashboardPageLayout 20 + scrollKey={`dashboard-${pubUri}-Subs`} 21 + pageTitle="Subscribers" 22 + mobileActions={<NewDraftActionButton publication={pubUri} compact />} 23 + publication={pubUri} 24 + showHeader={false} 25 + > 26 + <div className="flex justify-end text-sm text-tertiary"> 27 + <SubscriberStatusFilter /> 28 + </div> 29 + <PublicationSubscribers showPageBackground={showPageBackground} /> 30 + </DashboardPageLayout> 31 + ); 32 + }
+1 -1
app/lish/[did]/[publication]/theme-settings/ThemeSettingsContent.tsx
··· 46 46 changes, 47 47 } = state; 48 48 49 - let settingsHref = `/lish/${params.did}/${params.publication}/dashboard?tab=Settings`; 49 + let settingsHref = `/lish/${params.did}/${params.publication}/dashboard/settings`; 50 50 51 51 let hasUnsavedChanges = 52 52 changes ||
+22 -18
components/ActionBar/ActionButton.tsx
··· 15 15 label: React.ReactNode; 16 16 primary?: boolean; 17 17 secondary?: boolean; 18 - nav?: boolean; 18 + active?: boolean; 19 19 className?: string; 20 20 subtext?: string; 21 21 labelOnMobile?: boolean; 22 - smallOnMobile?: boolean; 23 22 z?: boolean; 24 23 } 25 24 >((_props, ref) => { ··· 29 28 label, 30 29 primary, 31 30 secondary, 32 - nav, 31 + active, 33 32 labelOnMobile, 34 - smallOnMobile, 35 33 subtext, 36 34 className, 37 35 ...buttonProps ··· 47 45 } 48 46 }, [sidebar, inOpenPopover]); 49 47 50 - let showLabelOnMobile = 51 - labelOnMobile !== false && (primary || secondary || nav); 48 + // let showLabelOnMobile = labelOnMobile !== false && (primary || secondary); 52 49 53 50 return ( 54 51 <button 55 52 {...buttonProps} 56 53 ref={ref} 57 54 className={` 58 - actionButton relative font-bold 55 + actionButton relative shrink-0 56 + h-fit 59 57 rounded-md border 58 + py-0.5 px-1 60 59 flex gap-2 items-start justify-start 61 - sm:w-full sm:max-w-full p-1 62 - w-max 63 - ${smallOnMobile && "sm:text-base text-sm py-0! sm:py-1! sm:h-fit h-6 items-center!"} 60 + sm:w-full sm:max-w-full ${labelOnMobile ? "w-full" : "w-fit"} 61 + outline-2 outline-transparent outline-offset-1 62 + 64 63 ${ 65 64 primary 66 - ? "bg-accent-1 border-accent-1 text-accent-2 transparent-outline sm:hover:outline-accent-contrast focus:outline-accent-1 outline-offset-1 " 65 + ? "bg-accent-1 border-accent-1 text-accent-2 sm:hover:outline-accent-contrast focus:outline-accent-1 font-bold" 67 66 : secondary 68 - ? " bg-bg-page border-accent-contrast text-accent-contrast transparent-outline focus:outline-accent-contrast sm:hover:outline-accent-contrast outline-offset-1" 69 - : nav 70 - ? "border-transparent text-secondary sm:hover:border-border justify-start! max-w-full" 71 - : "border-transparent text-accent-contrast sm:hover:border-accent-contrast" 67 + ? "border-accent-contrast sm:hover:outline-accent-contrast focus:outline-accent-1 text-accent-contrast hover:border-accent-contrast focus:border-accent-contrast font-bold" 68 + : "border-transparent text-secondary sm:hover:border-border justify-start! max-w-full" 72 69 } 70 + ${active ? "bg-bg-page! border-border-light!" : ""} 73 71 ${className} 74 72 `} 75 73 > 76 - <div className="shrink-0 flex flex-row gap-0.5">{icon}</div> 77 74 <div 78 - className={`flex flex-col ${subtext && "leading-snug"} sm:max-w-full min-w-0 mr-1 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`} 75 + className={`shrink-0 flex flex-row gap-0.5 ${!primary && !secondary && "text-secondary sm:text-tertiary"}`} 76 + > 77 + {icon} 78 + </div> 79 + <div 80 + className={`flex flex-col ${subtext && "leading-snug"} sm:max-w-full min-w-0 mr-1 ${sidebar.open ? "block" : labelOnMobile ? "sm:hidden block" : "hidden"}`} 79 81 style={{ width: "-webkit-fill-available" }} 80 82 > 81 83 <div className="truncate text-left">{label}</div> 82 84 {subtext && ( 83 - <div className="text-xs text-tertiary font-normal text-left"> 85 + <div 86 + className={`text-xs ${secondary ? "text-accent-contrast" : "text-tertiary"} font-normal text-left`} 87 + > 84 88 {subtext} 85 89 </div> 86 90 )}
+219 -53
components/ActionBar/DesktopNavigation.tsx
··· 1 + "use client"; 2 + import { usePathname } from "next/navigation"; 1 3 import { useIdentityData } from "components/IdentityProvider"; 2 4 import { 3 - navPages, 4 - HomeButton, 5 + NotificationButton, 5 6 ReaderButton, 6 - NotificationButton, 7 7 WriterButton, 8 + useIsOnWriterPage, 8 9 } from "./NavigationButtons"; 9 10 import { PublicationButtons } from "./Publications"; 10 11 import { Sidebar } from "./Sidebar"; ··· 12 13 import { ProfileButton } from "./ProfileButton"; 13 14 import { ActionButton } from "./ActionButton"; 14 15 import { AccountSmall } from "components/Icons/AccountSmall"; 16 + import { TabsSmall } from "components/Icons/TabsSmall"; 17 + import { SpeedyLink } from "components/SpeedyLink"; 18 + import { GoToArrow } from "components/Icons/GoToArrow"; 19 + import { GoToArrowLined } from "components/Icons/GoToArrowLined"; 20 + import { HelpSmall } from "components/Icons/HelpSmall"; 21 + import { Popover } from "components/Popover"; 22 + import { useIsMobile } from "src/hooks/isMobile"; 23 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 24 + import { BlueskySmall } from "components/Icons/BlueskySmall"; 25 + import { Separator } from "components/Layout"; 26 + import { LeafletTiny } from "components/Icons/LeafletTiny"; 27 + import { ButtonPrimary } from "components/Buttons"; 15 28 16 - export const DesktopNavigation = (props: { 17 - currentPage: navPages; 29 + type NavigationProps = { 30 + pageTitle: React.ReactNode; 18 31 publication?: string; 19 - }) => { 32 + actions?: React.ReactNode; 33 + tabs?: { [name: string]: { href: string; icon?: React.ReactNode } }; 34 + }; 35 + 36 + function safeDecode(p: string): string { 37 + try { 38 + return decodeURIComponent(p); 39 + } catch { 40 + return p; 41 + } 42 + } 43 + 44 + function pickActiveTabHref( 45 + pathname: string, 46 + tabs: { [name: string]: { href: string } }, 47 + ): string | null { 48 + const decodedPathname = safeDecode(pathname); 49 + const hrefs = Object.values(tabs).map((t) => t.href); 50 + let best: string | null = null; 51 + for (const href of hrefs) { 52 + const decodedHref = safeDecode(href); 53 + // If this href is a strict prefix of another tab's href, only allow exact 54 + // match — otherwise a parent tab would always swallow sibling-but-unmatched 55 + // paths (e.g. /reader/new highlighting /reader's Inbox tab). 56 + const isPrefixOfAnother = hrefs.some( 57 + (other) => 58 + other !== href && safeDecode(other).startsWith(decodedHref + "/"), 59 + ); 60 + const matches = isPrefixOfAnother 61 + ? decodedPathname === decodedHref 62 + : decodedPathname === decodedHref || 63 + decodedPathname.startsWith(decodedHref + "/"); 64 + if (matches) { 65 + if (!best || href.length > best.length) best = href; 66 + } 67 + } 68 + return best; 69 + } 70 + 71 + export const NavigationContent = (props: NavigationProps) => { 20 72 let { identity } = useIdentityData(); 21 - let thisPublication = identity?.publications?.find( 22 - (pub) => pub.uri === props.publication, 23 - ); 73 + let pathname = usePathname(); 74 + let activeTabHref = props.tabs 75 + ? pickActiveTabHref(pathname, props.tabs) 76 + : null; 77 + let onWriterPage = useIsOnWriterPage(); 24 78 25 - let currentlyWriter = 26 - props.currentPage === "home" || 27 - props.currentPage === "looseleafs" || 28 - props.currentPage === "pub"; 29 79 return ( 30 - <div className="flex flex-col gap-3"> 31 - <Sidebar alwaysOpen> 80 + <> 81 + {props.pageTitle} <hr className="border-border-light mb-2" /> 82 + {props.actions && ( 83 + <> 84 + <div className="flex flex-col gap-1">{props.actions}</div> 85 + {props.tabs && <hr className="border-border-light my-2" />} 86 + </> 87 + )} 88 + {props.tabs && ( 89 + <> 90 + {Object.entries(props.tabs).map(([name, { href, icon }]) => ( 91 + <SpeedyLink key={name} href={href} className="hover:no-underline!"> 92 + <ActionButton 93 + labelOnMobile 94 + icon={icon ?? <TabsSmall />} 95 + label={name} 96 + active={href === activeTabHref} 97 + /> 98 + </SpeedyLink> 99 + ))} 100 + </> 101 + )} 102 + {onWriterPage && <PublicationButtons />} 103 + <div className="flex-1" /> 104 + <WriterButton /> 105 + <ReaderButton 106 + subs={ 107 + identity?.publication_subscriptions?.length !== 0 && 108 + identity?.publication_subscriptions?.length !== undefined 109 + } 110 + /> 111 + {identity?.atp_did && <NotificationButton />} 112 + <div className="flex gap-1 items-center"> 32 113 {identity ? ( 33 114 <> 34 - <ProfileButton /> 35 - {identity.atp_did && ( 36 - <NotificationButton 37 - current={props.currentPage === "notifications"} 38 - /> 39 - )} 115 + <hr className="border-border-light my-1" /> 116 + <div className="grow min-w-0"> 117 + <ProfileButton /> 118 + </div> 40 119 </> 41 120 ) : ( 42 - <LoginModal 43 - asChild 44 - trigger={ 45 - <ActionButton 46 - secondary 47 - icon={<AccountSmall />} 48 - label="Log In/Sign Up" 49 - /> 50 - } 51 - /> 121 + <div className="grow min-w-0"> 122 + <LoginModal 123 + asChild 124 + trigger={ 125 + <ActionButton 126 + className="w-full! grow" 127 + secondary 128 + icon={<AccountSmall />} 129 + label="Log In/Sign Up" 130 + /> 131 + } 132 + /> 133 + </div> 52 134 )} 53 - </Sidebar> 135 + <HelpPopover /> 136 + </div> 137 + </> 138 + ); 139 + }; 54 140 55 - <Sidebar alwaysOpen> 56 - <ReaderButton 57 - current={props.currentPage === "reader"} 58 - subs={ 59 - identity?.publication_subscriptions?.length !== 0 && 60 - identity?.publication_subscriptions?.length !== undefined 61 - } 62 - /> 63 - <WriterButton 64 - currentPage={props.currentPage} 65 - currentPubUri={thisPublication?.uri} 66 - /> 67 - {currentlyWriter && ( 68 - <> 69 - <hr className="border-border-light border-dashed" /> 70 - <PublicationButtons 71 - currentPage={props.currentPage} 72 - currentPubUri={thisPublication?.uri} 141 + const HelpPopover = () => { 142 + let isMobile = useIsMobile(); 143 + return ( 144 + <Popover 145 + asChild 146 + side={isMobile ? "top" : "right"} 147 + align={isMobile ? "center" : "start"} 148 + className="w-xs max-sw-full" 149 + trigger={ 150 + <button 151 + type="button" 152 + aria-label="About Leaflet" 153 + className="shrink-0 pr-2 text-tertiary" 154 + > 155 + <HelpSmall /> 156 + </button> 157 + } 158 + > 159 + <div className="flex flex-col text-secondary text-center pt-2 "> 160 + <h3>Welcome to Leaflet!</h3> 161 + <div className="pb-3"> 162 + An expressive tool for publishing blogs and newsletters 163 + </div> 164 + <div className="flex flex-col gap-1 "> 165 + <a 166 + href="https://bsky.app/profile/leaflet.pub" 167 + target="_blank" 168 + rel="noreferrer" 169 + className="no-underline!" 170 + > 171 + <ButtonPrimary 172 + compact 173 + fullWidth 174 + className="bg-[#1281F6]! border-[#1281F6]! hover:outline-[#1281F6]! text-white!" 175 + > 176 + <BlueskyTiny className="shrink-0" /> 177 + Follow us on Bluesky 178 + </ButtonPrimary> 179 + </a> 180 + 181 + <a 182 + href="https://buttondown.com/leaflet" 183 + target="_blank" 184 + rel="noreferrer" 185 + className="no-underline!" 186 + > 187 + <ButtonPrimary 188 + compact 189 + fullWidth 190 + className="bg-[#57822B]! border-[#57822B]! hover:outline-[#57822B]! text-white!" 191 + > 192 + <LeafletTiny className="shrink-0" /> Sign up for our Newsletter 193 + </ButtonPrimary> 194 + </a> 195 + </div> 196 + <hr className="mt-3 mb-1 border-border-light" /> 197 + <div className="text-sm flex gap-4 mx-auto pb-1"> 198 + <SpeedyLink href="/legal" target="_blank"> 199 + Terms 200 + </SpeedyLink> 201 + {/* 202 + THIS SHOULD GO TO THE LANDING PAGE 203 + <SpeedyLink href="/about" target="_blank"> 204 + Learn More 205 + </SpeedyLink> 206 + */} 207 + <a href="mailto:contact@leaflet.pub" target="_blank" rel="noreferrer"> 208 + Contact 209 + </a> 210 + </div> 211 + </div> 212 + </Popover> 213 + ); 214 + }; 215 + 216 + export const PageTitle = (props: { 217 + pageTitle: string; 218 + showBackButton?: boolean; 219 + }) => { 220 + return ( 221 + <div className="flex gap-2 w-full px-1 py-0.5 items-center "> 222 + {props.showBackButton && ( 223 + <SpeedyLink href={"/home"} className="flex items-center"> 224 + <button> 225 + <GoToArrowLined 226 + className="accent-accent-contrast rotate-180 shrink-0" 227 + aria-label="Go Back" 73 228 /> 74 - </> 75 - )} 76 - </Sidebar> 229 + </button> 230 + </SpeedyLink> 231 + )} 232 + <div className="truncate min-w-0 text-tertiary uppercase text-sm font-bold"> 233 + {props.pageTitle} 234 + </div> 77 235 </div> 78 236 ); 79 237 }; 238 + 239 + export const DesktopNavigation = (props: NavigationProps) => { 240 + return ( 241 + <Sidebar alwaysOpen> 242 + <NavigationContent {...props} /> 243 + </Sidebar> 244 + ); 245 + };
+156 -61
components/ActionBar/MobileNavigation.tsx
··· 1 - import { useIdentityData } from "components/IdentityProvider"; 1 + "use client"; 2 + import { useEffect, useRef, useState } from "react"; 3 + 4 + import { MediaContents } from "components/Media"; 2 5 import { Separator } from "components/Layout"; 3 - import { 4 - navPages, 5 - NotificationButton, 6 - ReaderButton, 7 - WriterButton, 8 - } from "./NavigationButtons"; 9 - import { PublicationNavigation } from "./PublicationNavigation"; 10 - import { LoginModal } from "components/LoginButton"; 11 - import { ProfileButton } from "./ProfileButton"; 12 - import { ActionButton } from "./ActionButton"; 13 - import { AccountSmall } from "components/Icons/AccountSmall"; 6 + import { MenuSmall } from "components/Icons/MenuSmall"; 7 + import { CloseTiny } from "components/Icons/CloseTiny"; 8 + import { useSidebarStore } from "./Sidebar"; 9 + import { SearchTiny } from "components/Icons/SearchTiny"; 10 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 11 + import { useIdentityData } from "components/IdentityProvider"; 14 12 15 13 export const MobileNavigation = (props: { 16 - currentPage: navPages; 17 - currentPublicationUri?: string; 18 - currentProfileDid?: string; 14 + search?: React.ReactNode; 15 + mobileActions?: React.ReactNode; 16 + pageTitle: string; 17 + hiddenOnScroll?: boolean; 19 18 }) => { 20 - let { identity } = useIdentityData(); 19 + let [state, setState] = useState<"search" | "default">("default"); 20 + let [hidden, setHidden] = useState(false); 21 + let [distFromBottom, setDistFromBottom] = useState(1000); 22 + let lastScrollY = useRef(0); 23 + let cardBorderHidden = useCardBorderHidden(); 24 + let hiddenOnScroll = props.hiddenOnScroll; 25 + 26 + useEffect(() => { 27 + const homeContent = document.getElementById("home-content"); 28 + if (!homeContent) return; 29 + 30 + const computeDist = () => 31 + Math.max( 32 + 0, 33 + homeContent.scrollHeight - 34 + homeContent.clientHeight - 35 + homeContent.scrollTop, 36 + ); 37 + 38 + setDistFromBottom(computeDist()); 39 + 40 + const handleScroll = () => { 41 + const currentScrollY = homeContent.scrollTop; 42 + const dist = computeDist(); 43 + const delta = currentScrollY - lastScrollY.current; 44 + lastScrollY.current = currentScrollY; 45 + setDistFromBottom(dist); 46 + 47 + if (!hiddenOnScroll) { 48 + setHidden(false); 49 + return; 50 + } 51 + 52 + if (dist <= 0) { 53 + setHidden(false); 54 + return; 55 + } 56 + 57 + if (delta > 8) { 58 + setHidden(true); 59 + } else if (delta < -1) { 60 + setHidden(false); 61 + } 62 + }; 21 63 22 - let compactOnMobile = 23 - props.currentPage === "home" || 24 - props.currentPage === "looseleafs" || 25 - props.currentPage === "pub"; 64 + homeContent.addEventListener("scroll", handleScroll, { passive: true }); 65 + return () => homeContent.removeEventListener("scroll", handleScroll); 66 + }, [hiddenOnScroll]); 67 + 68 + let headerBGColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; 69 + let atBottom = distFromBottom < 20; 26 70 27 71 return ( 28 - <div 29 - className={`mobileFooter w-full flex gap-4 px-1 text-secondary grow items-center justify-between`} 72 + <MediaContents 73 + mobile={true} 74 + className={`mobilePageHeader z-20 fixed left-0 bottom-6 right-0 transition-transform duration-200 ${hidden ? "translate-y-[120px]" : ""}`} 75 + style={{ bottom: "var(--safe-padding-bottom)" }} 30 76 > 31 - <div className="mobileNav flex gap-2 items-center justify-start min-w-0"> 32 - <ReaderButton 33 - compactOnMobile={compactOnMobile} 34 - current={props.currentPage === "reader"} 35 - subs={ 36 - identity?.publication_subscriptions?.length !== 0 && 37 - identity?.publication_subscriptions?.length !== undefined 77 + <div 78 + style={ 79 + atBottom 80 + ? { 81 + paddingLeft: `calc(${distFromBottom / 20}*8px + 12px)`, 82 + paddingRight: `calc(${distFromBottom / 20}*8px + 12px)`, 83 + } 84 + : { paddingLeft: "20px", paddingRight: "20px" } 85 + } 86 + > 87 + <div 88 + className={`mobilePageHeaderContent pwa-padding-x rounded-lg text-secondary flex gap-2 border justify-between items-center py-1 w-full ${cardBorderHidden && atBottom ? "border-transparent " : " border-border-light"}`} 89 + style={ 90 + atBottom 91 + ? { 92 + paddingLeft: cardBorderHidden 93 + ? `calc(${distFromBottom / 20}*8px)` 94 + : "8px", 95 + paddingRight: cardBorderHidden 96 + ? `calc(${distFromBottom / 20}*8px)` 97 + : "8px", 98 + backgroundColor: !cardBorderHidden 99 + ? `rgba(${headerBGColor}, ${distFromBottom / 60 + 0.75})` 100 + : `rgba(${headerBGColor}, ${distFromBottom / 20})`, 101 + } 102 + : { 103 + paddingLeft: "8px", 104 + paddingRight: "8px", 105 + backgroundColor: cardBorderHidden 106 + ? "color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 95%)" 107 + : `rgb(var(--bg-page))`, 108 + } 38 109 } 39 - /> 40 - <WriterButton 41 - compactOnMobile={compactOnMobile} 42 - currentPage={props.currentPage} 43 - currentPubUri={props.currentPublicationUri} 44 - /> 110 + > 111 + {state === "default" ? ( 112 + <> 113 + <MobileSidebarTrigger pageTitle={props.pageTitle} /> 114 + <div className="flex-1" /> 45 115 46 - {compactOnMobile && ( 47 - <> 48 - <PublicationNavigation 49 - currentPage={props.currentPage} 50 - currentPubUri={props.currentPublicationUri} 51 - /> 52 - </> 53 - )} 54 - </div> 55 - {identity ? ( 56 - <div className="flex gap-2"> 57 - {identity.atp_did && <NotificationButton />} 58 - <ProfileButton /> 116 + {props.search && ( 117 + <button 118 + onClick={() => { 119 + setState("search"); 120 + }} 121 + > 122 + <SearchTiny /> 123 + </button> 124 + )} 125 + <div className="flex flex-row-reverse! gap-1"> 126 + {props.mobileActions} 127 + </div> 128 + </> 129 + ) : ( 130 + <> 131 + {props.search} 132 + <Separator classname="h-6!" /> 133 + <button 134 + className="text-secondary" 135 + onClick={() => { 136 + setState("default"); 137 + }} 138 + > 139 + <CloseTiny /> 140 + </button> 141 + </> 142 + )} 59 143 </div> 60 - ) : ( 61 - <LoginModal 62 - asChild 63 - trigger={ 64 - <ActionButton 65 - secondary 66 - icon={<AccountSmall />} 67 - label="Log In/Sign Up" 68 - /> 69 - } 70 - /> 71 - )} 72 - </div> 144 + </div> 145 + </MediaContents> 146 + ); 147 + }; 148 + 149 + const MobileSidebarTrigger = (props: { pageTitle: string }) => { 150 + let setOpen = useSidebarStore((s) => s.setOpen); 151 + let { identity } = useIdentityData(); 152 + let unreads = identity?.notifications[0]?.count; 153 + return ( 154 + <button 155 + className="flex gap-2 items-center text-secondary font-bold" 156 + onClick={() => setOpen(true)} 157 + > 158 + <div className="relative"> 159 + <MenuSmall /> 160 + {unreads ? ( 161 + <div className="absolute left-1 -top-0.5 min-w-4 h-4 px-1 rounded-full bg-accent-1 text-accent-2 border border-bg-page text-[8px] leading-none font-bold flex items-center justify-center -translate-x-1/2"> 162 + {unreads < 100 ? unreads : "∞"} 163 + </div> 164 + ) : null} 165 + </div> 166 + {props.pageTitle} 167 + </button> 73 168 ); 74 169 };
+69 -45
components/ActionBar/NavigationButtons.tsx
··· 1 + "use client"; 2 + import { usePathname, useRouter, useSearchParams } from "next/navigation"; 1 3 import { HomeSmall } from "components/Icons/HomeSmall"; 2 4 import { ActionButton } from "./ActionButton"; 3 5 import { useIdentityData } from "components/IdentityProvider"; 4 - import { PublicationButtons } from "./Publications"; 5 6 import { ReaderUnreadSmall } from "components/Icons/ReaderSmall"; 6 7 import { 7 8 NotificationsReadSmall, 8 9 NotificationsUnreadSmall, 9 10 } from "components/Icons/NotificationSmall"; 10 11 import { SpeedyLink } from "components/SpeedyLink"; 11 - import { Popover } from "components/Popover"; 12 12 import { WriterSmall } from "components/Icons/WriterSmall"; 13 13 export type navPages = 14 14 | "home" ··· 20 20 | "profile" 21 21 | "discover"; 22 22 23 - export const HomeButton = (props: { 24 - current?: boolean; 25 - className?: string; 26 - }) => { 23 + function useIsActive(href: string) { 24 + let pathname = usePathname(); 25 + return pathname === href || pathname.startsWith(href + "/"); 26 + } 27 + 28 + export const WRITER_PATHS = ["/home", "/looseleafs", "/notifications"] as const; 29 + 30 + export function useIsOnWriterPage() { 31 + let pathname = usePathname(); 32 + return WRITER_PATHS.some((p) => pathname.startsWith(p)); 33 + } 34 + 35 + export const HomeButton = (props: { className?: string }) => { 36 + let current = useIsActive("/home"); 27 37 return ( 28 38 <SpeedyLink href={"/home"} className="hover:!no-underline"> 29 39 <ActionButton 30 - nav 31 40 icon={<HomeSmall />} 32 41 label="Home" 33 - className={`${props.current ? "bg-bg-page! border-border-light!" : ""} w-full! ${props.className}`} 42 + active={current} 43 + className={`w-full! ${props.className}`} 34 44 /> 35 45 </SpeedyLink> 36 46 ); 37 47 }; 38 48 39 - export const WriterButton = (props: { 40 - currentPage: navPages; 41 - currentPubUri?: string; 42 - compactOnMobile?: boolean; 43 - }) => { 44 - let current = 45 - props.currentPage === "home" || 46 - props.currentPage === "looseleafs" || 47 - props.currentPage === "pub"; 48 - 49 + export const WriterButton = () => { 50 + let current = useIsOnWriterPage(); 49 51 return ( 50 52 <SpeedyLink href={"/home"} className="hover:!no-underline"> 51 53 <ActionButton 52 - nav 53 - labelOnMobile={!props.compactOnMobile} 54 + className={"w-full!"} 54 55 icon={<WriterSmall />} 55 56 label="Write" 56 - className={`${current ? "bg-bg-page! border-border-light!" : ""}`} 57 + active={current} 57 58 /> 58 59 </SpeedyLink> 59 60 ); 60 61 }; 61 62 62 - export const ReaderButton = (props: { 63 - current?: boolean; 64 - subs: boolean; 65 - compactOnMobile?: boolean; 66 - }) => { 63 + export const ReaderButton = (props: { subs: boolean }) => { 64 + let current = useIsActive("/reader"); 67 65 return ( 68 66 <SpeedyLink href={"/reader"} className="hover:no-underline!"> 69 67 <ActionButton 70 - nav 71 - labelOnMobile={!props.compactOnMobile} 68 + className="w-full!" 72 69 icon={<ReaderUnreadSmall />} 73 70 label="Read" 74 - className={props.current ? "bg-bg-page! border-border-light!" : ""} 71 + active={current} 75 72 /> 76 73 </SpeedyLink> 77 74 ); 78 75 }; 79 76 80 - export function NotificationButton(props: { current?: boolean }) { 77 + export function NotificationButton() { 81 78 let { identity } = useIdentityData(); 82 79 let unreads = identity?.notifications[0]?.count; 83 80 81 + let pathname = usePathname(); 82 + let searchParams = useSearchParams(); 83 + let router = useRouter(); 84 + 85 + let isOnPage = 86 + pathname === "/notifications" || pathname.startsWith("/notifications/"); 87 + let isOpen = searchParams.get("notifications") === "open"; 88 + let active = isOnPage || isOpen; 89 + 90 + function handleClick() { 91 + if (isOnPage || isOpen) return; 92 + let params = new URLSearchParams(searchParams.toString()); 93 + params.set("notifications", "open"); 94 + router.push(`${pathname}?${params.toString()}`, { scroll: false }); 95 + } 96 + 84 97 return ( 85 - <SpeedyLink href={"/notifications"} className="hover:no-underline!"> 86 - <ActionButton 87 - nav 88 - labelOnMobile={false} 89 - icon={ 90 - unreads ? ( 91 - <NotificationsUnreadSmall className="text-accent-contrast" /> 92 - ) : ( 93 - <NotificationsReadSmall /> 94 - ) 95 - } 96 - label="Notifications" 97 - className={`${props.current ? "bg-bg-page! border-border-light!" : ""} ${unreads ? "text-accent-contrast!" : ""}`} 98 - /> 99 - </SpeedyLink> 98 + <ActionButton 99 + type="button" 100 + onClick={handleClick} 101 + labelOnMobile={false} 102 + icon={ 103 + unreads ? ( 104 + <NotificationsUnreadSmall className="text-accent-contrast" /> 105 + ) : ( 106 + <NotificationsReadSmall /> 107 + ) 108 + } 109 + label={ 110 + unreads ? ( 111 + <span className="flex items-center justify-between gap-1.5"> 112 + Notifications 113 + <span className="min-w-6 h-fit px-1 py-0.5 rounded-full bg-accent-1 text-accent-2 text-sm leading-none font-bold flex items-center justify-center max-w-full truncate"> 114 + {unreads} 115 + </span> 116 + </span> 117 + ) : ( 118 + "Notifications" 119 + ) 120 + } 121 + active={active} 122 + className={unreads ? "text-accent-contrast! font-bold w-full!" : "w-full"} 123 + /> 100 124 ); 101 125 }
+3 -3
components/ActionBar/ProfileButton.tsx
··· 1 + "use client"; 1 2 import { Avatar } from "components/Avatar"; 2 3 import { ActionButton } from "./ActionButton"; 3 4 import { useIdentityData } from "components/IdentityProvider"; ··· 29 30 asChild 30 31 side={isMobile ? "top" : "right"} 31 32 align={isMobile ? "center" : "start"} 32 - className="w-xs py-1!" 33 + className="w-xs py-1! z-[60]!" 33 34 trigger={ 34 35 <ActionButton 35 - nav 36 36 labelOnMobile={false} 37 37 icon={ 38 38 record ? ( ··· 49 49 ? record.displayName || record.handle 50 50 : identity?.email || "Account" 51 51 } 52 - className={`w-full`} 52 + className={`font-bold`} 53 53 /> 54 54 } 55 55 >
-148
components/ActionBar/PublicationNavigation.tsx
··· 1 - "use client"; 2 - import { useIdentityData } from "components/IdentityProvider"; 3 - import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL"; 4 - import { 5 - normalizePublicationRecord, 6 - type NormalizedPublication, 7 - } from "src/utils/normalizeRecords"; 8 - import { SpeedyLink } from "components/SpeedyLink"; 9 - import { Popover } from "components/Popover"; 10 - import { ButtonPrimary } from "components/Buttons"; 11 - import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 12 - import { HomeButton, type navPages } from "./NavigationButtons"; 13 - import { HomeSmall } from "components/Icons/HomeSmall"; 14 - import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 15 - import { PubIcon, PublicationButtons } from "./Publications"; 16 - import { HomeTiny } from "components/Icons/HomeTiny"; 17 - import { LooseleafTiny } from "components/Icons/LooseleafTiny"; 18 - import { Separator } from "components/Layout"; 19 - import { Menu, MenuItem } from "components/Menu"; 20 - import { AddTiny } from "components/Icons/AddTiny"; 21 - 22 - export const PublicationNavigation = (props: { 23 - currentPage: navPages; 24 - currentPubUri?: string; 25 - }) => { 26 - let { identity } = useIdentityData(); 27 - 28 - if (!identity) return; 29 - 30 - let hasLooseleafs = !!identity?.permission_token_on_homepage.find( 31 - (f) => 32 - f.permission_tokens.leaflets_to_documents && 33 - f.permission_tokens.leaflets_to_documents[0]?.document, 34 - ); 35 - 36 - let pubCount = identity?.publications.length ?? 0; 37 - let onlyOnePub = pubCount === 1 && hasLooseleafs; 38 - let onlyLooseleafs = pubCount === 0 && hasLooseleafs; 39 - let className = 40 - "font-bold text-secondary flex gap-2 items-center grow min-w-0 text-sm h-[34px] px-2 accent-container"; 41 - 42 - // if not publications or looseleafs 43 - if (!identity.publications && !hasLooseleafs) { 44 - return ( 45 - <SpeedyLink href="/lish/createPub"> 46 - <ButtonPrimary compact className="text-sm!"> 47 - Create a Publication! 48 - </ButtonPrimary> 49 - </SpeedyLink> 50 - ); 51 - } 52 - 53 - switch (props.currentPage) { 54 - case "looseleafs": 55 - case "pub": 56 - if (onlyLooseleafs || onlyOnePub) 57 - return ( 58 - <> 59 - <SpeedyLink href={`/home`} className={className}> 60 - <HomeTiny className="shrink-0" /> 61 - Home 62 - </SpeedyLink> 63 - </> 64 - ); 65 - break; 66 - case "home": { 67 - if (onlyLooseleafs || onlyOnePub) { 68 - let pub = identity.publications[0]; 69 - return ( 70 - <div className={className}> 71 - <Menu trigger={<MoreOptionsVerticalTiny className="shrink-0" />}> 72 - <SpeedyLink href="/createPub"> 73 - <MenuItem className="items-center! text-sm" onSelect={() => {}}> 74 - <AddTiny /> 75 - Create New Publication 76 - </MenuItem> 77 - </SpeedyLink> 78 - </Menu> 79 - <Separator classname="h-6!" /> 80 - {onlyLooseleafs ? ( 81 - <SpeedyLink 82 - href="/looseleafs" 83 - className="hover:no-underline! text-inherit flex gap-2 items-center pr-2 w-full min-w-0" 84 - > 85 - <LooseleafTiny className="shrink-0" /> Looseleafs 86 - </SpeedyLink> 87 - ) : ( 88 - <SpeedyLink 89 - href={`${getBasePublicationURL(pub)}/dashboard`} 90 - className="hover:no-underline! text-inherit flex gap-2 items-center pr-2 w0ull min-w-0" 91 - > 92 - <PubIcon 93 - small 94 - record={normalizePublicationRecord(pub.record)} 95 - uri={pub.uri} 96 - /> 97 - <div className="truncate min-w-0">{pub.name}</div> 98 - </SpeedyLink> 99 - )} 100 - </div> 101 - ); 102 - } 103 - break; 104 - } 105 - } 106 - 107 - return ( 108 - <Popover 109 - trigger={ 110 - <div className={className}> 111 - <PubIcons 112 - publications={identity.publications.map((pub) => ({ 113 - record: normalizePublicationRecord(pub.record), 114 - uri: pub.uri, 115 - }))} 116 - />{" "} 117 - Publications 118 - </div> 119 - } 120 - className="pt-1 px-2!" 121 - > 122 - <HomeButton current={props.currentPage === "home"} /> 123 - <hr className="my-1 border-border-light" /> 124 - <PublicationButtons 125 - currentPage={props.currentPage} 126 - currentPubUri={props.currentPubUri} 127 - /> 128 - </Popover> 129 - ); 130 - }; 131 - 132 - function PubIcons(props: { 133 - publications: { record: NormalizedPublication | null; uri: string }[]; 134 - }) { 135 - if (props.publications.length < 1) return null; 136 - return ( 137 - <div className="flex"> 138 - {props.publications.map((pub, index) => { 139 - if (index <= 2) 140 - return ( 141 - <div className="-ml-[6px] first:ml-0" key={pub.uri}> 142 - <PubIcon small record={pub.record} uri={pub.uri} /> 143 - </div> 144 - ); 145 - })} 146 - </div> 147 - ); 148 - }
+70 -100
components/ActionBar/Publications.tsx
··· 12 12 type NormalizedPublication, 13 13 } from "src/utils/normalizeRecords"; 14 14 import { SpeedyLink } from "components/SpeedyLink"; 15 - import { PublishSmall } from "components/Icons/PublishSmall"; 16 - import { Popover } from "components/Popover"; 17 15 import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 18 - import { useIsMobile } from "src/hooks/isMobile"; 19 - import { useState } from "react"; 20 16 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 21 - import { type navPages } from "./NavigationButtons"; 22 17 import { LoginModal } from "components/LoginButton"; 18 + import useSWR from "swr"; 19 + import { getHomeDocs } from "app/(home-pages)/(writer)/home/storage"; 23 20 24 - export const PublicationButtons = (props: { 25 - currentPage: navPages; 26 - currentPubUri: string | undefined; 27 - className?: string; 28 - optionClassName?: string; 29 - }) => { 21 + export const PublicationButtons = (props: { className?: string }) => { 30 22 let { identity } = useIdentityData(); 31 23 let hasLooseleafs = !!identity?.permission_token_on_homepage.find( 32 24 (f) => 33 25 f.permission_tokens.leaflets_to_documents && 34 26 f.permission_tokens.leaflets_to_documents[0]?.document, 35 27 ); 28 + let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { 29 + fallbackData: [], 30 + }); 31 + let hasDocs = identity 32 + ? identity.permission_token_on_homepage.length > 0 33 + : localLeaflets.filter((d) => !d.hidden).length > 0; 36 34 37 - // don't show pub list button if not logged in or no pub list 35 + // don't show pub list button if 36 + // no pubs or looseleafs but has docs 37 + // if they don't have docs, the empty state of the homepage prompts them to make publications 38 38 // we show a "start a pub" banner instead 39 - if (!identity || !identity.atp_did || identity.publications.length === 0) 40 - return <PubListEmpty />; 39 + console.log(hasDocs); 40 + if ( 41 + !hasLooseleafs && 42 + hasDocs && 43 + (!identity || identity.publications.length === 0) 44 + ) 45 + return <PubListEmptyContent />; 46 + 47 + if ( 48 + !hasLooseleafs && 49 + !hasDocs && 50 + (!identity || identity.publications.length === 0) 51 + ) 52 + return null; 41 53 42 54 return ( 43 - <div 44 - className={`pubListWrapper w-full flex flex-col gap-1 sm:bg-transparent sm:border-0 ${props.className}`} 45 - > 46 - {hasLooseleafs && ( 47 - <> 48 - <SpeedyLink 49 - href={`/looseleafs`} 50 - className={`flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full `} 51 - > 52 - {/*TODO How should i get if this is the current page or not? 55 + <> 56 + <hr className="border-border-light mt-2" /> 57 + 58 + <div 59 + className={`pubListWrapper w-full flex flex-col gap-1 -mt-1 sm:bg-transparent grow overflow-y-auto min-h-0 py-2 60 + ${props.className}`} 61 + > 62 + <div className="text-tertiary uppercase text-sm px-1">PUBLICATIONS</div> 63 + {hasLooseleafs && ( 64 + <> 65 + <SpeedyLink 66 + href={`/looseleafs`} 67 + className={` hover:no-underline! `} 68 + > 69 + {/*TODO How should i get if this is the current page or not? 53 70 theres not "pub" to check the uri for. Do i need to add it as an option to NavPages? thats kinda annoying*/} 54 - <ActionButton 55 - label="Looseleafs" 56 - icon={<LooseLeafSmall />} 57 - nav 58 - className={`w-full! ${ 59 - props.currentPage === "looseleafs" 60 - ? "bg-bg-page! border-border!" 61 - : "" 62 - } 63 - ${props.optionClassName}`} 64 - /> 65 - </SpeedyLink> 66 - </> 67 - )} 71 + <ActionButton 72 + labelOnMobile 73 + label="Looseleafs" 74 + icon={<LooseLeafSmall />} 75 + /> 76 + </SpeedyLink> 77 + <hr className="border-border-light border-dashed my-1" /> 78 + </> 79 + )} 80 + {identity?.publications?.map((d) => { 81 + return <PublicationOption {...d} key={d.uri} record={d.record} />; 82 + })} 68 83 69 - {identity.publications?.map((d) => { 70 - return ( 71 - <PublicationOption 72 - {...d} 73 - key={d.uri} 74 - record={d.record} 75 - current={d.uri === props.currentPubUri} 84 + <SpeedyLink 85 + href={"/lish/createPub"} 86 + className={`pubListCreateNew no-underline!`} 87 + > 88 + <ActionButton 89 + labelOnMobile 90 + icon=<div className="group-hover/new-pub:border-accent-contrast m-0.5 w-5 h-5 border-border border-2 border-dashed rounded-full" /> 91 + label="New Publication" 92 + className="text-tertiary!" 76 93 /> 77 - ); 78 - })} 79 - <Link 80 - href={"/lish/createPub"} 81 - className={`pubListCreateNew group/new-pub text-tertiary hover:text-accent-contrast flex gap-2 items-center p-1 no-underline! ${props.optionClassName}`} 82 - > 83 - <div className="group-hover/new-pub:border-accent-contrast w-6 h-6 border-border-light border-2 border-dashed rounded-full" /> 84 - New Publication 85 - </Link> 86 - </div> 94 + </SpeedyLink> 95 + </div> 96 + </> 87 97 ); 88 98 }; 89 99 ··· 91 101 uri: string; 92 102 name: string; 93 103 record: Json; 94 - current?: boolean; 95 104 className?: string; 96 105 }) => { 97 106 let record = normalizePublicationRecord(props.record); ··· 100 109 return ( 101 110 <SpeedyLink 102 111 href={`${getBasePublicationURL(props)}/dashboard`} 103 - className={`flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full `} 112 + className={`hover:no-underline! `} 104 113 > 105 114 <ActionButton 115 + labelOnMobile 106 116 label={record.name} 107 117 icon={<PubIcon record={record} uri={props.uri} />} 108 - nav 109 - className={`w-full! ${props.current ? "bg-bg-page! border-border!" : ""} ${props.className}`} 118 + className={` ${props.className}`} 110 119 /> 111 120 </SpeedyLink> 112 121 ); 113 122 }; 114 123 115 - const PubListEmpty = () => { 116 - let isMobile = useIsMobile(); 117 - 118 - let [state, setState] = useState<"default" | "info">("default"); 119 - if (isMobile && state == "default") 120 - return ( 121 - <ActionButton 122 - label="Publish" 123 - icon={<PublishSmall />} 124 - nav 125 - subtext="Start a blog on ATProto!" 126 - onClick={() => { 127 - setState("info"); 128 - }} 129 - /> 130 - ); 131 - 132 - if (isMobile && state === "info") return <PubListEmptyContent />; 133 - else 134 - return ( 135 - <Popover 136 - side="right" 137 - align="start" 138 - className="p-1! max-w-full sm:max-w-xs w-[1000px] " 139 - asChild 140 - trigger={ 141 - <ActionButton 142 - label="Publish" 143 - icon={<PublishSmall />} 144 - nav 145 - subtext="Start a blog on ATProto!" 146 - /> 147 - } 148 - > 149 - <PubListEmptyContent /> 150 - </Popover> 151 - ); 152 - }; 153 - 154 124 export const PubListEmptyContent = (props: { compact?: boolean }) => { 155 125 let { identity } = useIdentityData(); 156 126 157 127 return ( 158 128 <div 159 - className={`accent-container w-full rounded-md flex flex-col text-center justify-center p-2 pb-4`} 129 + className={`accent-container w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 mt-2`} 160 130 > 161 131 <div className="mx-auto pt-2 scale-90"> 162 132 <PubListEmptyIllo /> ··· 185 155 noEmailLogin 186 156 asChild 187 157 trigger={ 188 - <ButtonPrimary compact className="mx-auto"> 158 + <ButtonPrimary compact className="mx-auto text-sm!"> 189 159 {identity ? "Link to" : "Log in with"} Atmosphere 190 160 </ButtonPrimary> 191 161 }
+27 -6
components/ActionBar/Sidebar.tsx
··· 1 1 "use client"; 2 - import { uv } from "colorjs.io/fn"; 3 2 import { Media } from "components/Media"; 3 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 4 4 import { createContext, useState } from "react"; 5 + import { create } from "zustand"; 5 6 6 7 export const SidebarContext = createContext({ 7 8 open: false, 8 9 setChildForceOpen: (b: boolean) => {}, 9 10 }); 10 11 12 + export const useSidebarStore = create<{ 13 + open: boolean; 14 + setOpen: (open: boolean) => void; 15 + }>((set) => ({ 16 + open: false, 17 + setOpen: (open) => set({ open }), 18 + })); 19 + 11 20 export function Sidebar(props: { 12 21 children?: React.ReactNode; 13 22 alwaysOpen?: boolean; 23 + mobile?: boolean; 14 24 className?: string; 15 25 }) { 16 26 let [sidebarExpanded, setSidebarExpanded] = useState(false); 17 27 let [childForceOpen, setChildForceOpen] = useState(false); 18 28 let open = sidebarExpanded || childForceOpen; 29 + let cardBorderHidden = useCardBorderHidden(); 19 30 return ( 20 - <Media mobile={false}> 31 + <Media mobile={props.mobile ?? false}> 21 32 <SidebarContext 22 33 value={{ 23 34 open: props.alwaysOpen ? true : open, ··· 25 36 }} 26 37 > 27 38 <div 39 + style={ 40 + props.alwaysOpen 41 + ? { height: "-webkit-fill-available" } 42 + : { height: "fit-content" } 43 + } 28 44 className={` 29 45 actionSidebar 30 - ${!props.alwaysOpen ? "absolute top-0 left-0 z-10 w-max" : "w-[192px] max-w-[192px]"} 31 - h-fit p-[6px] 32 - flex flex-col gap-1 justify-start border 33 - rounded-md bg-bg-page ${open && !props.alwaysOpen ? "border-border-light" : "frosted-container"} 46 + 47 + ${ 48 + !props.alwaysOpen 49 + ? ` w-max hover:w-48 absolute top-0 left-0 z-10 opaque-container` 50 + : `my-6 w-56 ${cardBorderHidden ? "light-container" : "frosted-container"}` 51 + } 52 + p-[6px] 53 + flex flex-col gap-0.5 justify-start border 54 + rounded-md 34 55 ${props.className} 35 56 `} 36 57 onMouseOver={() => {
+19
components/Icons/AnalyticsSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const AnalyticsSmall = (props: Props) => { 4 + return ( 5 + <svg 6 + width="24" 7 + height="24" 8 + viewBox="0 0 24 24" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M2.58013 20.6905C2.79021 20.5115 3.10607 20.5362 3.28521 20.7461L3.65923 21.1856C3.8382 21.3957 3.81352 21.7115 3.60357 21.8907C3.39354 22.0697 3.0777 22.0439 2.89849 21.834L2.52349 21.3956C2.34454 21.1855 2.37023 20.8696 2.58013 20.6905ZM20.628 2.58889C21.04 2.54763 21.4078 2.84782 21.4493 3.25979L21.8418 7.17484C21.8832 7.58685 21.5829 7.95457 21.1709 7.99613C20.7592 8.0372 20.3912 7.73696 20.3497 7.32523L20.1377 5.21975C19.6783 5.89135 19.4045 6.7096 19.2071 7.58695L20.5664 9.1807C20.7453 9.39079 20.7208 9.70666 20.5108 9.88578C20.3008 10.0648 19.9849 10.039 19.8057 9.82914L18.9688 8.84769C18.9576 8.91764 18.9455 8.98752 18.9346 9.05765C18.8808 9.40437 18.8259 9.76011 18.7676 10.1065L20.5664 12.2149C20.7454 12.4249 20.7206 12.7408 20.5108 12.92C20.3008 13.099 19.9849 13.0741 19.8057 12.8643L18.501 11.334C18.4929 11.3612 18.486 11.3885 18.4776 11.4151C18.3928 11.681 18.2763 11.9644 18.1104 12.2217L20.5811 15.1192C20.7597 15.3291 20.735 15.6442 20.5254 15.8233C20.3154 16.0023 19.9996 15.9774 19.8204 15.7676L17.376 12.9014C17.2566 12.9607 17.1263 13.0099 16.9825 13.042C16.7748 13.0884 16.5784 13.1044 16.3916 13.0938L20.5811 18.0059C20.7597 18.2159 20.735 18.5309 20.5254 18.71C20.3154 18.889 19.9996 18.8642 19.8204 18.6543L14.6319 12.5723C14.4968 12.7115 14.3629 12.9046 14.1768 13.1778C14.1518 13.2144 14.125 13.2524 14.0977 13.292L20.5664 20.8751C20.7455 21.0851 20.7207 21.401 20.5108 21.5801C20.3008 21.7591 19.9849 21.7341 19.8057 21.5245L13.4502 14.0743C13.2557 14.251 13.0206 14.4117 12.7383 14.5088L18.4336 21.1856C18.6125 21.3957 18.587 21.7116 18.377 21.8907C18.167 22.0694 17.852 22.0438 17.6729 21.834L11.5791 14.6915C11.5132 14.6142 11.4792 14.5323 11.4659 14.4522C11.0547 14.2587 10.7509 13.9159 10.5352 13.6172C10.4092 13.4428 10.289 13.2443 10.1709 13.0401L7.96685 10.4561C7.95664 10.4567 7.94592 10.4571 7.9356 10.458C7.87391 10.4638 7.77888 10.4942 7.64068 10.6407C7.54499 10.7421 7.44589 10.8841 7.34185 11.0694L15.9707 21.1856C16.1498 21.3957 16.1251 21.7115 15.9151 21.8907C15.705 22.0696 15.3892 22.0439 15.21 21.834L6.89556 12.087C6.78671 12.3888 6.68346 12.7162 6.57818 13.0616L13.5088 21.1856C13.6877 21.3957 13.6622 21.7116 13.4522 21.8907C13.2422 22.0694 12.9272 22.0438 12.7481 21.834L6.23736 14.2022C6.14153 14.5279 6.03946 14.8609 5.93365 15.1924L11.0459 21.1856C11.225 21.3957 11.2003 21.7115 10.9903 21.8907C10.7802 22.0696 10.4644 22.0439 10.2852 21.834L5.55181 16.2852C5.43562 16.5802 5.31265 16.8631 5.17779 17.1241C5.17059 17.138 5.16167 17.1512 5.15435 17.1651L8.58403 21.1856C8.76293 21.3956 8.73824 21.7115 8.52837 21.8907C8.31834 22.0697 8.0025 22.0439 7.82329 21.834L4.58306 18.0362C4.39361 18.2602 4.17681 18.4594 3.92876 18.6153L6.12115 21.1856C6.30019 21.3957 6.27551 21.7115 6.06548 21.8907C5.85545 22.0696 5.53956 22.0439 5.3604 21.834L2.89459 18.9444C2.52492 18.9335 2.2105 18.6514 2.17095 18.2735C2.12811 17.8618 2.42733 17.4926 2.83892 17.4493C3.12007 17.4199 3.45988 17.1804 3.84478 16.4356C4.21519 15.7187 4.51212 14.7584 4.81841 13.7159C5.11329 12.7122 5.4202 11.6189 5.80962 10.7735C6.00579 10.3476 6.24461 9.93487 6.54986 9.61136C6.86304 9.2795 7.27647 9.01295 7.79888 8.96488C8.65706 8.88618 9.32438 9.24374 9.82427 9.73148C10.3001 10.196 10.6604 10.8118 10.9512 11.3438C11.2658 11.9193 11.495 12.3846 11.751 12.7393C12.0104 13.0985 12.1342 13.1063 12.1485 13.1065C12.2319 13.1065 12.3176 13.0804 12.4473 12.961C12.5973 12.8228 12.7409 12.6216 12.9375 12.333C13.1114 12.078 13.3425 11.7297 13.625 11.458C13.927 11.1678 14.3479 10.9045 14.9092 10.9043C15.5863 10.9044 16.0543 11.3578 16.2002 11.4668C16.3035 11.5438 16.3594 11.5715 16.4043 11.585C16.4393 11.5954 16.5108 11.6094 16.6553 11.5772C16.717 11.5633 16.8745 11.5032 17.0479 10.959C17.2169 10.4284 17.3138 9.71859 17.4522 8.82718C17.6676 7.43944 17.9743 5.66182 18.9815 4.26077L16.8623 4.47464C16.4504 4.51577 16.0824 4.21477 16.0411 3.80276C15.9998 3.39066 16.3008 3.02283 16.7129 2.98147L20.628 2.58889Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+22
components/Icons/GoToArrowLined.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const GoToArrowLined = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M8.59574 3L13.8041 8M13.8041 8L8.59574 13M13.8041 8H2.19589" 15 + stroke="currentColor" 16 + stroke-width="2" 17 + stroke-linecap="round" 18 + stroke-linejoin="round" 19 + /> 20 + </svg> 21 + ); 22 + };
+19
components/Icons/LeafletTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const LeafletTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M11.8668 0.000946697C12.1911 -0.00776564 12.7669 0.0342619 13.1383 0.530244C13.2074 0.622574 13.2583 0.715693 13.2946 0.804658C13.562 0.780496 13.9339 0.749887 14.2799 0.77829C14.6129 0.805673 15.2866 0.911671 15.6793 1.51657C16.0252 2.04966 15.9383 2.59492 15.7692 2.96579C15.6717 3.17955 15.5299 3.37339 15.4186 3.51267C15.5381 3.61445 15.6731 3.75242 15.7789 3.9277C16.0334 4.34915 16.0455 4.81599 15.9323 5.25681C15.7334 6.03035 15.0695 6.3684 14.6656 6.5195C14.2505 6.67483 13.7641 6.75976 13.401 6.82321C13.3869 6.82568 13.3728 6.82764 13.359 6.83005C13.3303 6.83508 13.3023 6.83988 13.275 6.8447C13.3642 7.14642 13.3452 7.46648 13.2418 7.77048C12.9557 8.61163 12.3032 9.01101 11.6832 9.17966C11.1519 9.3241 10.5577 9.32349 10.1354 9.32321H9.98401C10.0893 9.73959 9.96874 10.15 9.85022 10.4277C9.41231 11.4532 8.422 11.836 7.6217 11.998C6.82175 12.1599 5.89364 12.1657 5.12366 12.1699H5.07092C4.64879 12.1723 4.27434 12.1746 3.9469 12.1924C3.6562 12.7387 3.41593 13.3457 3.13733 14.0547L3.13635 14.0566C3.06317 14.2428 2.98742 14.4361 2.90881 14.6318C2.59825 15.4047 1.71984 15.7792 0.946899 15.4687C0.173965 15.1582 -0.201463 14.2798 0.109009 13.5068C0.506651 12.5168 0.919122 11.8273 1.46838 10.7666C1.61594 10.4816 1.74023 10.2517 1.84924 10.0381C1.84924 9.39381 1.89774 8.9382 1.9928 8.3613C2.15266 7.39112 2.28769 7.0177 2.48499 6.13278C2.66647 5.52121 2.93322 4.70011 3.35217 4.24802C3.59296 3.98818 3.96492 3.60802 4.52502 3.582C4.8648 3.56636 5.07696 3.84018 5.25647 3.957C5.77748 3.25199 6.21912 2.31295 6.7887 1.58395C7.24546 0.999382 7.73681 0.48834 8.52991 0.610322C8.93855 0.673299 9.51997 0.98071 9.52014 1.58395C9.67153 1.41562 10.1343 0.768553 10.3287 0.610322C10.6991 0.308862 11.2118 0.0185629 11.8668 0.000946697ZM11.5748 0.782197C10.877 0.800962 10.3849 1.39427 9.97229 1.89255C9.67357 2.25329 9.41649 2.56445 9.15198 2.57126C8.8923 2.57794 8.91166 2.40388 8.93616 2.18356C8.97108 1.86951 9.01628 1.46131 8.27893 1.34763C7.6674 1.25338 7.0226 2.38484 6.46252 3.36716C6.07935 4.0392 5.73509 4.64189 5.46936 4.73434C5.06333 4.87542 4.87514 4.70949 4.71448 4.56833C4.52019 4.39763 4.36629 4.26285 3.91663 4.74802C3.44288 5.25924 3.1194 6.65748 2.80627 8.18063C2.76675 8.37509 3.04448 8.50032 3.17151 8.34763C3.89251 7.48102 5.06446 6.28956 6.46252 5.40817C7.74786 4.59785 8.98753 3.79311 10.3903 3.38767C10.597 3.32793 10.6556 3.55928 10.4576 3.64352C8.87603 4.31647 7.51741 5.20388 6.09436 6.38864C4.49485 7.69103 2.98179 9.42484 2.21448 10.8379C1.74437 11.6211 1.31469 12.4745 0.942016 13.4023C0.842025 13.6514 0.962572 13.934 1.21155 14.0342C1.46069 14.1342 1.74429 14.0138 1.84436 13.7646C1.92005 13.5762 1.99291 13.3896 2.06506 13.206C2.3935 12.3703 2.70122 11.5864 3.13538 10.874C3.16357 10.8278 3.21156 10.7974 3.26526 10.791C3.73712 10.7349 4.29865 10.7317 4.87952 10.7285C6.46361 10.7196 8.1939 10.7096 8.65784 9.62302C8.90008 9.0555 8.60814 8.97388 8.34436 8.90036C8.12876 8.84027 7.9323 8.78481 8.06213 8.47458C8.2872 7.93768 9.02056 7.93748 9.79749 7.93747C10.7027 7.93746 11.6668 7.93727 11.9557 7.08786C12.0793 6.7244 11.859 6.58156 11.6637 6.45505C11.5003 6.34923 11.3551 6.25456 11.443 6.05173C11.5834 5.72844 12.1931 5.62098 12.8395 5.50778C13.6113 5.37261 14.435 5.22874 14.5719 4.69626C14.7013 4.19269 14.4599 4.03041 14.2106 3.86227C14.0582 3.7595 13.9035 3.65383 13.8278 3.46774C13.7351 3.23965 13.9373 2.99554 14.1481 2.74216C14.397 2.44292 14.6566 2.13049 14.4528 1.81638C14.2206 1.45972 13.5296 1.5223 12.9576 1.57419C12.6007 1.60656 12.2902 1.63422 12.1666 1.55368C12.0413 1.47185 12.0464 1.33369 12.0514 1.19626C12.0593 0.98129 12.0666 0.769051 11.5748 0.782197Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+1
components/Icons/MenuSmall.tsx
··· 8 8 viewBox="0 0 24 24" 9 9 fill="none" 10 10 xmlns="http://www.w3.org/2000/svg" 11 + {...props} 11 12 > 12 13 <path 13 14 d="M19.9541 16.6455C20.6444 16.6455 21.2039 17.2053 21.2041 17.8955C21.2041 18.5859 20.6445 19.1455 19.9541 19.1455H4.04492C3.35476 19.1453 2.79492 18.5857 2.79492 17.8955C2.79509 17.2054 3.35486 16.6457 4.04492 16.6455H19.9541ZM19.9541 10.75C20.6444 10.75 21.2041 11.3097 21.2041 12C21.2041 12.6904 20.6445 13.25 19.9541 13.25H4.04492C3.35476 13.2498 2.79492 12.6902 2.79492 12C2.79495 11.3098 3.35478 10.7502 4.04492 10.75H19.9541ZM19.9541 4.85449C20.6445 4.85449 21.2041 5.41414 21.2041 6.10449C21.2039 6.79471 20.6444 7.35449 19.9541 7.35449H4.04492C3.35486 7.35426 2.79509 6.79457 2.79492 6.10449C2.79492 5.41428 3.35476 4.85472 4.04492 4.85449H19.9541Z"
+19
components/Icons/NewSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const NewSmall = (props: Props) => { 4 + return ( 5 + <svg 6 + width="24" 7 + height="24" 8 + viewBox="0 0 24 24" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M2.76412 1.57602C3.05691 1.48688 3.37578 1.58471 3.56784 1.82309C4.23412 2.64998 5.27638 3.05721 6.61572 3.5106C7.8804 3.93869 9.41814 4.4097 10.5982 5.48619C11.3164 6.14145 11.9065 6.87927 12.2398 7.6239C12.5705 8.36317 12.6922 9.22614 12.2476 9.98719C12.0276 10.3635 11.699 10.5488 11.397 10.6405C11.8884 11.2674 12.2513 12.1681 12.3667 13.5741C12.6557 13.1631 13.0721 12.666 13.5494 12.4599C13.412 12.2271 13.3387 11.9364 13.3218 11.621C13.286 10.9505 13.58 10.4254 14.0357 10.08C14.4536 9.7634 14.9813 9.61329 15.4605 9.54382C16.413 9.40594 17.5233 9.54249 18.1637 9.79675C19.762 10.4317 21.4201 11.699 22.7877 14.3729C22.9361 14.6631 22.8799 15.017 22.6491 15.247C22.4178 15.4768 22.0635 15.5308 21.774 15.3808C21.2444 15.1057 20.3035 15.1001 19.5074 15.4462C18.9784 15.6763 18.3744 15.8547 17.8014 15.9618C17.2391 16.0668 16.6491 16.1139 16.1617 16.0419C15.7185 15.9763 15.1613 15.8689 14.691 15.5233C14.1844 15.1507 13.882 14.5859 13.7574 13.83C13.7568 13.8265 13.756 13.8227 13.7554 13.8192C13.5967 13.9641 13.4328 14.1481 13.271 14.4091C13.0012 14.8447 12.7067 15.5548 12.7066 16.8261C12.7504 18.3844 12.6077 18.7803 12.4888 19.7343C12.4679 19.9023 12.4462 20.0804 12.4195 20.2577C12.8136 20.2271 13.2287 20.2482 13.5816 20.4443C13.8365 20.586 14.1297 20.7367 14.4078 20.8398C14.7731 20.9751 14.9333 20.9781 15.2545 20.8173C15.9823 20.4526 16.7528 19.9074 17.6012 19.9071C18.0149 19.9075 18.3509 20.2434 18.3512 20.6571C18.3512 21.0711 18.0151 21.4068 17.6012 21.4072C17.5506 21.4073 17.4336 21.427 17.2349 21.5038C17.0455 21.5772 16.8333 21.6818 16.6041 21.8017C16.3938 21.9117 16.1337 22.0542 15.9263 22.1581C15.7289 22.257 15.4641 22.3821 15.2144 22.4286C14.7405 22.5164 14.2463 22.3804 13.8863 22.247C13.6935 22.1755 13.5042 22.0904 13.3287 22.0048C13.1296 21.9005 12.9365 21.748 12.7056 21.746C12.5696 21.745 12.3966 21.763 12.189 21.7958C11.7601 21.8637 11.3287 21.9822 10.8921 21.9823C10.5357 21.9823 10.0844 21.7708 9.73879 21.8671C9.65706 21.89 9.59817 21.9113 9.45265 21.957C9.19184 22.0388 8.83771 22.1336 8.37452 22.1337C7.61434 22.1337 7.12167 22.0031 6.73291 21.8398C6.53186 21.7553 6.34762 21.6173 6.13329 21.5771C5.72602 21.5015 5.45714 21.1103 5.5327 20.703C5.60869 20.2963 5.99978 20.027 6.40673 20.1025C7.1182 20.2347 7.60258 20.6336 8.37452 20.6337C8.87001 20.6335 9.3125 20.3506 9.80715 20.3505C9.88992 20.3505 9.9794 20.3577 10.0679 20.3671C10.3152 19.6884 10.9814 17.9048 11.0122 17.1103C11.0718 15.5752 11.3485 14.0935 11.0122 12.536C10.9256 12.1353 10.7553 11.6625 10.4741 11.285C10.377 11.4216 10.2678 11.5628 10.1411 11.6972C9.80938 12.0488 9.35105 12.3922 8.7505 12.5448C8.13874 12.7 7.45285 12.6388 6.71435 12.3065C5.77508 11.8839 4.3434 11.4308 3.35983 9.9784C2.37849 8.52898 1.89856 6.21432 2.2358 2.23032C2.26191 1.92526 2.4713 1.6656 2.76412 1.57602ZM1.75239 20.3612C2.00789 20.3172 2.26367 20.3618 2.45943 20.5458C2.57654 20.6516 2.66304 20.7939 2.69479 20.9609C2.77165 21.3674 2.50443 21.7593 2.0981 21.8368C1.67354 21.9173 1.22369 21.7053 1.14106 21.2275C1.07104 20.8197 1.34462 20.4319 1.75239 20.3612ZM18.5855 20.8007C18.6954 20.4018 19.1083 20.1669 19.5074 20.2763C19.6499 20.3153 19.8488 20.352 19.8902 20.3593C20.8447 20.5266 20.6036 22.0021 19.6451 21.8398L19.6441 21.8388C19.4732 21.8388 19.2736 21.7672 19.1109 21.7226C18.7114 21.6131 18.4761 21.2002 18.5855 20.8007ZM3.95066 20.0136C4.3641 20.0142 4.70029 20.3501 4.70066 20.7636C4.70066 21.1774 4.36433 21.513 3.95066 21.5136H3.6362C3.22198 21.5136 2.88619 21.1778 2.88619 20.7636C2.88656 20.3497 3.22221 20.0136 3.6362 20.0136H3.95066ZM17.61 11.1903C17.2417 11.0442 16.4 10.9224 15.6754 11.0272C15.3171 11.0792 15.0739 11.1756 14.942 11.2753C14.672 11.4799 14.8875 11.6711 15.067 11.8134C16.038 11.3202 17.3037 11.5847 18.4801 12.2001C18.7242 12.328 18.8182 12.6305 18.691 12.8749C18.5631 13.1195 18.2608 13.2145 18.0162 13.0868C16.8356 12.4693 15.9523 12.4269 15.4049 12.7763C15.3584 13.0346 15.1946 13.3236 15.2379 13.5868C15.3168 14.0647 15.4672 14.2326 15.5797 14.3153C15.7289 14.4249 15.9593 14.496 16.3814 14.5585C16.6368 14.5961 17.0427 14.5784 17.526 14.4882C17.9992 14.3997 18.4941 14.251 18.9098 14.0702C19.4326 13.843 20.0333 13.7064 20.6295 13.6835C19.6345 12.2697 18.576 11.574 17.61 11.1903ZM3.64206 3.84947C3.54755 6.7364 4.00685 8.25985 4.60105 9.13757C5.288 10.152 6.20802 10.4346 7.32959 10.9393C7.95312 11.2199 8.57086 11.1768 9.05031 10.6688C9.32831 10.374 9.49464 10.0239 9.65285 9.65808C9.27243 8.90779 8.65643 8.37352 7.96046 7.9198C7.51257 7.62783 7.05821 7.3839 6.60888 7.1278C6.17608 6.88111 5.73751 6.61747 5.40184 6.30455C5.20013 6.11634 5.18952 5.79946 5.37743 5.59752C5.56566 5.39617 5.88264 5.38438 6.08446 5.57213C6.32496 5.79628 6.66827 6.01127 7.104 6.25963C7.52328 6.4986 8.02632 6.76833 8.50733 7.0819C9.27433 7.58198 10.0495 8.23458 10.5406 9.1991C10.6699 9.23625 10.8337 9.2518 10.9624 9.20691C11.0583 9.02312 11.0875 8.71881 10.8716 8.2362C10.6488 7.73834 10.2096 7.16225 9.58742 6.59459C8.68752 5.77365 7.49226 5.3912 6.13427 4.9315C5.32152 4.65638 4.43323 4.34753 3.64206 3.84947Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+1
components/Icons/SortSmall.tsx
··· 8 8 viewBox="0 0 24 24" 9 9 fill="none" 10 10 xmlns="http://www.w3.org/2000/svg" 11 + {...props} 11 12 > 12 13 <path 13 14 d="M6.67186 18.5V8.74805L4.70408 10.7002C4.31199 11.089 3.67892 11.0863 3.29002 10.6943C2.90107 10.3023 2.90381 9.66923 3.29588 9.28027L6.96776 5.6377L7.04295 5.56934C7.43471 5.25182 8.01037 5.27501 8.37596 5.6377L12.0478 9.28027C12.4399 9.66924 12.4427 10.3023 12.0537 10.6943C11.6648 11.0863 11.0317 11.089 10.6396 10.7002L8.67186 8.74805V18.5C8.67186 19.0523 8.22414 19.5 7.67186 19.5C7.1196 19.5 6.67186 19.0523 6.67186 18.5ZM15.3281 5.5C15.3281 4.94772 15.7758 4.5 16.3281 4.5C16.8804 4.50003 17.3281 4.94774 17.3281 5.5V15.1016L19.2959 13.1504C19.688 12.7616 20.321 12.7642 20.7099 13.1562C21.0988 13.5483 21.0961 14.1814 20.7041 14.5703L17.0322 18.2129C16.6423 18.5997 16.0139 18.5997 15.624 18.2129L11.9521 14.5703C11.5601 14.1814 11.5573 13.5483 11.9463 13.1562C12.3352 12.7642 12.9683 12.7615 13.3603 13.1504L15.3281 15.1016V5.5Z"
+21
components/Icons/SubscribersSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const SubscribersSmall = (props: Props) => { 4 + return ( 5 + <svg 6 + width="24" 7 + height="24" 8 + viewBox="0 0 24 24" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + fillRule="evenodd" 15 + clipRule="evenodd" 16 + d="M7.17935 10.7605C7.59213 10.7261 7.89887 10.3636 7.86447 9.95081C7.83007 9.53803 7.46756 9.23129 7.05478 9.26568C5.49269 9.39586 3.95985 10.2589 2.82705 11.5079C1.6883 12.7635 0.902765 14.4641 0.902765 16.3412C0.902765 20.2757 4.09233 23.4652 8.02685 23.4652C11.9614 23.4652 15.1509 20.2757 15.1509 16.3412C15.1509 16.0057 15.1405 15.6939 15.1133 15.3959C15.4187 15.4024 15.8151 15.4063 16.3304 15.4063H16.3434L16.3565 15.4059C20.2606 15.2695 23.3833 12.0622 23.3833 8.12525C23.3833 4.1018 20.1216 0.840149 16.0982 0.840149C14.0728 0.840149 12.2393 1.66768 10.9197 3.00121C10.6283 3.29564 10.6308 3.77051 10.9253 4.06186C11.2197 4.35321 11.6946 4.35071 11.9859 4.05628C13.0353 2.99578 14.4895 2.34015 16.0982 2.34015C19.2932 2.34015 21.8833 4.93023 21.8833 8.12525C21.8833 11.2471 19.4103 13.7916 16.3169 13.9063C15.5443 13.9062 15.0668 13.8969 14.7755 13.8856C14.5894 13.3953 14.328 12.9184 13.9819 12.3876C13.7556 12.0406 13.2909 11.9428 12.9439 12.1691C12.597 12.3953 12.4992 12.86 12.7254 13.207C13.1204 13.8126 13.3409 14.2623 13.4702 14.7079C13.5453 14.9663 13.594 15.2382 13.6217 15.557C13.3632 15.5021 13.0835 15.5024 12.7947 15.5712C11.8013 15.8078 11.302 16.5408 11.012 17.345C10.8714 17.7346 11.0734 18.1644 11.463 18.3049C11.8527 18.4455 12.2825 18.2435 12.423 17.8539C12.6263 17.2901 12.8427 17.1017 13.1422 17.0304C13.2621 17.0018 13.351 17.0273 13.4147 17.0694C13.4839 17.1151 13.5382 17.1911 13.5576 17.2855C13.56 17.2976 13.5628 17.3095 13.5658 17.3214C13.102 19.9602 10.7986 21.9652 8.02685 21.9652C4.92076 21.9652 2.40277 19.4473 2.40277 16.3412C2.40277 14.8879 3.01296 13.5358 3.93816 12.5156C4.86931 11.4889 6.06862 10.8531 7.17935 10.7605ZM5.74281 17.4715C5.90829 17.1443 6.09997 16.9597 6.28559 16.862C6.46735 16.7664 6.70429 16.7216 7.0243 16.7796C7.22691 16.8163 7.36224 16.9258 7.47061 17.1082C7.58994 17.309 7.65526 17.5722 7.67487 17.8034C7.70988 18.2161 8.07284 18.5223 8.48557 18.4873C8.8983 18.4523 9.20451 18.0893 9.16951 17.6766C9.13752 17.2994 9.03027 16.7966 8.76017 16.342C8.47912 15.8689 8.00818 15.4334 7.2918 15.3036C6.69282 15.195 6.10767 15.2606 5.58699 15.5346C5.07018 15.8066 4.67909 16.2511 4.40423 16.7946C4.21731 17.1643 4.36543 17.6154 4.73507 17.8024C5.10471 17.9893 5.55589 17.8412 5.74281 17.4715ZM11.8024 9.97903C11.594 10.1276 11.3838 10.1948 11.2227 10.1903C10.7533 10.1772 9.9005 9.53315 9.94092 8.08572C9.98134 6.6383 10.8688 6.04289 11.3382 6.056C11.8076 6.06911 12.6604 6.71311 12.62 8.16054C12.6157 8.31601 12.6015 8.46165 12.5792 8.59776C12.439 8.16577 12.0332 7.85346 11.5545 7.85346C10.9596 7.85346 10.4774 8.3357 10.4774 8.93058C10.4774 9.52546 10.9596 10.0077 11.5545 10.0077C11.6398 10.0077 11.7228 9.99778 11.8024 9.97903ZM13.8695 8.19543C13.8184 10.0272 12.6177 11.4797 11.1878 11.4398C9.75794 11.3998 8.64025 9.88256 8.69141 8.05083C8.74256 6.2191 9.94318 4.76655 11.3731 4.80648C12.2475 4.8309 13.0051 5.40776 13.4501 6.27115C13.8878 6.15036 14.3325 6.14832 14.7769 6.24122C15.2095 5.23569 16.0592 4.52985 17.0749 4.46749C18.6245 4.37233 19.9739 5.81115 20.0887 7.68116C20.2035 9.55118 19.0404 11.1443 17.4907 11.2394C15.941 11.3346 14.5917 9.89576 14.4768 8.02575C14.4703 7.91941 14.4679 7.81397 14.4695 7.70965C14.266 7.66796 14.0575 7.66249 13.8561 7.71545C13.8694 7.87242 13.8741 8.03268 13.8695 8.19543ZM17.4141 9.99177C18.0655 9.95177 18.9277 9.1684 18.8411 7.75777C18.7544 6.34714 17.8029 5.67514 17.1515 5.71514C16.5 5.75514 15.6379 6.53851 15.7245 7.94914C15.7326 8.08076 15.7482 8.20595 15.7704 8.32473C15.9319 7.9337 16.317 7.65853 16.7663 7.65853C17.3612 7.65853 17.8435 8.14077 17.8435 8.73565C17.8435 9.33053 17.3612 9.81277 16.7663 9.81277C16.7568 9.81277 16.7473 9.81264 16.7378 9.8124C16.9686 9.94501 17.2066 10.0045 17.4141 9.99177Z" 17 + fill="currentColor" 18 + /> 19 + </svg> 20 + ); 21 + };
+19
components/Icons/TrendingSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const TrendingSmall = (props: Props) => { 4 + return ( 5 + <svg 6 + width="24" 7 + height="24" 8 + viewBox="0 0 24 24" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M11.1498 0.426701C11.4091 0.301938 11.7176 0.336275 11.9427 0.515568C12.7657 1.17136 14.0155 2.31309 15.1224 3.63178C16.1091 4.8073 17.0562 6.21288 17.4153 7.59175C17.5317 7.16166 17.6246 6.6762 18.1624 6.61323C18.4771 6.57667 18.7808 6.74229 18.9212 7.02632C19.452 8.10148 19.8427 9.5198 19.8597 11.1562C19.884 13.5188 19.0293 14.9723 17.8538 16.2021C17.3939 16.6831 16.7654 16.9767 16.1478 17.164C15.521 17.3541 14.8383 17.4546 14.2044 17.5058C13.4469 17.567 12.723 17.5584 12.1937 17.539C12.0731 17.5836 11.9073 17.6627 11.6732 17.7832C11.4356 17.9054 11.1382 18.0631 10.7982 18.2295C10.1116 18.5654 9.22544 18.9506 8.11952 19.249C7.85312 19.3205 7.57911 19.1628 7.50722 18.8964C7.43558 18.6301 7.59348 18.3552 7.85976 18.2832C8.94762 17.9896 9.81009 17.6024 10.475 17.2714C10.2031 17.187 9.91503 17.0947 9.58045 16.998C8.84126 16.7844 7.99532 16.5911 7.14785 16.581C6.897 16.6339 6.49358 16.7002 6.05411 16.7695C6.32439 17.1994 6.49569 17.7111 6.53067 18.2587C6.57168 18.9012 6.54146 19.4551 6.54239 19.7832C6.54343 20.1497 6.58071 20.2684 6.6293 20.3466C6.86839 20.7319 6.95553 21.1411 6.94375 21.5263C7.94296 21.2516 8.74978 20.9416 9.50721 20.6338C10.5971 20.1908 11.6469 19.7297 12.9945 19.4482C13.4531 19.3524 13.8974 19.713 13.8978 20.1816C13.8978 20.5741 13.8335 20.9774 13.682 21.3408C13.7002 21.3804 13.7145 21.4173 13.7484 21.4453C14.166 21.233 14.6918 20.9369 15.2435 20.2441C15.6107 19.7827 15.7254 19.191 16.0316 18.6972C16.121 18.58 16.2435 18.4907 16.3851 18.4423C18.8562 17.5985 19.3518 17.0688 20.6682 16.292C21.1892 15.5372 21.0729 14.5462 20.3792 13.9589C20.0632 13.6913 20.0238 13.2184 20.2913 12.9023C20.5589 12.5864 21.0318 12.5471 21.3479 12.8144C22.4888 13.7801 23.1842 16.5701 21.5608 17.5068C20.3284 18.2177 19.6639 18.8714 17.1741 19.7539C16.9512 20.2391 16.7525 20.7576 16.4173 21.1787C15.4373 22.4094 14.3406 22.8124 14.097 22.9541C13.5805 23.2547 12.8951 22.7761 12.5697 22.3828C12.3379 22.1024 12.0902 21.6117 12.1156 21.2353C11.4383 21.4569 10.8011 21.727 10.0717 22.0234C8.9821 22.4662 7.74059 22.9374 5.98575 23.2959C5.17676 23.8196 4.00376 23.7271 3.16448 23.3281C2.68995 23.1024 2.24129 22.7362 1.94867 22.1914C1.65485 21.6441 1.55204 20.9782 1.6645 20.2138C1.75817 19.5771 1.79849 19.0364 1.82953 18.5673C1.85933 18.117 1.88228 17.6758 1.94477 17.3134C2.009 16.9412 2.13207 16.5345 2.44183 16.2011C2.75589 15.8633 3.17206 15.6959 3.62249 15.6074C4.23835 15.4873 4.87505 15.434 5.49552 15.3398C4.77398 14.0011 4.80073 12.7383 4.69475 11.5722C4.59529 10.477 5.07516 9.77566 5.41544 9.22944C5.74434 8.70145 6.02382 8.21846 6.02384 7.35835C6.02394 6.29241 6.45941 5.37564 6.95742 4.68745C7.45444 4.00079 8.05175 3.4882 8.47596 3.21284C8.72525 3.0512 9.04676 3.05196 9.29529 3.21479C9.54359 3.37764 9.67294 3.67187 9.62439 3.96479C9.6013 4.10338 9.5895 4.28438 9.599 4.44819C9.70326 4.35962 9.83237 4.2242 9.96619 4.02436C10.3632 3.43102 10.725 2.4266 10.725 1.10248C10.7251 0.814582 10.8904 0.551673 11.1498 0.426701ZM3.54046 17.2226C3.51894 17.2459 3.46667 17.3168 3.42327 17.5683C3.26073 18.5106 3.28741 19.4843 3.14788 20.4326C3.07349 20.9384 3.15505 21.2673 3.26995 21.4814C3.52336 21.9533 4.1 22.1449 4.60686 22.1474C4.97145 22.1492 5.29696 22.0533 5.41251 21.6797C5.47069 21.4913 5.45752 21.3031 5.3549 21.1377C4.85073 20.3254 5.09132 19.2585 5.03361 18.3545C5.00536 17.912 4.82436 17.5499 4.59611 17.3261C4.37099 17.1054 3.81407 16.9283 3.54046 17.2226ZM4.24846 18.5586C4.52457 18.5586 4.74846 18.7824 4.74846 19.0586C4.74845 19.4784 4.46918 20.1996 4.61662 20.5966C4.73122 20.901 5.1346 21.0606 4.81486 21.4482C4.63915 21.6612 4.32376 21.6912 4.11077 21.5156C3.91659 21.3554 3.76404 21.1729 3.67913 20.9443C3.59565 20.7195 3.59846 20.5031 3.61663 20.3203C3.65642 19.9202 3.74845 19.6519 3.74846 19.0586C3.74846 18.7826 3.97252 18.5588 4.24846 18.5586ZM12.0834 2.62201C11.9112 3.52614 11.596 4.28639 11.2132 4.85835C10.926 5.2875 10.576 5.64799 10.1849 5.86323C9.79565 6.07734 9.24836 6.19757 8.74647 5.88471C8.52924 5.74925 8.39413 5.56018 8.308 5.39057C7.86058 5.94905 7.52394 6.62969 7.52382 7.35835C7.5238 8.64466 7.06312 9.42162 6.68887 10.0224C6.32609 10.6048 6.14054 10.9038 6.18887 11.4365C6.31214 12.7926 6.28147 13.651 6.82168 14.6406C6.95279 14.76 7.17706 14.9451 7.35293 15.0869C8.33318 15.1231 9.26003 15.3435 9.99744 15.5566C10.3941 15.6713 10.7687 15.7937 11.0375 15.8759C11.1957 15.9243 11.3653 15.9891 11.5316 16.0039C11.865 16.0247 12.9369 16.1034 14.0833 16.0107C14.6566 15.9644 15.2241 15.8765 15.7122 15.7285C16.2091 15.5778 16.5613 15.3832 16.7698 15.165C17.7341 14.1561 18.3791 13.0647 18.3597 11.1718C18.3539 10.6173 18.2959 10.096 18.2044 9.61421C18.0362 9.85381 17.81 10.0544 17.5169 10.121C16.8466 10.2732 16.4191 9.69958 16.2347 9.14058C16.1558 8.90136 16.0887 8.60815 16.0296 8.26167C15.8427 7.16602 15.0268 5.8511 13.973 4.59565C13.354 3.85822 12.6834 3.17863 12.0834 2.62201ZM9.09315 10.2314C10.3175 10.0512 11.0126 11.5533 11.1517 12.7021C11.1424 13.0804 11.1003 13.4634 10.9261 13.8056C11.0257 13.8566 11.121 13.9096 11.2113 13.9638C11.5369 14.1595 11.9487 14.5431 12.3539 14.5068C12.6285 14.4825 12.8711 14.6852 12.8959 14.9599C12.9202 15.2347 12.7175 15.4781 12.4427 15.5029C11.7623 15.5638 11.2428 15.1495 10.6966 14.8212C10.2816 14.5719 9.82163 14.3711 9.10291 14.4785C8.91979 14.5305 8.71463 14.5454 8.54725 14.4355C7.61586 14.1109 7.22542 13.4008 7.1957 12.4541C7.19582 11.2558 7.9762 10.4594 9.09315 10.2314ZM17.3694 11.4873C17.644 11.5145 17.8446 11.7595 17.8177 12.0341C17.6882 13.344 17.3028 14.1762 16.7279 14.6777C16.1565 15.1757 15.4852 15.2679 14.9906 15.3027C14.7153 15.322 14.476 15.1141 14.4564 14.8388C14.4373 14.5636 14.645 14.325 14.9202 14.3056C15.3666 14.2743 15.7563 14.1977 16.0706 13.9238C16.3811 13.6529 16.7068 13.1078 16.8226 11.9365C16.8497 11.6617 17.0947 11.4601 17.3694 11.4873ZM9.2533 11.2197C8.89319 11.3004 8.63719 11.4588 8.47401 11.6425C8.31171 11.8254 8.19575 12.0839 8.19569 12.4541C8.1957 12.9029 8.29574 13.0956 8.3744 13.1923C8.44852 13.2834 8.58998 13.3944 8.891 13.497C9.27612 13.4331 9.60621 13.3115 10.0111 13.3896C10.1292 13.2112 10.1446 12.961 10.1488 12.7539C10.086 12.3175 9.92565 11.8529 9.7074 11.538C9.49537 11.2324 9.34898 11.2092 9.2533 11.2197ZM15.2044 9.05366C16.4532 9.22678 16.8782 10.6005 16.6654 11.6728C16.3814 12.9418 15.7816 13.3816 14.9945 13.3818L14.7874 13.371C13.8571 13.2701 13.4033 12.4769 13.347 11.3779L13.3412 11.1386C13.3412 9.93565 13.9091 8.96329 15.2044 9.05366ZM9.37732 11.9326C9.73294 11.9326 10.0072 12.2244 10.0072 12.58C10.0072 12.9355 9.79757 13.2215 9.4408 13.1796C9.09039 13.0839 8.79627 12.8907 8.79627 12.5351C8.79644 12.1798 9.02205 11.9327 9.37732 11.9326ZM14.9945 10.039C14.682 10.039 14.5904 10.1314 14.5404 10.1982C14.4526 10.3156 14.3412 10.5919 14.3412 11.1386C14.3412 11.6827 14.4519 12.0177 14.5667 12.1865C14.6451 12.3016 14.7442 12.3818 14.9945 12.3818C15.1952 12.3817 15.2792 12.3322 15.349 12.2587C15.4519 12.1503 15.6209 11.8758 15.7327 11.2353C15.7824 10.9505 15.7064 10.6223 15.5345 10.373C15.3675 10.1312 15.1701 10.0392 14.9945 10.039ZM15.055 10.6181C15.3746 10.6184 15.4788 10.9476 15.4788 11.2744C15.4788 11.6012 15.3747 11.8016 15.055 11.8017C14.7351 11.8017 14.475 11.5368 14.4749 11.2099C14.4749 10.883 14.7351 10.6181 15.055 10.6181ZM11.8763 4.11225C12.0562 4.0238 12.2723 4.05081 12.4242 4.18159C12.6334 4.36201 13.206 4.95978 13.7386 5.67182C14.0072 6.031 14.2755 6.43166 14.4847 6.83491C14.6903 7.23127 14.8584 7.66692 14.891 8.08686C14.9122 8.36212 14.7062 8.6026 14.431 8.62397C14.1557 8.64519 13.9152 8.43929 13.8939 8.16401C13.8763 7.93791 13.7764 7.6417 13.597 7.29585C13.4212 6.95679 13.1866 6.60309 12.9378 6.27046C12.7478 6.01632 12.5526 5.7826 12.3783 5.58198C12.3176 5.76123 12.2449 5.9517 12.1576 6.1396C12.0092 6.45849 11.8114 6.7947 11.557 7.06538C11.3026 7.336 10.9587 7.57424 10.5238 7.61714C10.397 7.62961 10.233 7.87002 10.1302 7.94722C9.96638 8.07025 9.74321 8.18374 9.44568 8.1855C9.27101 8.18649 9.09467 8.14504 8.91151 8.07417C8.53926 8.50038 8.61364 9.26671 8.35878 9.77437C8.23481 10.021 7.93363 10.1199 7.68691 9.99605C7.44043 9.87204 7.34051 9.57178 7.46425 9.32515C7.70061 8.85433 7.70322 8.59924 7.7582 8.27729C7.78798 8.10302 7.83656 7.90353 7.96034 7.69038C8.08147 7.48208 8.25734 7.29042 8.50038 7.09175C8.6691 6.97311 8.87717 6.93691 9.06483 7.04389C9.28747 7.17085 9.39944 7.18568 9.43982 7.1855C9.57506 7.18464 9.70951 6.98096 9.8031 6.90229C9.93846 6.78856 10.1407 6.65009 10.4261 6.62202C10.5382 6.6108 10.6733 6.5458 10.8285 6.38081C10.9837 6.21557 11.1275 5.98262 11.2504 5.7187C11.4113 5.37264 11.5657 4.97365 11.598 4.58881C11.5869 4.38881 11.6967 4.20095 11.8763 4.11225ZM4.23088 3.91206C4.42415 3.79875 4.62314 3.81601 4.76017 3.85932C5.17081 3.98934 5.46607 4.42663 5.64982 4.79292C5.73882 4.97041 5.82456 5.1923 5.84122 5.4062C5.86674 5.73552 5.85389 5.99808 5.78946 6.2187C5.71869 6.46047 5.59755 6.60955 5.49943 6.71577C5.40513 6.81783 5.3765 6.84123 5.33927 6.90522C5.30613 6.96227 5.25745 7.07372 5.22306 7.30561C5.18127 7.49628 5.06381 7.65496 4.86857 7.71186C4.61707 7.78514 4.36476 7.67125 4.16936 7.51753C3.98878 7.37534 3.7881 7.15148 3.5678 6.82319C3.17903 6.24329 3.30212 5.65548 3.476 5.23042C3.56151 5.02152 3.66981 4.82373 3.75237 4.67085C3.89125 4.41366 3.96453 4.06849 4.23088 3.91206ZM4.4008 5.60835C4.27738 5.90994 4.30116 6.13449 4.48576 6.38862C4.59156 6.21209 4.71634 6.08973 4.76506 6.03706C4.89621 5.89504 4.85778 5.65904 4.84416 5.48335C4.83291 5.34483 4.73867 5.20042 4.66545 5.08491C4.55892 5.28359 4.46918 5.44133 4.4008 5.60835Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+1 -1
components/IdentityProvider.tsx
··· 2 2 import { getIdentityData } from "actions/getIdentityData"; 3 3 import { createContext, useContext, useEffect } from "react"; 4 4 import useSWR, { KeyedMutator, mutate } from "swr"; 5 - import { DashboardState } from "./PageLayouts/DashboardLayout"; 5 + import type { DashboardState } from "./PageLayouts/dashboardState"; 6 6 import { supabaseBrowserClient } from "supabase/browserClient"; 7 7 import { produce, Draft } from "immer"; 8 8
+19 -5
components/LoginButton.tsx
··· 19 19 confirmEmailAuthToken, 20 20 } from "actions/emailAuth"; 21 21 import { loginWithEmailToken } from "actions/login"; 22 - import { getHomeDocs } from "app/(home-pages)/home/storage"; 22 + import { getHomeDocs } from "app/(home-pages)/(writer)/home/storage"; 23 23 import { mutate } from "swr"; 24 24 25 25 export const LoginModal = (props: { ··· 43 43 trigger={props.trigger} 44 44 open={open} 45 45 onOpenChange={setOpen} 46 + className="w-full!" 46 47 > 47 48 <LoginContent 48 49 noEmailLogin={props.noEmailLogin} ··· 60 61 redirectRoute?: string; 61 62 open?: boolean; 62 63 onSuccess?: () => void; 64 + className?: string; 63 65 }) => { 64 66 let identityData = useIdentityData(); 65 67 let [state, setState] = useState< ··· 125 127 }; 126 128 127 129 return ( 128 - <div className="flex flex-col gap-2 w-xs"> 130 + <div className={`flex flex-col gap-2 w-full sm:w-xs ${props.className}`}> 129 131 <ToggleGroup 130 132 value={ 131 133 state === "email log in" || state === "email confirm" ··· 180 182 <hr className="border-border-light mt-2 mb-1" /> 181 183 <button 182 184 className="text-sm text-accent-contrast" 183 - onClick={() => { 185 + onClick={(e) => { 186 + e.preventDefault(); 187 + e.stopPropagation(); 184 188 setState("email log in"); 185 189 }} 186 190 > ··· 194 198 className="flex flex-col gap-1" 195 199 onSubmit={(e) => { 196 200 e.preventDefault(); 201 + e.stopPropagation(); 197 202 handleEmailSubmit(); 198 203 }} 199 204 > ··· 206 211 onChange={setLoginEmail} 207 212 loading={loading} 208 213 action={ 209 - <button type="submit"> 214 + <button 215 + type="submit" 216 + onClick={(e) => { 217 + e.preventDefault(); 218 + e.stopPropagation(); 219 + handleEmailSubmit(); 220 + }} 221 + > 210 222 <GoToArrow className="h-fit" /> 211 223 </button> 212 224 } ··· 215 227 <button 216 228 type="button" 217 229 className="text-accent-contrast text-sm" 218 - onClick={() => { 230 + onClick={(e) => { 231 + e.preventDefault(); 232 + e.stopPropagation(); 219 233 setState("log in"); 220 234 }} 221 235 >
+3 -1
components/Media.tsx
··· 25 25 mobile: boolean; 26 26 children: React.ReactNode; 27 27 className?: string; 28 + style?: {}; 28 29 }) { 29 30 let initialRender = useIsInitialRender(); 30 31 let isMobile = useIsMobile(); 31 32 if (initialRender) 32 33 return ( 33 34 <div 34 - className={`${props.mobile ? "sm:hidden contents" : "hidden sm:contents"} ${props.className}`} 35 + className={`mediaContentContainer ${props.mobile ? "sm:hidden contents" : "hidden sm:contents"} ${props.className}`} 36 + style={props.style} 35 37 > 36 38 {props.children} 37 39 </div>
+25 -7
components/Modal.tsx
··· 1 + "use client"; 1 2 import * as Dialog from "@radix-ui/react-dialog"; 2 3 import React from "react"; 4 + import { isIOS } from "src/utils/isDevice"; 3 5 import { CloseTiny } from "./Icons/CloseTiny"; 6 + import { useVisualViewport } from "./ViewportSizeLayout"; 4 7 5 8 export const Modal = ({ 6 9 className, ··· 19 22 title?: React.ReactNode; 20 23 children: React.ReactNode; 21 24 }) => { 25 + let { height, offsetTop, difference } = useVisualViewport(); 26 + // iOS keyboard open: re-center modal against the visual viewport. Android 27 + // resizes the layout viewport via interactiveWidget: "resizes-content". 28 + let keyboardOpen = isIOS() && difference !== 0 && height > 0; 22 29 return ( 23 30 <Dialog.Root open={open} onOpenChange={onOpenChange}> 24 31 {trigger !== undefined && ( 25 32 <Dialog.Trigger asChild={asChild}>{trigger}</Dialog.Trigger> 26 33 )} 27 34 <Dialog.Portal> 28 - <Dialog.Overlay className="fixed z-50 inset-0 bg-primary data-[state=open]:animate-overlayShow opacity-60" /> 35 + <Dialog.Overlay 36 + style={ 37 + keyboardOpen 38 + ? { top: `${offsetTop}px`, height: `${height}px` } 39 + : undefined 40 + } 41 + className="fixed z-50 inset-0 bg-primary data-[state=open]:animate-overlayShow opacity-60" 42 + /> 29 43 <Dialog.Content 44 + style={ 45 + keyboardOpen 46 + ? { 47 + top: `${offsetTop + height / 2}px`, 48 + maxHeight: `${height - 32}px`, 49 + } 50 + : undefined 51 + } 30 52 className={` 31 - z-50 32 - fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 33 - overflow-y-scroll no-scrollbar 34 - w-max max-w-[calc(100vw-32px)] 35 - h-fit max-h-[calc(100dvh-32px)] 36 - p-3 flex flex-col text-primary 53 + z-50 fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 54 + overflow-y-scroll no-scrollbar max-w-[calc(100vw-32px)] h-fit max-h-[calc(100dvh-32px)] p-3 flex flex-col w-full sm:w-max text-primary 37 55 `} 38 56 > 39 57 <Dialog.Close className="bg-bg-page rounded-full -mb-3 mr-2 z-10 w-fit p-1 place-self-end border border-border-light text-tertiary">
+5 -3
components/PageHeader.tsx
··· 1 1 "use client"; 2 2 import { useState, useEffect } from "react"; 3 3 import { useCardBorderHidden } from "./Pages/useCardBorderHidden"; 4 + import { MediaContents } from "./Media"; 4 5 5 - export const Header = (props: { children: React.ReactNode }) => { 6 + export const PageHeader = (props: { children: React.ReactNode }) => { 6 7 let cardBorderHidden = useCardBorderHidden(); 7 8 let [scrollPos, setScrollPos] = useState(0); 8 9 ··· 24 25 let headerBGColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; 25 26 26 27 return ( 27 - <div 28 + <MediaContents 29 + mobile={false} 28 30 className={` 29 31 headerWrapper 30 32 sticky top-0 z-20 ··· 71 73 {props.children} 72 74 </div> 73 75 </div> 74 - </div> 76 + </MediaContents> 75 77 ); 76 78 };
-580
components/PageLayouts/DashboardLayout.tsx
··· 1 - "use client"; 2 - import { useState, createContext, useContext, useEffect } from "react"; 3 - import { useSearchParams } from "next/navigation"; 4 - import { Header } from "../PageHeader"; 5 - import { Footer } from "components/ActionBar/Footer"; 6 - import { Sidebar } from "components/ActionBar/Sidebar"; 7 - import { DesktopNavigation } from "components/ActionBar/DesktopNavigation"; 8 - 9 - import { MobileNavigation } from "components/ActionBar/MobileNavigation"; 10 - import { 11 - navPages, 12 - NotificationButton, 13 - } from "components/ActionBar/NavigationButtons"; 14 - import { create } from "zustand"; 15 - import { Popover } from "components/Popover"; 16 - import { Checkbox } from "components/Checkbox"; 17 - import { Separator } from "components/Layout"; 18 - import { CloseTiny } from "components/Icons/CloseTiny"; 19 - import { MediaContents } from "components/Media"; 20 - import { SortSmall } from "components/Icons/SortSmall"; 21 - import { TabsSmall } from "components/Icons/TabsSmall"; 22 - import { Input } from "components/Input"; 23 - import { SearchTiny } from "components/Icons/SearchTiny"; 24 - import { InterfaceState, useIdentityData } from "components/IdentityProvider"; 25 - import { updateIdentityInterfaceState } from "actions/updateIdentityInterfaceState"; 26 - import Link from "next/link"; 27 - import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; 28 - import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 29 - import { Tab } from "components/Tab"; 30 - import { PubIcon, PublicationButtons } from "components/ActionBar/Publications"; 31 - 32 - export type DashboardState = { 33 - display?: "grid" | "list"; 34 - sort?: "created" | "alphabetical"; 35 - filter: { 36 - drafts: boolean; 37 - published: boolean; 38 - docs: boolean; 39 - archived: boolean; 40 - }; 41 - subscriberStatus: { 42 - unconfirmed: boolean; 43 - subscribed: boolean; 44 - unsubscribed: boolean; 45 - }; 46 - }; 47 - 48 - type DashboardStore = { 49 - dashboards: { [id: string]: DashboardState }; 50 - setDashboard: (id: string, partial: Partial<DashboardState>) => void; 51 - }; 52 - 53 - const defaultDashboardState: DashboardState = { 54 - display: undefined, 55 - sort: undefined, 56 - filter: { 57 - drafts: false, 58 - published: false, 59 - docs: false, 60 - archived: false, 61 - }, 62 - subscriberStatus: { 63 - unconfirmed: false, 64 - subscribed: true, 65 - unsubscribed: false, 66 - }, 67 - }; 68 - 69 - // Existing identities have stored interface_state without newer fields 70 - // (e.g. subscriberStatus). Merge so callers always see a complete shape. 71 - function withDefaults(stored: DashboardState | undefined): DashboardState { 72 - if (!stored) return defaultDashboardState; 73 - return { 74 - ...defaultDashboardState, 75 - ...stored, 76 - filter: { ...defaultDashboardState.filter, ...stored.filter }, 77 - subscriberStatus: { 78 - ...defaultDashboardState.subscriberStatus, 79 - ...stored.subscriberStatus, 80 - }, 81 - }; 82 - } 83 - 84 - export const useDashboardStore = create<DashboardStore>((set, get) => ({ 85 - dashboards: {}, 86 - setDashboard: (id: string, partial: Partial<DashboardState>) => { 87 - set((state) => ({ 88 - dashboards: { 89 - ...state.dashboards, 90 - [id]: { 91 - ...(state.dashboards[id] || defaultDashboardState), 92 - ...partial, 93 - }, 94 - }, 95 - })); 96 - }, 97 - })); 98 - 99 - export const DashboardIdContext = createContext<string | null>(null); 100 - 101 - export const useDashboardId = () => { 102 - const id = useContext(DashboardIdContext); 103 - if (!id) { 104 - throw new Error("useDashboardId must be used within a DashboardLayout"); 105 - } 106 - return id; 107 - }; 108 - 109 - export const useDashboardState = () => { 110 - const id = useDashboardId(); 111 - let { identity } = useIdentityData(); 112 - let localState = useDashboardStore((state) => state.dashboards[id]); 113 - if (!identity) return withDefaults(localState); 114 - let metadata = identity.interface_state as InterfaceState; 115 - return withDefaults(metadata?.dashboards?.[id]); 116 - }; 117 - 118 - export const useSetDashboardState = () => { 119 - const id = useDashboardId(); 120 - let { identity, mutate } = useIdentityData(); 121 - const setDashboard = useDashboardStore((state) => state.setDashboard); 122 - return async (partial: Partial<DashboardState>) => { 123 - if (!identity) return setDashboard(id, partial); 124 - 125 - let interface_state = (identity.interface_state as InterfaceState) || {}; 126 - let newDashboardState = { 127 - ...defaultDashboardState, 128 - ...interface_state.dashboards?.[id], 129 - ...partial, 130 - }; 131 - mutate( 132 - { 133 - ...identity, 134 - interface_state: { 135 - ...interface_state, 136 - dashboards: { 137 - ...interface_state.dashboards, 138 - [id]: newDashboardState, 139 - }, 140 - }, 141 - }, 142 - { revalidate: false }, 143 - ); 144 - await updateIdentityInterfaceState({ 145 - ...interface_state, 146 - dashboards: { 147 - [id]: newDashboardState, 148 - }, 149 - }); 150 - }; 151 - }; 152 - 153 - export function DashboardLayout< 154 - T extends { 155 - [name: string]: { 156 - content: React.ReactNode; 157 - controls: React.ReactNode; 158 - icon?: React.ReactNode; 159 - }; 160 - }, 161 - >(props: { 162 - id: string; 163 - tabs: T; 164 - defaultTab: keyof T; 165 - currentPage: navPages; 166 - publication?: string; 167 - profileDid?: string; 168 - actions?: React.ReactNode; 169 - pageTitle?: string; 170 - onTabHover?: (tabName: string) => void; 171 - }) { 172 - const searchParams = useSearchParams(); 173 - const tabParam = searchParams.get("tab"); 174 - 175 - // Initialize tab from search param if valid, otherwise use default 176 - const initialTab = 177 - tabParam && props.tabs[tabParam] ? tabParam : props.defaultTab; 178 - let [tab, setTab] = useState<keyof T>(initialTab); 179 - 180 - // Custom setter that updates both state and URL 181 - const setTabWithUrl = (newTab: keyof T) => { 182 - setTab(newTab); 183 - const params = new URLSearchParams(searchParams.toString()); 184 - params.set("tab", newTab as string); 185 - const newUrl = `${window.location.pathname}?${params.toString()}`; 186 - window.history.replaceState(null, "", newUrl); 187 - }; 188 - 189 - let { content, controls } = props.tabs[tab]; 190 - let { ref } = usePreserveScroll<HTMLDivElement>( 191 - `dashboard-${props.id}-${tab as string}`, 192 - ); 193 - 194 - let [headerState, setHeaderState] = useState<"default" | "controls">( 195 - "default", 196 - ); 197 - 198 - return ( 199 - <DashboardIdContext.Provider value={props.id}> 200 - <div 201 - className={`dashboard pwa-padding relative max-w-(--breakpoint-lg) w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6`} 202 - > 203 - <MediaContents mobile={false}> 204 - <div className="flex flex-col gap-3 my-6"> 205 - <DesktopNavigation 206 - currentPage={props.currentPage} 207 - publication={props.publication} 208 - /> 209 - {props.actions && <Sidebar alwaysOpen>{props.actions}</Sidebar>} 210 - </div> 211 - </MediaContents> 212 - <div 213 - className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-3 px-3 sm:pt-8 sm:pb-6 sm:pl-8 sm:pr-4 `} 214 - ref={ref} 215 - id="home-content" 216 - > 217 - {props.pageTitle && ( 218 - <PageTitle pageTitle={props.pageTitle} actions={props.actions} /> 219 - )} 220 - 221 - {Object.keys(props.tabs).length <= 1 && !controls ? null : ( 222 - <> 223 - <Header> 224 - {headerState === "default" ? ( 225 - <> 226 - {Object.keys(props.tabs).length > 1 && ( 227 - <div className="pubDashTabs flex flex-row gap-1"> 228 - {Object.keys(props.tabs).map((t) => { 229 - return ( 230 - <Tab 231 - key={t} 232 - name={t} 233 - icon={props.tabs[t].icon} 234 - selected={t === tab} 235 - onSelect={() => setTabWithUrl(t)} 236 - onMouseEnter={() => props.onTabHover?.(t)} 237 - onPointerDown={() => props.onTabHover?.(t)} 238 - /> 239 - ); 240 - })} 241 - </div> 242 - )} 243 - {props.publication && controls && ( 244 - <button 245 - className={`sm:hidden block text-tertiary`} 246 - onClick={() => { 247 - setHeaderState("controls"); 248 - }} 249 - > 250 - <SortSmall /> 251 - </button> 252 - )} 253 - <div 254 - className={`sm:block ${props.publication && "hidden"} grow`} 255 - > 256 - {controls} 257 - </div> 258 - </> 259 - ) : ( 260 - <> 261 - {controls} 262 - <button 263 - className="text-tertiary" 264 - onClick={() => { 265 - setHeaderState("default"); 266 - }} 267 - > 268 - <TabsSmall /> 269 - </button> 270 - </> 271 - )} 272 - </Header> 273 - </> 274 - )} 275 - {content} 276 - </div> 277 - <Footer> 278 - <MobileNavigation 279 - currentPage={props.currentPage} 280 - currentPublicationUri={props.publication} 281 - currentProfileDid={props.profileDid} 282 - /> 283 - </Footer> 284 - </div> 285 - </DashboardIdContext.Provider> 286 - ); 287 - } 288 - 289 - export const PageTitle = (props: { 290 - pageTitle: string; 291 - actions: React.ReactNode; 292 - }) => { 293 - return ( 294 - <MediaContents 295 - mobile={true} 296 - className="flex justify-between items-center px-1 mt-1 -mb-1 w-full " 297 - > 298 - <h4 className="grow truncate">{props.pageTitle}</h4> 299 - <div className="flex flex-row-reverse! gap-1">{props.actions}</div> 300 - {/* <div className="shrink-0 h-6">{props.controls}</div> */} 301 - </MediaContents> 302 - ); 303 - }; 304 - 305 - export const HomeDashboardControls = (props: { 306 - searchValue: string; 307 - setSearchValueAction: (searchValue: string) => void; 308 - hasBackgroundImage: boolean; 309 - defaultDisplay: Exclude<DashboardState["display"], undefined>; 310 - hasPubs: boolean; 311 - hasArchived: boolean; 312 - }) => { 313 - let { display, sort } = useDashboardState(); 314 - display = display || props.defaultDisplay; 315 - let setState = useSetDashboardState(); 316 - 317 - let { identity } = useIdentityData(); 318 - 319 - return ( 320 - <div className="dashboardControls w-full flex gap-4"> 321 - {identity && ( 322 - <SearchInput 323 - searchValue={props.searchValue} 324 - setSearchValue={props.setSearchValueAction} 325 - hasBackgroundImage={props.hasBackgroundImage} 326 - /> 327 - )} 328 - <div className="flex gap-2 w-max shrink-0 items-center text-sm text-tertiary"> 329 - <DisplayToggle setState={setState} display={display} /> 330 - <Separator classname="h-4 min-h-4!" /> 331 - 332 - {props.hasPubs || props.hasArchived ? ( 333 - <> 334 - <FilterOptions 335 - hasPubs={props.hasPubs} 336 - hasArchived={props.hasArchived} 337 - /> 338 - <Separator classname="h-4 min-h-4!" />{" "} 339 - </> 340 - ) : null} 341 - <SortToggle setState={setState} sort={sort} /> 342 - </div> 343 - </div> 344 - ); 345 - }; 346 - 347 - export const PublicationDashboardControls = (props: { 348 - searchValue: string; 349 - setSearchValueAction: (searchValue: string) => void; 350 - hasBackgroundImage: boolean; 351 - defaultDisplay: Exclude<DashboardState["display"], undefined>; 352 - }) => { 353 - let { display, sort } = useDashboardState(); 354 - display = display || props.defaultDisplay; 355 - let setState = useSetDashboardState(); 356 - return ( 357 - <div className="dashboardControls w-full flex gap-4"> 358 - <SearchInput 359 - searchValue={props.searchValue} 360 - setSearchValue={props.setSearchValueAction} 361 - hasBackgroundImage={props.hasBackgroundImage} 362 - /> 363 - <div className="flex gap-2 w-max shrink-0 items-center text-sm text-tertiary"> 364 - <DisplayToggle setState={setState} display={display} /> 365 - <Separator classname="h-4 min-h-4!" /> 366 - <SortToggle setState={setState} sort={sort} /> 367 - </div> 368 - </div> 369 - ); 370 - }; 371 - 372 - const SortToggle = (props: { 373 - setState: (partial: Partial<DashboardState>) => Promise<void>; 374 - sort: string | undefined; 375 - }) => { 376 - return ( 377 - <button 378 - onClick={() => 379 - props.setState({ 380 - sort: props.sort === "created" ? "alphabetical" : "created", 381 - }) 382 - } 383 - > 384 - Sort: {props.sort === "created" ? "Created On" : "A to Z"} 385 - </button> 386 - ); 387 - }; 388 - 389 - const DisplayToggle = (props: { 390 - setState: (partial: Partial<DashboardState>) => Promise<void>; 391 - display: string | undefined; 392 - }) => { 393 - return ( 394 - <button 395 - onClick={() => { 396 - props.setState({ 397 - display: props.display === "list" ? "grid" : "list", 398 - }); 399 - }} 400 - > 401 - {props.display === "list" ? "List" : "Grid"} 402 - </button> 403 - ); 404 - }; 405 - 406 - const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => { 407 - let { filter } = useDashboardState(); 408 - let setState = useSetDashboardState(); 409 - let filterCount = Object.values(filter).filter(Boolean).length; 410 - 411 - return ( 412 - <Popover 413 - className="text-sm px-2! py-1!" 414 - trigger={<div>Filter {filterCount > 0 && `(${filterCount})`}</div>} 415 - > 416 - {props.hasPubs && ( 417 - <> 418 - <Checkbox 419 - small 420 - checked={filter.drafts} 421 - onChange={(e) => 422 - setState({ 423 - filter: { ...filter, drafts: !!e.target.checked }, 424 - }) 425 - } 426 - > 427 - Drafts 428 - </Checkbox> 429 - <Checkbox 430 - small 431 - checked={filter.published} 432 - onChange={(e) => 433 - setState({ 434 - filter: { ...filter, published: !!e.target.checked }, 435 - }) 436 - } 437 - > 438 - Published 439 - </Checkbox> 440 - </> 441 - )} 442 - 443 - {props.hasArchived && ( 444 - <Checkbox 445 - small 446 - checked={filter.archived} 447 - onChange={(e) => 448 - setState({ 449 - filter: { ...filter, archived: !!e.target.checked }, 450 - }) 451 - } 452 - > 453 - Archived 454 - </Checkbox> 455 - )} 456 - <Checkbox 457 - small 458 - checked={filter.docs} 459 - onChange={(e) => 460 - setState({ 461 - filter: { ...filter, docs: !!e.target.checked }, 462 - }) 463 - } 464 - > 465 - Docs 466 - </Checkbox> 467 - <hr className="border-border-light mt-1 mb-0.5" /> 468 - <button 469 - className="flex gap-1 items-center -mx-[2px] text-tertiary" 470 - onClick={() => { 471 - setState({ 472 - filter: { 473 - docs: false, 474 - published: false, 475 - drafts: false, 476 - archived: false, 477 - }, 478 - }); 479 - }} 480 - > 481 - <CloseTiny className="scale-75" /> Clear 482 - </button> 483 - </Popover> 484 - ); 485 - }; 486 - 487 - export const SubscriberStatusFilter = () => { 488 - let { subscriberStatus } = useDashboardState(); 489 - let setState = useSetDashboardState(); 490 - let count = Object.values(subscriberStatus).filter(Boolean).length; 491 - 492 - return ( 493 - <Popover 494 - className="text-sm px-2! py-1!" 495 - trigger={<div>Status {count > 0 && `(${count})`}</div>} 496 - > 497 - <Checkbox 498 - small 499 - checked={subscriberStatus.subscribed} 500 - onChange={(e) => 501 - setState({ 502 - subscriberStatus: { 503 - ...subscriberStatus, 504 - subscribed: !!e.target.checked, 505 - }, 506 - }) 507 - } 508 - > 509 - Subscribed 510 - </Checkbox> 511 - <Checkbox 512 - small 513 - checked={subscriberStatus.unconfirmed} 514 - onChange={(e) => 515 - setState({ 516 - subscriberStatus: { 517 - ...subscriberStatus, 518 - unconfirmed: !!e.target.checked, 519 - }, 520 - }) 521 - } 522 - > 523 - Unconfirmed 524 - </Checkbox> 525 - <Checkbox 526 - small 527 - checked={subscriberStatus.unsubscribed} 528 - onChange={(e) => 529 - setState({ 530 - subscriberStatus: { 531 - ...subscriberStatus, 532 - unsubscribed: !!e.target.checked, 533 - }, 534 - }) 535 - } 536 - > 537 - Unsubscribed 538 - </Checkbox> 539 - </Popover> 540 - ); 541 - }; 542 - 543 - export const SubscriberDashboardControls = () => { 544 - return ( 545 - <div className="dashboardControls w-full flex gap-4 justify-end"> 546 - <div className="flex gap-2 w-max shrink-0 items-center text-sm text-tertiary"> 547 - <SubscriberStatusFilter /> 548 - </div> 549 - </div> 550 - ); 551 - }; 552 - 553 - const SearchInput = (props: { 554 - searchValue: string; 555 - setSearchValue: (searchValue: string) => void; 556 - hasBackgroundImage: boolean; 557 - }) => { 558 - return ( 559 - <div className="relative grow shrink-0"> 560 - <Input 561 - className={`dashboardSearchInput 562 - appearance-none! outline-hidden! 563 - w-full min-w-0 text-primary relative pl-7 pr-1 -my-px 564 - border rounded-md border-border-light focus-within:border-border 565 - bg-transparent ${props.hasBackgroundImage ? "focus-within:bg-bg-page" : "focus-within:bg-bg-leaflet"} `} 566 - type="text" 567 - id="pubName" 568 - size={1} 569 - placeholder="search..." 570 - value={props.searchValue} 571 - onChange={(e) => { 572 - props.setSearchValue(e.currentTarget.value); 573 - }} 574 - /> 575 - <div className="absolute left-[6px] top-[4px] text-tertiary"> 576 - <SearchTiny /> 577 - </div> 578 - </div> 579 - ); 580 - };
+39
components/PageLayouts/DashboardPageLayout.tsx
··· 1 + "use client"; 2 + import { PageHeader } from "components/PageHeader"; 3 + import { MobileNavigation } from "../ActionBar/MobileNavigation"; 4 + import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 5 + 6 + export function DashboardPageLayout(props: { 7 + scrollKey: string; 8 + pageTitle: string; 9 + mobileActions?: React.ReactNode; 10 + search?: React.ReactNode; 11 + publication?: string; 12 + showHeader?: boolean; 13 + children: React.ReactNode; 14 + }) { 15 + let { ref } = usePreserveScroll<HTMLDivElement>(props.scrollKey); 16 + 17 + return ( 18 + <div 19 + className={`dashboardPage w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-[calc(var(--safe-padding-bottom)+56px)] px-3 sm:pt-6 sm:pb-6 sm:pl-8 sm:pr-4`} 20 + ref={ref} 21 + id="home-content" 22 + > 23 + {props.showHeader && ( 24 + <PageHeader> 25 + <div className={`sm:block ${props.publication && "hidden"} grow`}> 26 + {props.search} 27 + </div> 28 + </PageHeader> 29 + )} 30 + {props.children} 31 + 32 + <MobileNavigation 33 + pageTitle={props.pageTitle} 34 + mobileActions={props.mobileActions} 35 + search={props.search} 36 + /> 37 + </div> 38 + ); 39 + }
+61
components/PageLayouts/DashboardShell.tsx
··· 1 + "use client"; 2 + import * as Dialog from "@radix-ui/react-dialog"; 3 + import { useSearchParams } from "next/navigation"; 4 + import { 5 + DesktopNavigation, 6 + NavigationContent, 7 + } from "components/ActionBar/DesktopNavigation"; 8 + import { Sidebar, useSidebarStore } from "components/ActionBar/Sidebar"; 9 + import { DashboardIdContext } from "./dashboardState"; 10 + import { NotificationContent } from "./NotificationContent"; 11 + 12 + export type DashboardShellProps = { 13 + id: string; 14 + pageTitle: React.ReactNode; 15 + publication?: string; 16 + actions?: React.ReactNode; 17 + tabs?: { [name: string]: { href: string; icon?: React.ReactNode } }; 18 + children: React.ReactNode; 19 + }; 20 + 21 + export function DashboardShell(props: DashboardShellProps) { 22 + let { id, children, ...navigationProps } = props; 23 + let { open, setOpen } = useSidebarStore(); 24 + let searchParams = useSearchParams(); 25 + let showNotifications = searchParams.get("notifications") === "open"; 26 + 27 + return ( 28 + <DashboardIdContext.Provider value={id}> 29 + <div 30 + className={`dashboard pwa-padding relative max-w-(--breakpoint-lg) w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6`} 31 + > 32 + <DesktopNavigation {...navigationProps} /> 33 + <Dialog.Root open={open} onOpenChange={setOpen}> 34 + <Dialog.Portal> 35 + <Dialog.Overlay className="fixed z-50 inset-0 bg-primary opacity-60 data-[state=open]:animate-overlayShow" /> 36 + <Dialog.Content 37 + className="mobile-sidebar-content fixed z-50 left-0 top-0 h-dvh outline-none" 38 + onClick={(e) => { 39 + const target = e.target as HTMLElement; 40 + const interactive = target.closest("a, button"); 41 + if (!interactive) return; 42 + if (interactive.getAttribute("aria-haspopup")) return; 43 + setOpen(false); 44 + }} 45 + > 46 + <Dialog.Title className="sr-only">Navigation</Dialog.Title> 47 + <Sidebar 48 + mobile 49 + alwaysOpen 50 + className="my-2! ml-2 h-[calc(100dvh-16px)]! bg-bg-page!" 51 + > 52 + <NavigationContent {...navigationProps} /> 53 + </Sidebar> 54 + </Dialog.Content> 55 + </Dialog.Portal> 56 + </Dialog.Root> 57 + {showNotifications ? <NotificationContent /> : children} 58 + </div> 59 + </DashboardIdContext.Provider> 60 + ); 61 + }
+32
components/PageLayouts/NotificationContent.tsx
··· 1 + "use client"; 2 + import { useEffect, useState } from "react"; 3 + import { DashboardPageLayout } from "./DashboardPageLayout"; 4 + import { NotificationList } from "app/(home-pages)/(writer)/notifications/NotificationList"; 5 + import { getNotifications } from "app/(home-pages)/(writer)/notifications/getNotifications"; 6 + import { HydratedNotification } from "src/notifications"; 7 + 8 + export function NotificationContent() { 9 + let [notifications, setNotifications] = useState< 10 + HydratedNotification[] | null 11 + >(null); 12 + 13 + useEffect(() => { 14 + getNotifications().then(setNotifications); 15 + }, []); 16 + 17 + return ( 18 + <DashboardPageLayout 19 + pageTitle="Notifications" 20 + scrollKey="notifications-overlay" 21 + showHeader={false} 22 + > 23 + {notifications === null ? ( 24 + <div className="flex justify-center py-6"> 25 + <div className="h-5 w-5 animate-spin rounded-full border-2 border-border border-t-accent-contrast" /> 26 + </div> 27 + ) : ( 28 + <NotificationList notifications={notifications} /> 29 + )} 30 + </DashboardPageLayout> 31 + ); 32 + }
+333
components/PageLayouts/PageSearch.tsx
··· 1 + "use client"; 2 + import { Popover } from "components/Popover"; 3 + import { Checkbox } from "components/Checkbox"; 4 + import { Separator } from "components/Layout"; 5 + import { CloseTiny } from "components/Icons/CloseTiny"; 6 + import { Input } from "components/Input"; 7 + import { SearchTiny } from "components/Icons/SearchTiny"; 8 + import { SortSmall } from "components/Icons/SortSmall"; 9 + import { 10 + DashboardState, 11 + useDashboardState, 12 + useSetDashboardState, 13 + } from "./dashboardState"; 14 + 15 + export const PageSearch = (props: { 16 + searchValue: string; 17 + setSearchValueAction: (searchValue: string) => void; 18 + hasBackgroundImage: boolean; 19 + defaultDisplay: Exclude<DashboardState["display"], undefined>; 20 + hasPubs?: boolean; 21 + hasArchived?: boolean; 22 + }) => { 23 + let { display, sort } = useDashboardState(); 24 + display = display || props.defaultDisplay; 25 + let setState = useSetDashboardState(); 26 + 27 + return ( 28 + <div className="searchControls w-full flex sm:gap-4 gap-2"> 29 + <SearchInput 30 + searchValue={props.searchValue} 31 + setSearchValue={props.setSearchValueAction} 32 + hasBackgroundImage={props.hasBackgroundImage} 33 + /> 34 + <div className="desktopSearchControls hidden sm:flex gap-2 w-max shrink-0 items-center text-sm text-tertiary"> 35 + <DisplayToggle setState={setState} display={display} /> 36 + <Separator classname="h-4 min-h-4!" /> 37 + {props.hasPubs || props.hasArchived ? ( 38 + <> 39 + <FilterOptions 40 + hasPubs={props.hasPubs ?? false} 41 + hasArchived={props.hasArchived ?? false} 42 + /> 43 + <Separator classname="h-4 min-h-4!" />{" "} 44 + </> 45 + ) : null} 46 + <SortToggle setState={setState} sort={sort} /> 47 + </div> 48 + <div className="flex sm:hidden w-max shrink-0 items-center text-sm text-tertiary"> 49 + <MobileSearchControls 50 + display={display} 51 + sort={sort} 52 + setState={setState} 53 + hasPubs={props.hasPubs} 54 + hasArchived={props.hasArchived} 55 + /> 56 + </div> 57 + </div> 58 + ); 59 + }; 60 + 61 + const SortToggle = (props: { 62 + setState: (partial: Partial<DashboardState>) => Promise<void>; 63 + sort: string | undefined; 64 + }) => { 65 + return ( 66 + <button 67 + onClick={() => 68 + props.setState({ 69 + sort: props.sort === "created" ? "alphabetical" : "created", 70 + }) 71 + } 72 + > 73 + Sort: {props.sort === "created" ? "Created On" : "A to Z"} 74 + </button> 75 + ); 76 + }; 77 + 78 + const DisplayToggle = (props: { 79 + setState: (partial: Partial<DashboardState>) => Promise<void>; 80 + display: string | undefined; 81 + }) => { 82 + return ( 83 + <button 84 + onClick={() => { 85 + props.setState({ 86 + display: props.display === "list" ? "grid" : "list", 87 + }); 88 + }} 89 + > 90 + {props.display === "list" ? "List" : "Grid"} 91 + </button> 92 + ); 93 + }; 94 + 95 + const FilterCheckboxes = (props: { 96 + hasPubs: boolean; 97 + hasArchived: boolean; 98 + }) => { 99 + let { filter } = useDashboardState(); 100 + let setState = useSetDashboardState(); 101 + let filterCount = Object.values(filter).filter(Boolean).length; 102 + 103 + return ( 104 + <> 105 + {props.hasPubs && ( 106 + <> 107 + <Checkbox 108 + small 109 + checked={filter.drafts} 110 + onChange={(e) => 111 + setState({ filter: { ...filter, drafts: !!e.target.checked } }) 112 + } 113 + > 114 + Drafts 115 + </Checkbox> 116 + <Checkbox 117 + small 118 + checked={filter.published} 119 + onChange={(e) => 120 + setState({ 121 + filter: { ...filter, published: !!e.target.checked }, 122 + }) 123 + } 124 + > 125 + Published 126 + </Checkbox> 127 + </> 128 + )} 129 + {props.hasArchived && ( 130 + <Checkbox 131 + small 132 + checked={filter.archived} 133 + onChange={(e) => 134 + setState({ filter: { ...filter, archived: !!e.target.checked } }) 135 + } 136 + > 137 + Archived 138 + </Checkbox> 139 + )} 140 + <Checkbox 141 + small 142 + checked={filter.docs} 143 + onChange={(e) => 144 + setState({ filter: { ...filter, docs: !!e.target.checked } }) 145 + } 146 + > 147 + Docs 148 + </Checkbox> 149 + 150 + <hr className="border-border-light border-dashed mt-1 mb-0.5" /> 151 + <button 152 + className="flex gap-1 items-center -mx-[2px] text-tertiary" 153 + onClick={() => 154 + setState({ 155 + filter: { 156 + docs: false, 157 + published: false, 158 + drafts: false, 159 + archived: false, 160 + }, 161 + }) 162 + } 163 + > 164 + <CloseTiny className="scale-75" /> Clear 165 + </button> 166 + </> 167 + ); 168 + }; 169 + 170 + export const SubscriberStatusFilter = () => { 171 + let { subscriberStatus } = useDashboardState(); 172 + let setState = useSetDashboardState(); 173 + let count = Object.values(subscriberStatus).filter(Boolean).length; 174 + 175 + return ( 176 + <Popover 177 + className="text-sm px-2! py-1!" 178 + trigger={<div>Status {count > 0 && `(${count})`}</div>} 179 + > 180 + <Checkbox 181 + small 182 + checked={subscriberStatus.subscribed} 183 + onChange={(e) => 184 + setState({ 185 + subscriberStatus: { 186 + ...subscriberStatus, 187 + subscribed: !!e.target.checked, 188 + }, 189 + }) 190 + } 191 + > 192 + Subscribed 193 + </Checkbox> 194 + <Checkbox 195 + small 196 + checked={subscriberStatus.unconfirmed} 197 + onChange={(e) => 198 + setState({ 199 + subscriberStatus: { 200 + ...subscriberStatus, 201 + unconfirmed: !!e.target.checked, 202 + }, 203 + }) 204 + } 205 + > 206 + Unconfirmed 207 + </Checkbox> 208 + <Checkbox 209 + small 210 + checked={subscriberStatus.unsubscribed} 211 + onChange={(e) => 212 + setState({ 213 + subscriberStatus: { 214 + ...subscriberStatus, 215 + unsubscribed: !!e.target.checked, 216 + }, 217 + }) 218 + } 219 + > 220 + Unsubscribed 221 + </Checkbox> 222 + </Popover> 223 + ); 224 + }; 225 + 226 + const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => { 227 + let { filter } = useDashboardState(); 228 + let filterCount = Object.values(filter).filter(Boolean).length; 229 + 230 + return ( 231 + <Popover 232 + className="text-sm px-2! py-1!" 233 + trigger={<div>Filter {filterCount > 0 && `(${filterCount})`}</div>} 234 + > 235 + <FilterCheckboxes 236 + hasPubs={props.hasPubs} 237 + hasArchived={props.hasArchived} 238 + /> 239 + </Popover> 240 + ); 241 + }; 242 + 243 + const MobileSearchControls = (props: { 244 + display: string; 245 + sort: string | undefined; 246 + setState: (partial: Partial<DashboardState>) => Promise<void>; 247 + hasPubs?: boolean; 248 + hasArchived?: boolean; 249 + }) => { 250 + let { filter } = useDashboardState(); 251 + let filterCount = Object.values(filter).filter(Boolean).length; 252 + 253 + return ( 254 + <Popover 255 + align="end" 256 + className={`text-sm w-48`} 257 + trigger={ 258 + <div 259 + className={`${filterCount > 0 ? "text-accent-2 bg-accent-1 rounded-md" : "text-secondary"}`} 260 + > 261 + <SortSmall /> 262 + </div> 263 + } 264 + > 265 + <button 266 + className="w-full flex justify-between font-bold" 267 + onClick={() => 268 + props.setState({ 269 + display: props.display === "list" ? "grid" : "list", 270 + }) 271 + } 272 + > 273 + <div className="text-sm text-tertiary font-normal uppercase"> 274 + Display 275 + </div> 276 + {props.display === "list" ? "List" : "Grid"} 277 + </button> 278 + <hr className="border-border-light my-1" /> 279 + <button 280 + className="w-full flex justify-between font-bold" 281 + onClick={() => 282 + props.setState({ 283 + sort: props.sort === "created" ? "alphabetical" : "created", 284 + }) 285 + } 286 + > 287 + <div className="text-sm text-tertiary font-normal uppercase">Sort</div> 288 + {props.sort === "created" ? "Created On" : "A to Z"} 289 + </button> 290 + {(props.hasPubs || props.hasArchived) && ( 291 + <> 292 + <hr className="border-border-light my-1" /> 293 + <div className="text-sm text-tertiary font-normal uppercase"> 294 + Filter 295 + </div> 296 + <FilterCheckboxes 297 + hasPubs={props.hasPubs ?? false} 298 + hasArchived={props.hasArchived ?? false} 299 + /> 300 + </> 301 + )} 302 + </Popover> 303 + ); 304 + }; 305 + 306 + const SearchInput = (props: { 307 + searchValue: string; 308 + setSearchValue: (searchValue: string) => void; 309 + hasBackgroundImage: boolean; 310 + }) => { 311 + return ( 312 + <div className="relative grow shrink-0"> 313 + <Input 314 + className={`dashboardSearchInput 315 + appearance-none! outline-hidden! 316 + w-full min-w-0 text-primary relative pl-7 pr-1 -my-px 317 + border rounded-md border-border-light focus-within:border-border 318 + bg-transparent focus-within:bg-bg-page`} 319 + type="text" 320 + id="pubName" 321 + size={1} 322 + placeholder="search..." 323 + value={props.searchValue} 324 + onChange={(e) => { 325 + props.setSearchValue(e.currentTarget.value); 326 + }} 327 + /> 328 + <div className="absolute left-[6px] top-[4px] text-secondary"> 329 + <SearchTiny /> 330 + </div> 331 + </div> 332 + ); 333 + };
+126
components/PageLayouts/dashboardState.ts
··· 1 + "use client"; 2 + import { createContext, useContext } from "react"; 3 + import { create } from "zustand"; 4 + import { InterfaceState, useIdentityData } from "components/IdentityProvider"; 5 + import { updateIdentityInterfaceState } from "actions/updateIdentityInterfaceState"; 6 + 7 + export type DashboardState = { 8 + display?: "grid" | "list"; 9 + sort?: "created" | "alphabetical"; 10 + filter: { 11 + drafts: boolean; 12 + published: boolean; 13 + docs: boolean; 14 + archived: boolean; 15 + }; 16 + subscriberStatus: { 17 + unconfirmed: boolean; 18 + subscribed: boolean; 19 + unsubscribed: boolean; 20 + }; 21 + }; 22 + 23 + type DashboardStore = { 24 + dashboards: { [id: string]: DashboardState }; 25 + setDashboard: (id: string, partial: Partial<DashboardState>) => void; 26 + }; 27 + 28 + const defaultDashboardState: DashboardState = { 29 + display: undefined, 30 + sort: undefined, 31 + filter: { 32 + drafts: false, 33 + published: false, 34 + docs: false, 35 + archived: false, 36 + }, 37 + subscriberStatus: { 38 + unconfirmed: false, 39 + subscribed: true, 40 + unsubscribed: false, 41 + }, 42 + }; 43 + 44 + // Existing identities have stored interface_state without newer fields 45 + // (e.g. subscriberStatus). Merge so callers always see a complete shape. 46 + function withDefaults(stored: DashboardState | undefined): DashboardState { 47 + if (!stored) return defaultDashboardState; 48 + return { 49 + ...defaultDashboardState, 50 + ...stored, 51 + filter: { ...defaultDashboardState.filter, ...stored.filter }, 52 + subscriberStatus: { 53 + ...defaultDashboardState.subscriberStatus, 54 + ...stored.subscriberStatus, 55 + }, 56 + }; 57 + } 58 + 59 + export const useDashboardStore = create<DashboardStore>((set) => ({ 60 + dashboards: {}, 61 + setDashboard: (id: string, partial: Partial<DashboardState>) => { 62 + set((state) => ({ 63 + dashboards: { 64 + ...state.dashboards, 65 + [id]: { 66 + ...(state.dashboards[id] || defaultDashboardState), 67 + ...partial, 68 + }, 69 + }, 70 + })); 71 + }, 72 + })); 73 + 74 + export const DashboardIdContext = createContext<string | null>(null); 75 + 76 + export const useDashboardId = () => { 77 + const id = useContext(DashboardIdContext); 78 + if (!id) { 79 + throw new Error("useDashboardId must be used within a DashboardLayout"); 80 + } 81 + return id; 82 + }; 83 + 84 + export const useDashboardState = () => { 85 + const id = useDashboardId(); 86 + let { identity } = useIdentityData(); 87 + let localState = useDashboardStore((state) => state.dashboards[id]); 88 + if (!identity) return withDefaults(localState); 89 + let metadata = identity.interface_state as InterfaceState; 90 + return withDefaults(metadata?.dashboards?.[id]); 91 + }; 92 + 93 + export const useSetDashboardState = () => { 94 + const id = useDashboardId(); 95 + let { identity, mutate } = useIdentityData(); 96 + const setDashboard = useDashboardStore((state) => state.setDashboard); 97 + return async (partial: Partial<DashboardState>) => { 98 + if (!identity) return setDashboard(id, partial); 99 + 100 + let interface_state = (identity.interface_state as InterfaceState) || {}; 101 + let newDashboardState = { 102 + ...defaultDashboardState, 103 + ...interface_state.dashboards?.[id], 104 + ...partial, 105 + }; 106 + mutate( 107 + { 108 + ...identity, 109 + interface_state: { 110 + ...interface_state, 111 + dashboards: { 112 + ...interface_state.dashboards, 113 + [id]: newDashboardState, 114 + }, 115 + }, 116 + }, 117 + { revalidate: false }, 118 + ); 119 + await updateIdentityInterfaceState({ 120 + ...interface_state, 121 + dashboards: { 122 + [id]: newDashboardState, 123 + }, 124 + }); 125 + }; 126 + };
+9 -2
components/Popover/index.tsx
··· 5 5 import { useEffect, useState } from "react"; 6 6 import { PopoverArrow } from "../Icons/PopoverArrow"; 7 7 import { PopoverOpenContext } from "./PopoverContext"; 8 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 8 9 export const Popover = (props: { 9 10 trigger: React.ReactNode; 10 11 disabled?: boolean; ··· 28 29 useEffect(() => { 29 30 if (props.open !== undefined) setOpen(props.open); 30 31 }, [props.open]); 32 + let cardBorderHidden = useCardBorderHidden(); 31 33 return ( 32 34 <RadixPopover.Root 33 35 defaultOpen={props.defaultOpen} ··· 38 40 }} 39 41 > 40 42 <PopoverOpenContext value={open}> 41 - <RadixPopover.Trigger disabled={props.disabled} asChild={props.asChild}> 43 + <RadixPopover.Trigger 44 + className="min-w-0" 45 + disabled={props.disabled} 46 + asChild={props.asChild} 47 + > 42 48 {props.trigger} 43 49 </RadixPopover.Trigger> 44 50 <RadixPopover.Portal> 45 51 <NestedCardThemeProvider> 46 52 <RadixPopover.Content 47 53 className={` 48 - z-20 relative bg-bg-page 54 + z-20 relative 49 55 text-primary 50 56 flex flex-col 51 57 px-3 py-2 ··· 53 59 max-h-(--radix-popover-content-available-height) 54 60 border border-border rounded-md shadow-md 55 61 ${props.className} 62 + ${cardBorderHidden ? "light-container" : "bg-bg-page"} 56 63 `} 57 64 side={props.side} 58 65 align={props.align ? props.align : "center"}
+5 -4
components/PostListing.tsx
··· 138 138 {postRecord.title} 139 139 </h3> 140 140 )} 141 - 142 - <p className="postListingDescription text-secondary line-clamp-3 leading-snug sm:text-base text-sm"> 143 - {postRecord.description || getFirstParagraph(postRecord)} 144 - </p> 141 + {postRecord.description && postRecord.description !== "" && ( 142 + <p className="postListingDescription text-secondary line-clamp-3 leading-snug sm:text-base text-sm"> 143 + {postRecord.description || getFirstParagraph(postRecord)} 144 + </p> 145 + )} 145 146 <div className="flex flex-col-reverse gap-2 text-sm text-tertiary items-center justify-start pt-1.5 w-full"> 146 147 {props.publication && pubRecord && ( 147 148 <PubInfo
+2
components/SpeedyLink.tsx
··· 7 7 className?: string; 8 8 style?: React.CSSProperties; 9 9 children?: React.ReactNode; 10 + target?: string; 10 11 }) { 11 12 let [prefetch, setPrefetch] = useState(false); 12 13 return ( ··· 16 17 style={props.style} 17 18 prefetch={prefetch} 18 19 href={props.href} 20 + target={props.target} 19 21 className={props.className} 20 22 > 21 23 {props.children}
+3 -3
components/Subscribe/HandleSubscribe.tsx
··· 238 238 export const AtmosphericHandleInfo = (props: { trigger?: React.ReactNode }) => { 239 239 return ( 240 240 <Popover 241 - className="z-100! max-w-sm flex flex-col" 241 + className="z-100! w-[min(24rem,var(--radix-popover-content-available-width))] flex flex-col" 242 242 trigger={ 243 243 props.trigger ? ( 244 244 props.trigger ··· 249 249 ) 250 250 } 251 251 > 252 - <div className="font-bold text-secondary pb-1"> 252 + <div className="font-bold text-secondary pb-1 "> 253 253 The Atmosphere is a growing ecosystem of social apps, like Leaflet and 254 254 Bluesky. 255 255 <br /> 256 256 </div> 257 - <div className="pb-3 font-bold text-secondary"> 257 + <div className="pb-3 font-bold text-secondary"> 258 258 One account gets you into <em>all</em> of them. 259 259 </div> 260 260
+9 -17
components/ThemeManager/ThemeSetter.tsx
··· 50 50 }); 51 51 } 52 52 export const ThemePopover = (props: { entityID: string; home?: boolean }) => { 53 - let { rep } = useReplicache(); 54 53 let { data: pub } = useLeafletPublicationData(); 55 54 let isMobile = useIsMobile(); 56 55 57 - // 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. 58 56 let permission = useEntitySetContext().permissions.write; 59 - let leafletBGImage = useEntity(props.entityID, "theme/background-image"); 60 - let leafletBGRepeat = useEntity( 61 - props.entityID, 62 - "theme/background-image-repeat", 63 - ); 64 - 65 - let [openPicker, setOpenPicker] = useState<pickers>( 66 - props.home === true ? "leaflet" : "null", 67 - ); 68 - let set = useMemo(() => { 69 - return setColorAttribute(rep, props.entityID); 70 - }, [rep, props.entityID]); 71 - 72 57 if (!permission) return null; 73 58 if (pub?.publications) return null; 74 59 ··· 80 65 asChild 81 66 side={isMobile ? "top" : "right"} 82 67 align={isMobile ? "center" : "start"} 83 - trigger={<ActionButton icon={<PaintSmall />} label="Theme" />} 68 + trigger={ 69 + <ActionButton 70 + icon={<PaintSmall />} 71 + label="Theme" 72 + className="sm:w-full! w-fit!" 73 + /> 74 + } 84 75 > 85 76 <ThemeSetterContent {...props} /> 86 77 </Popover> ··· 260 251 ?.data.value; 261 252 262 253 // Read font values directly since the popover is portalled outside .leafletWrapper 263 - let headingFontId = useEntity(props.entityID, "theme/heading-font")?.data.value; 254 + let headingFontId = useEntity(props.entityID, "theme/heading-font")?.data 255 + .value; 264 256 let bodyFontId = useEntity(props.entityID, "theme/body-font")?.data.value; 265 257 let bodyFontFamily = getFontFamilyValue(getFontConfig(bodyFontId)); 266 258 let headingFontFamily = getFontFamilyValue(getFontConfig(headingFontId));
+5 -1
components/ToggleGroup.tsx
··· 23 23 } 24 24 ${props.optionClassName} 25 25 `} 26 - onClick={() => props.onChange(option.value)} 26 + onClick={(e) => { 27 + e.preventDefault(); 28 + e.stopPropagation(); 29 + props.onChange(option.value); 30 + }} 27 31 > 28 32 {option.label} 29 33 </button>
+46 -72
components/ViewportSizeLayout.tsx
··· 2 2 import { useEffect, useState } from "react"; 3 3 import { isIOS } from "src/utils/isDevice"; 4 4 5 - export function ViewportSizeLayout(props: { children: React.ReactNode }) { 6 - let viewheight = useViewportSize().height; 7 - let difference = useViewportDifference(); 8 - return ( 9 - <div 10 - style={{ 11 - height: 12 - isIOS() && difference !== 0 13 - ? `calc(${viewheight}px + 10px)` 14 - : "calc(100% + env(safe-area-inset-top))", 15 - }} 16 - > 17 - {props.children} 18 - </div> 19 - ); 20 - } 5 + type VisualViewportState = { 6 + width: number; 7 + height: number; 8 + offsetTop: number; 9 + difference: number; 10 + }; 21 11 22 - function useViewportDifference(): number { 23 - let [difference, setDifference] = useState(0); 24 - 25 - useEffect(() => { 26 - // Use visualViewport api to track available height even on iOS virtual keyboard opening 27 - let onResize = () => { 28 - setDifference(window.innerHeight - getViewportSize().height); 29 - }; 30 - 31 - if (!visualViewport) { 32 - window.addEventListener("resize", onResize); 33 - } else { 34 - visualViewport.addEventListener("resize", onResize); 35 - } 36 - 37 - return () => { 38 - if (!visualViewport) { 39 - window.removeEventListener("resize", onResize); 40 - } else { 41 - visualViewport.removeEventListener("resize", onResize); 42 - } 43 - }; 44 - }, []); 45 - 46 - return difference; 47 - } 48 - 49 - function getViewportSize() { 50 - if (typeof window === "undefined") return { width: 0, height: 0 }; 12 + function getVisualViewport(): VisualViewportState { 13 + if (typeof window === "undefined") { 14 + return { width: 0, height: 0, offsetTop: 0, difference: 0 }; 15 + } 16 + let height = visualViewport?.height || window.innerHeight; 51 17 return { 52 - width: visualViewport?.width || window?.innerWidth, 53 - height: visualViewport?.height || window?.innerHeight, 18 + width: visualViewport?.width || window.innerWidth, 19 + height, 20 + offsetTop: visualViewport?.offsetTop || 0, 21 + difference: window.innerHeight - height, 54 22 }; 55 23 } 56 24 57 - export function useViewportSize(): { 58 - width: number; 59 - height: number; 60 - } { 61 - let [size, setSize] = useState(() => getViewportSize()); 25 + export function useVisualViewport(): VisualViewportState { 26 + let [state, setState] = useState(getVisualViewport); 62 27 63 28 useEffect(() => { 64 - // Use visualViewport api to track available height even on iOS virtual keyboard opening 65 29 let onResize = () => { 66 - setSize((size) => { 67 - let newSize = getViewportSize(); 68 - if (newSize.width === size.width && newSize.height === size.height) { 69 - return size; 30 + setState((prev) => { 31 + let next = getVisualViewport(); 32 + if ( 33 + prev.width === next.width && 34 + prev.height === next.height && 35 + prev.offsetTop === next.offsetTop 36 + ) { 37 + return prev; 70 38 } 71 - return newSize; 39 + return next; 72 40 }); 73 41 }; 74 42 75 - if (!visualViewport) { 76 - window.addEventListener("resize", onResize); 77 - } else { 78 - visualViewport.addEventListener("resize", onResize); 79 - } 43 + let target: VisualViewport | Window = visualViewport || window; 44 + target.addEventListener("resize", onResize); 45 + return () => target.removeEventListener("resize", onResize); 46 + }, []); 80 47 81 - return () => { 82 - if (!visualViewport) { 83 - window.removeEventListener("resize", onResize); 84 - } else { 85 - visualViewport.removeEventListener("resize", onResize); 86 - } 87 - }; 88 - }, []); 48 + return state; 49 + } 89 50 90 - return size; 51 + export function ViewportSizeLayout(props: { children: React.ReactNode }) { 52 + let { height, difference } = useVisualViewport(); 53 + return ( 54 + <div 55 + style={{ 56 + height: 57 + isIOS() && difference !== 0 58 + ? `calc(${height}px + 10px)` 59 + : "calc(100% + env(safe-area-inset-top))", 60 + }} 61 + > 62 + {props.children} 63 + </div> 64 + ); 91 65 }
-20
components/utils/AddLeafletToHomepage.tsx
··· 1 - "use client"; 2 - 3 - import { addDocToHome } from "app/(home-pages)/home/storage"; 4 - import { useIdentityData } from "components/IdentityProvider"; 5 - import { useEffect } from "react"; 6 - import { useReplicache } from "src/replicache"; 7 - 8 - export function AddLeafletToHomepage() { 9 - let { permission_token } = useReplicache(); 10 - let { identity } = useIdentityData(); 11 - useEffect(() => { 12 - if (identity) return; 13 - if (permission_token.permission_token_rights[0].write) { 14 - try { 15 - addDocToHome(permission_token); 16 - } catch (e) {} 17 - } 18 - }, [permission_token, identity]); 19 - return null; 20 - }
+1
components/utils/DotLoader.tsx
··· 1 + "use client"; 1 2 import { useEffect, useState } from "react"; 2 3 3 4 export function DotLoader(props: { className?: string }) {
+1
src/hooks/isMobile.ts
··· 1 + "use client"; 1 2 import { useState, useEffect } from "react"; 2 3 3 4 export function useIsMobile() {