diff --git a/src/app/(authed)/(home)/[...slug]/page.tsx b/src/app/(authed)/(home)/[...slug]/page.tsx
index e70225d5..b43b7b80 100644
--- a/src/app/(authed)/(home)/[...slug]/page.tsx
+++ b/src/app/(authed)/(home)/[...slug]/page.tsx
@@ -1,15 +1,12 @@
"use client"
-import { useContext, useEffect } from "react"
-import { ProjectsContainerContext } from "@/common"
-import DelayedLoadingIndicator from "@/common/ui/DelayedLoadingIndicator"
+import { useEffect } from "react"
import ErrorMessage from "@/common/ui/ErrorMessage"
import { updateWindowTitle } from "@/features/projects/domain"
import { useProjectSelection } from "@/features/projects/data"
import Documentation from "@/features/projects/view/Documentation"
export default function Page() {
- const { error, isLoading } = useContext(ProjectsContainerContext)
const { project, version, specification, navigateToSelectionIfNeeded } = useProjectSelection()
// Ensure the URL reflects the current selection of project, version, and specification.
useEffect(() => {
@@ -32,10 +29,6 @@ export default function Page() {
return
} else if (project && !specification) {
return
- } else if (isLoading) {
- return
- } else if (error) {
- return
} else {
// No project is selected so we will not show anything.
return <>>
diff --git a/src/app/(authed)/(home)/layout.tsx b/src/app/(authed)/(home)/layout.tsx
deleted file mode 100644
index 49b27871..00000000
--- a/src/app/(authed)/(home)/layout.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-"use client"
-
-import { useContext } from "react"
-import { SplitView } from "@/features/sidebar/view"
-import { useProjects, useProjectSelection } from "@/features/projects/data"
-import {
- ProjectsContainerContext,
- ServerSideCachedProjectsContext
-} from "@/common"
-
-export default function Layout({ children }: { children: React.ReactNode }) {
- const { projects, error, isLoading } = useProjects()
- // Update projects provided to child components, using cached projects from the server if needed.
- const serverSideCachedProjects = useContext(ServerSideCachedProjectsContext)
- const newProjectsContainer = { projects, error, isLoading }
- if (isLoading && serverSideCachedProjects) {
- newProjectsContainer.isLoading = false
- newProjectsContainer.projects = serverSideCachedProjects
- }
- return (
-
-
- {children}
-
-
- )
-}
-
-const SplitViewWrapper = ({ children }: { children: React.ReactNode }) => {
- const { project } = useProjectSelection()
- return (
-
- {children}
-
- )
-}
\ No newline at end of file
diff --git a/src/app/(authed)/layout.tsx b/src/app/(authed)/layout.tsx
index bc977cf5..84da124e 100644
--- a/src/app/(authed)/layout.tsx
+++ b/src/app/(authed)/layout.tsx
@@ -3,7 +3,8 @@ import { SessionProvider } from "next-auth/react"
import { session, projectRepository } from "@/composition"
import ErrorHandler from "@/common/ui/ErrorHandler"
import SessionBarrier from "@/features/auth/view/SessionBarrier"
-import ServerSideCachedProjectsProvider from "@/features/projects/view/ServerSideCachedProjectsProvider"
+import ProjectsContextProvider from "@/features/projects/view/ProjectsContextProvider"
+import { SplitView } from "@/features/sidebar/view"
export default async function Layout({ children }: { children: React.ReactNode }) {
const isAuthenticated = await session.getIsAuthenticated()
@@ -15,11 +16,13 @@ export default async function Layout({ children }: { children: React.ReactNode }
-
- {children}
-
+
+
+ {children}
+
+
)
-}
\ No newline at end of file
+}
diff --git a/src/app/api/user/projects/route.ts b/src/app/api/user/projects/route.ts
deleted file mode 100644
index 53b0876d..00000000
--- a/src/app/api/user/projects/route.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { NextResponse } from "next/server"
-import {
- makeAPIErrorResponse,
- UnauthorizedError,
- makeUnauthenticatedAPIErrorResponse
-} from "@/common"
-import { session, projectDataSource } from "@/composition"
-
-export async function GET() {
- const isAuthenticated = await session.getIsAuthenticated()
- if (!isAuthenticated) {
- return makeUnauthenticatedAPIErrorResponse()
- }
- try {
- const projects = await projectDataSource.getProjects()
- return NextResponse.json({projects})
- } catch (error) {
- if (error instanceof UnauthorizedError) {
- return makeAPIErrorResponse(401, error.message)
- } else if (error instanceof Error) {
- return makeAPIErrorResponse(500, error.message)
- } else {
- return makeAPIErrorResponse(500, "Unknown error")
- }
- }
-}
\ No newline at end of file
diff --git a/src/common/contexts.ts b/src/common/contexts.ts
index e455e5b1..ba3d74fb 100644
--- a/src/common/contexts.ts
+++ b/src/common/contexts.ts
@@ -1,19 +1,14 @@
"use client"
import { createContext } from "react"
-import { Project, } from "@/features/projects/domain"
+import { Project } from "@/features/projects/domain"
-export const SidebarContext = createContext<{ isToggleable: boolean }>({ isToggleable: true })
-
-type ProjectsContainer = {
- readonly projects: Project[]
- readonly isLoading: boolean
- readonly error?: Error
+type ProjectsContextValue = {
+ projects: Project[],
+ setProjects: (projects: Project[]) => void
}
-export const ProjectsContainerContext = createContext({
- isLoading: true,
- projects: []
+export const ProjectsContext = createContext({
+ projects: [],
+ setProjects: () => {}
})
-
-export const ServerSideCachedProjectsContext = createContext(undefined)
diff --git a/src/common/ui/MenuItemHover.tsx b/src/common/ui/MenuItemHover.tsx
index d627a980..fcb33795 100644
--- a/src/common/ui/MenuItemHover.tsx
+++ b/src/common/ui/MenuItemHover.tsx
@@ -1,3 +1,5 @@
+"use client"
+
import { SxProps } from "@mui/system"
import { Box } from "@mui/material"
import useMediaQuery from "@mui/material/useMediaQuery"
diff --git a/src/features/projects/data/index.ts b/src/features/projects/data/index.ts
index affecff1..748a41b9 100644
--- a/src/features/projects/data/index.ts
+++ b/src/features/projects/data/index.ts
@@ -1,6 +1,5 @@
export { default as GitHubProjectDataSource } from "./GitHubProjectDataSource"
export * from "./GitHubProjectDataSource"
-export { default as useProjects } from "./useProjects"
export { default as useProjectSelection } from "./useProjectSelection"
export { default as GitHubLoginDataSource } from "./GitHubLoginDataSource"
export { default as GitHubRepositoryDataSource } from "./GitHubRepositoryDataSource"
diff --git a/src/features/projects/data/useProjectSelection.ts b/src/features/projects/data/useProjectSelection.ts
index 6c9229e1..13765896 100644
--- a/src/features/projects/data/useProjectSelection.ts
+++ b/src/features/projects/data/useProjectSelection.ts
@@ -4,14 +4,14 @@ import { useRouter, usePathname } from "next/navigation"
import { useContext } from "react"
import useMediaQuery from "@mui/material/useMediaQuery"
import { useTheme } from "@mui/material/styles"
-import { ProjectsContainerContext } from "@/common"
+import { ProjectsContext } from "@/common"
import { Project, ProjectNavigator, getProjectSelectionFromPath } from "../domain"
import { useSidebarOpen } from "@/features/sidebar/data"
export default function useProjectSelection() {
const router = useRouter()
const pathname = usePathname()
- const { projects } = useContext(ProjectsContainerContext)
+ const { projects } = useContext(ProjectsContext)
const selection = getProjectSelectionFromPath({ projects, path: pathname })
const pathnameReader = {
get pathname() {
diff --git a/src/features/projects/data/useProjects.ts b/src/features/projects/data/useProjects.ts
deleted file mode 100644
index a0b2cb16..00000000
--- a/src/features/projects/data/useProjects.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-"use client"
-
-import useSWR from "swr"
-import { fetcher } from "@/common"
-import { Project } from "../domain"
-
-type ProjectContainer = { projects: Project[] }
-
-export default function useProjects() {
- const { data, error, isLoading } = useSWR(
- "/api/user/projects",
- fetcher
- )
- return {
- projects: data?.projects || [],
- isLoading,
- error
- }
-}
diff --git a/src/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx
new file mode 100644
index 00000000..cd3725e9
--- /dev/null
+++ b/src/features/projects/view/ProjectsContextProvider.tsx
@@ -0,0 +1,22 @@
+"use client"
+
+import { useState } from "react"
+import { ProjectsContext } from "@/common"
+import { Project } from "@/features/projects/domain"
+
+const ProjectsContextProvider = ({
+ initialProjects,
+ children
+}: {
+ initialProjects?: Project[],
+ children?: React.ReactNode
+}) => {
+ const [projects, setProjects] = useState(initialProjects || [])
+ return (
+
+ {children}
+
+ )
+}
+
+export default ProjectsContextProvider
\ No newline at end of file
diff --git a/src/features/projects/view/ServerSideCachedProjectsProvider.tsx b/src/features/projects/view/ServerSideCachedProjectsProvider.tsx
deleted file mode 100644
index 810a6633..00000000
--- a/src/features/projects/view/ServerSideCachedProjectsProvider.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-"use client"
-
-import { Project } from "../domain"
-import { ServerSideCachedProjectsContext } from "@/common"
-
-const ServerSideCachedProjectsProvider = ({
- projects,
- children
-}: {
- projects: Project[] | undefined
- children: React.ReactNode
-}) => {
- return (
-
- {children}
-
- )
-}
-
-export default ServerSideCachedProjectsProvider
diff --git a/src/features/sidebar/view/SecondarySplitHeader.tsx b/src/features/sidebar/view/SecondarySplitHeader.tsx
index d809d3b0..0a9d59ee 100644
--- a/src/features/sidebar/view/SecondarySplitHeader.tsx
+++ b/src/features/sidebar/view/SecondarySplitHeader.tsx
@@ -1,12 +1,12 @@
"use client"
-import { useState, useEffect, useContext } from "react"
+import { useState, useEffect } from "react"
import { useSessionStorage } from "usehooks-ts"
import { Box, IconButton, Stack, Tooltip, Divider, Collapse } from "@mui/material"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons"
import { useTheme } from "@mui/material/styles"
-import { SidebarContext, isMac as checkIsMac } from "@/common"
+import { isMac as checkIsMac } from "@/common"
import { useSidebarOpen } from "@/features/sidebar/data"
import ToggleMobileToolbarButton from "./internal/secondary/ToggleMobileToolbarButton"
@@ -23,7 +23,6 @@ const Header = ({
}) => {
const [isSidebarOpen, setSidebarOpen] = useSidebarOpen()
const [isMac, setIsMac] = useState(false)
- const { isToggleable: isSidebarToggleable } = useContext(SidebarContext)
const [isMobileToolbarVisible, setMobileToolbarVisible] = useSessionStorage("isMobileToolbarVisible", true)
useEffect(() => {
// checkIsMac uses window so we delay the check.
@@ -31,7 +30,6 @@ const Header = ({
}, [isMac, setIsMac])
const openCloseKeyboardShortcut = `(${isMac ? "⌘" : "^"} + .)`
const theme = useTheme()
-
return (
- {isSidebarToggleable && !isSidebarOpen &&
+ {!isSidebarOpen &&
}
- {isSidebarToggleable && isSidebarOpen &&
+ {isSidebarOpen &&
}
{showDivider && }
-
)
}
diff --git a/src/features/sidebar/view/SplitView.tsx b/src/features/sidebar/view/SplitView.tsx
index 78cadccf..3f2387c4 100644
--- a/src/features/sidebar/view/SplitView.tsx
+++ b/src/features/sidebar/view/SplitView.tsx
@@ -1,52 +1,22 @@
-"use client"
+import ClientSplitView from "./internal/ClientSplitView"
+import { projectDataSource } from "@/composition"
+import BaseSidebar from "./internal/sidebar/Sidebar"
+import ProjectList from "./internal/sidebar/projects/ProjectList"
-import { useEffect } from "react"
-import { Stack } from "@mui/material"
-import { isMac, useKeyboardShortcut } from "@/common"
-import { useSidebarOpen } from "../data"
-import PrimaryContainer from "./internal/primary/Container"
-import SecondaryContainer from "./internal/secondary/Container"
-import Sidebar from "./internal/sidebar/Sidebar"
-
-const SplitView = ({
- canToggleSidebar: _canToggleSidebar,
- children
-}: {
- canToggleSidebar?: boolean
- children?: React.ReactNode
-}) => {
- const [isSidebarOpen, setSidebarOpen] = useSidebarOpen()
- const canToggleSidebar = _canToggleSidebar !== undefined ? _canToggleSidebar : true
- useEffect(() => {
- // Show the sidebar if no project is selected.
- if (!canToggleSidebar) {
- setSidebarOpen(true)
- }
- }, [canToggleSidebar, setSidebarOpen])
- useKeyboardShortcut(event => {
- const isActionKey = isMac() ? event.metaKey : event.ctrlKey
- if (isActionKey && event.key === ".") {
- event.preventDefault()
- if (canToggleSidebar) {
- setSidebarOpen(!isSidebarOpen)
- }
- }
- }, [canToggleSidebar, setSidebarOpen])
- const sidebarWidth = 320
+const SplitView = ({ children }: { children?: React.ReactNode }) => {
return (
-
- setSidebarOpen(false)}
- >
-
-
-
- {children}
-
-
+ }>
+ {children}
+
)
}
export default SplitView
+
+const Sidebar = () => {
+ return (
+
+
+
+ )
+}
diff --git a/src/features/sidebar/view/internal/ClientSplitView.tsx b/src/features/sidebar/view/internal/ClientSplitView.tsx
new file mode 100644
index 00000000..f59f1b48
--- /dev/null
+++ b/src/features/sidebar/view/internal/ClientSplitView.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import { useEffect } from "react"
+import { Stack } from "@mui/material"
+import { isMac, useKeyboardShortcut } from "@/common"
+import { useSidebarOpen } from "../../data"
+import { useProjectSelection } from "@/features/projects/data"
+import PrimaryContainer from "./primary/Container"
+import SecondaryContainer from "./secondary/Container"
+
+const ClientSplitView = ({
+ sidebar,
+ children
+}: {
+ sidebar: React.ReactNode
+ children?: React.ReactNode
+}) => {
+ const [isSidebarOpen, setSidebarOpen] = useSidebarOpen()
+ const { project } = useProjectSelection()
+ const canToggleSidebar = project !== undefined
+ useEffect(() => {
+ // Show the sidebar if no project is selected.
+ if (!canToggleSidebar) {
+ setSidebarOpen(true)
+ }
+ }, [canToggleSidebar, setSidebarOpen])
+ useKeyboardShortcut(event => {
+ const isActionKey = isMac() ? event.metaKey : event.ctrlKey
+ if (isActionKey && event.key === ".") {
+ event.preventDefault()
+ if (canToggleSidebar) {
+ setSidebarOpen(!isSidebarOpen)
+ }
+ }
+ }, [canToggleSidebar, setSidebarOpen])
+ const sidebarWidth = 320
+ return (
+
+ setSidebarOpen(false)}
+ >
+ {sidebar}
+
+
+ {children}
+
+
+ )
+}
+
+export default ClientSplitView
diff --git a/src/features/sidebar/view/internal/sidebar/Sidebar.tsx b/src/features/sidebar/view/internal/sidebar/Sidebar.tsx
index c4b99ae5..258f4f0e 100644
--- a/src/features/sidebar/view/internal/sidebar/Sidebar.tsx
+++ b/src/features/sidebar/view/internal/sidebar/Sidebar.tsx
@@ -1,16 +1,17 @@
+"use client"
+
import { useRef, useEffect, useState } from "react"
import { Box, Divider } from "@mui/material"
import Header from "./Header"
import UserButton from "./user/UserButton"
import SettingsList from "./settings/SettingsList"
-import ProjectList from "./projects/ProjectList"
-
-const Sidebar = () => {
+
+const Sidebar = ({ children }: { children?: React.ReactNode }) => {
const [isScrolledToTop, setScrolledToTop] = useState(true)
const [isScrolledToBottom, setScrolledToBottom] = useState(true)
- const projectListRef = useRef(null)
+ const scrollableAreaRef = useRef(null)
const handleScroll = () => {
- const element = projectListRef.current
+ const element = scrollableAreaRef.current
if (!element) {
return
}
@@ -18,7 +19,7 @@ const Sidebar = () => {
setScrolledToBottom(element.scrollHeight - element.scrollTop - element.clientHeight < 10)
}
useEffect(() => {
- const element = projectListRef.current
+ const element = scrollableAreaRef.current
if (element) {
element.addEventListener("scroll", handleScroll)
handleScroll()
@@ -30,8 +31,8 @@ const Sidebar = () => {
return <>
-
-
+
+ {children}
diff --git a/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx b/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx
new file mode 100644
index 00000000..6245274d
--- /dev/null
+++ b/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx
@@ -0,0 +1,22 @@
+"use client"
+
+import { useContext } from "react"
+import { ProjectsContext } from "@/common"
+import SpacedList from "@/common/ui/SpacedList"
+import { Project } from "@/features/projects/domain"
+import ProjectListItem from "./ProjectListItem"
+
+const PopulatedProjectList = ({ projects }: { projects: Project[] }) => {
+ // Ensure that context reflects the displayed projects.
+ const { setProjects } = useContext(ProjectsContext)
+ setProjects(projects)
+ return (
+
+ {projects.map(project => (
+
+ ))}
+
+ )
+}
+
+export default PopulatedProjectList
diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx
index 87ea22e9..9be428b2 100644
--- a/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx
+++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx
@@ -1,51 +1,47 @@
-import { useContext } from "react"
+import { Suspense } from "react"
+import ProjectListFallback from "./ProjectListFallback"
import { Box, Typography } from "@mui/material"
-import { ProjectsContainerContext } from "@/common"
-import SpacedList from "@/common/ui/SpacedList"
-import { useProjectSelection } from "@/features/projects/data"
-import ProjectListItem, { Skeleton as ProjectListItemSkeleton } from "./ProjectListItem"
+import PopulatedProjectList from "./PopulatedProjectList"
+import { IProjectDataSource } from "@/features/projects/domain"
-const ProjectList = () => {
- const { projects, isLoading } = useContext(ProjectsContainerContext)
- const projectSelection = useProjectSelection()
- const itemSpacing = 1
- if (isLoading) {
- return (
-
- {
- [...new Array(6)].map((_, idx) => (
-
- ))
- }
-
- )
- } else if (projects.length > 0) {
- return (
-
- {projects.map(project => (
- projectSelection.selectProject(project)}
- />
- ))}
-
- )
+const ProjectList = ({
+ projectDataSource
+}: {
+ projectDataSource: IProjectDataSource
+}) => {
+ return (
+ }>
+
+
+ )
+}
+
+export default ProjectList
+
+const DataFetchingProjectList = async ({
+ projectDataSource
+}: {
+ projectDataSource: IProjectDataSource
+}) => {
+ const projects = await projectDataSource.getProjects()
+ if (projects.length > 0) {
+ return
} else {
- return (
-
-
- Your list of projects is empty.
-
-
- )
+ return
}
}
-export default ProjectList
+const EmptyProjectList = () => {
+ return (
+
+
+ Your list of projects is empty.
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectListFallback.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectListFallback.tsx
new file mode 100644
index 00000000..9084ca2a
--- /dev/null
+++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectListFallback.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import { useContext } from "react"
+import { ProjectsContext } from "@/common"
+import SpacedList from "@/common/ui/SpacedList"
+import PopulatedProjectList from "./PopulatedProjectList"
+import { Skeleton as ProjectListItemSkeleton } from "./ProjectListItem"
+
+const StaleProjectList = () => {
+ const { projects } = useContext(ProjectsContext)
+ if (projects.length > 0) {
+ return
+ } else {
+ return
+ }
+}
+
+export default StaleProjectList
+
+const LoadingProjectList = () => {
+ return (
+
+ {
+ [...new Array(6)].map((_, idx) => (
+
+ ))
+ }
+
+ )
+}
\ No newline at end of file
diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx
index 3e8e8989..72686fe4 100644
--- a/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx
+++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx
@@ -1,3 +1,5 @@
+"use client"
+
import {
Box,
ListItem,
@@ -11,22 +13,17 @@ import MenuItemHover from "@/common/ui/MenuItemHover"
import { Project } from "@/features/projects/domain"
import ProjectAvatar from "./ProjectAvatar"
import ProjectAvatarSquircle from "./ProjectAvatarSquircle"
+import { useProjectSelection } from "@/features/projects/data"
const AVATAR_SIZE = { width: 40, height: 40 }
-const ProjectListItem = ({
- project,
- selected,
- onSelect
-}: {
- project: Project
- selected: boolean
- onSelect: () => void
-}) => {
+const ProjectListItem = ({ project }: { project: Project }) => {
+ const { project: selectedProject, selectProject } = useProjectSelection()
+ const selected = project.id === selectedProject?.id
return (
selectProject(project)}
avatar={
{
const { data: session, status } = useSession()
- const { projects, isLoading } = useContext(ProjectsContainerContext)
+ const { projects } = useContext(ProjectsContext)
const isLoadingSession = status == "loading"
return (
@@ -76,7 +76,7 @@ const ShowSectionsLayer = () => {
- {!isLoading &&
+ {projects.length > 0 &&