diff --git a/docker-compose.yaml b/docker-compose.yaml index e2212f02..19e500df 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,3 @@ -version: '3.8' services: cache: image: redis:6.2-alpine @@ -8,6 +7,26 @@ services: command: redis-server --save 20 1 --loglevel warning volumes: - cache:/data + + postgres: + image: postgres:16.8 + restart: always + ports: + - '5432:5432' + environment: + - POSTGRES_USER=oscar + - POSTGRES_PASSWORD=passw0rd + - POSTGRES_DB=shape-docs + + app: + build: . + ports: + - '3000:3000' + env_file: + - .env + depends_on: + - cache + volumes: cache: - driver: local \ No newline at end of file + driver: local diff --git a/package-lock.json b/package-lock.json index 84404445..db3041db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13980,4 +13980,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/app/(authed)/(project-doc)/[...slug]/layout.tsx b/src/app/(authed)/(project-doc)/[...slug]/layout.tsx index 98306516..114f49c2 100644 --- a/src/app/(authed)/(project-doc)/[...slug]/layout.tsx +++ b/src/app/(authed)/(project-doc)/[...slug]/layout.tsx @@ -4,32 +4,21 @@ import { Box, Stack } from "@mui/material" import { useTheme } from "@mui/material/styles" import TrailingToolbarItem from "@/features/projects/view/toolbar/TrailingToolbarItem" import MobileToolbar from "@/features/projects/view/toolbar/MobileToolbar" -import SecondaryHeaderPlaceholder from "@/features/sidebar/view/SecondarySplitHeaderPlaceholder" -import { useContext } from "react" -import { ProjectsContext } from "@/common" -import LoadingIndicator from "@/common/ui/LoadingIndicator" import SecondarySplitHeader from "@/features/sidebar/view/SecondarySplitHeader" export default function Page({ children }: { children: React.ReactNode }) { - const { refreshed } = useContext(ProjectsContext) - const theme = useTheme() return ( <> - {!refreshed ? : - }> - - - } - - {refreshed ? -
- {children} -
: - - } + }> + + + +
+ {children} +
) diff --git a/src/app/(authed)/(project-doc)/[...slug]/page.tsx b/src/app/(authed)/(project-doc)/[...slug]/page.tsx index 9cb06eb9..66bb1423 100644 --- a/src/app/(authed)/(project-doc)/[...slug]/page.tsx +++ b/src/app/(authed)/(project-doc)/[...slug]/page.tsx @@ -1,14 +1,17 @@ "use client" -import { useEffect } from "react" +import { useContext, 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" import NotFound from "@/features/projects/view/NotFound" +import { ProjectsContext } from "@/common/context/ProjectsContext" +import LoadingIndicator from "@/common/ui/LoadingIndicator" export default function Page() { const { project, version, specification, navigateToSelectionIfNeeded } = useProjectSelection() + const { refreshing } = useContext(ProjectsContext) // Ensure the URL reflects the current selection of project, version, and specification. useEffect(() => { navigateToSelectionIfNeeded() @@ -30,10 +33,13 @@ export default function Page() { {project && version && specification && } - {project && (!version || !specification) && + {project && (!version || !specification) && !refreshing && } - {!project && } + {refreshing && // project data is currently being fetched - show loading indicator + + } + {!project && !refreshing && } ) } diff --git a/src/app/(authed)/layout.tsx b/src/app/(authed)/layout.tsx index 4f004aa8..798d2fe5 100644 --- a/src/app/(authed)/layout.tsx +++ b/src/app/(authed)/layout.tsx @@ -1,30 +1,38 @@ -import { redirect } from "next/navigation" -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 ProjectsContextProvider from "@/features/projects/view/ProjectsContextProvider" -import { SidebarTogglableContextProvider, SplitView } from "@/features/sidebar/view" +import { redirect } from "next/navigation"; +import { SessionProvider } from "next-auth/react"; +import { session, projectDataSource } from "@/composition"; +import ErrorHandler from "@/common/ui/ErrorHandler"; +import SessionBarrier from "@/features/auth/view/SessionBarrier"; +import ProjectsContextProvider from "@/features/projects/view/ProjectsContextProvider"; +import { + SidebarTogglableContextProvider, + SplitView, +} from "@/features/sidebar/view"; -export default async function Layout({ children }: { children: React.ReactNode }) { - const isAuthenticated = await session.getIsAuthenticated() +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const isAuthenticated = await session.getIsAuthenticated(); if (!isAuthenticated) { - return redirect("/api/auth/signin") + return redirect("/api/auth/signin"); } - const projects = await projectRepository.get() + + const projects = await projectDataSource.getProjects(); + + return ( - - {children} - + {children} - ) + ); } diff --git a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts index e91360e0..f6f06b0c 100644 --- a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts +++ b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts @@ -33,4 +33,4 @@ export async function GET(req: NextRequest, { params }: { params: Promise => { await gitHubHookHandler.handle(req) return NextResponse.json({ status: "OK" }) -} +} \ No newline at end of file diff --git a/src/app/api/refresh-projects/route.ts b/src/app/api/refresh-projects/route.ts new file mode 100644 index 00000000..fbaedd4c --- /dev/null +++ b/src/app/api/refresh-projects/route.ts @@ -0,0 +1,9 @@ + +import { NextResponse } from "next/server" +import { projectDataSource } from "@/composition"; + + +export async function POST() { + const projects = await projectDataSource.refreshProjects() + return NextResponse.json({ projects }) +} \ No newline at end of file diff --git a/src/common/context/ProjectsContext.ts b/src/common/context/ProjectsContext.ts index cc0fe966..f4d30699 100644 --- a/src/common/context/ProjectsContext.ts +++ b/src/common/context/ProjectsContext.ts @@ -6,13 +6,11 @@ import { Project } from "@/features/projects/domain" export const SidebarTogglableContext = createContext(true) type ProjectsContextValue = { - refreshed: boolean, + refreshing: boolean, projects: Project[], - setProjects: (projects: Project[]) => void } export const ProjectsContext = createContext({ - refreshed: false, + refreshing: false, projects: [], - setProjects: () => {} }) diff --git a/src/common/utils/fetcher.ts b/src/common/utils/fetcher.ts index 70229a78..9cb8bd01 100644 --- a/src/common/utils/fetcher.ts +++ b/src/common/utils/fetcher.ts @@ -1,4 +1,4 @@ - export class FetcherError extends Error { +export class FetcherError extends Error { readonly status: number constructor(status: number, message: string) { @@ -12,9 +12,9 @@ input: RequestInfo, init?: RequestInit ): Promise { - const res = await fetch(input, init) + const res = await fetch(input, init) if (!res.ok) { throw new FetcherError(res.status, "An error occurred while fetching the data.") } return res.json() -} +} \ No newline at end of file diff --git a/src/composition.ts b/src/composition.ts index 40a754bc..10f4e504 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -230,4 +230,4 @@ export const gitHubHookHandler = new GitHubHookHandler({ }) }) }) -}) +}) \ No newline at end of file diff --git a/src/features/docs/view/Stoplight.tsx b/src/features/docs/view/Stoplight.tsx index ab104e9f..787fa1e0 100644 --- a/src/features/docs/view/Stoplight.tsx +++ b/src/features/docs/view/Stoplight.tsx @@ -75,4 +75,4 @@ const ElementsAPI = ({ }) } -export default Stoplight +export default Stoplight \ No newline at end of file diff --git a/src/features/docs/view/Swagger.tsx b/src/features/docs/view/Swagger.tsx index 2d88be99..5000d37f 100644 --- a/src/features/docs/view/Swagger.tsx +++ b/src/features/docs/view/Swagger.tsx @@ -62,4 +62,4 @@ const Swagger = ({ url }: { url: string }) => { ) } -export default Swagger +export default Swagger \ No newline at end of file diff --git a/src/features/projects/domain/CachingProjectDataSource.ts b/src/features/projects/domain/CachingProjectDataSource.ts index 48c6fb01..73f85281 100644 --- a/src/features/projects/domain/CachingProjectDataSource.ts +++ b/src/features/projects/domain/CachingProjectDataSource.ts @@ -1,19 +1,33 @@ -import Project from "./Project" -import IProjectDataSource from "./IProjectDataSource" -import IProjectRepository from "./IProjectRepository" +import Project from "./Project"; +import IProjectDataSource from "./IProjectDataSource"; +import IProjectRepository from "./IProjectRepository"; + export default class CachingProjectDataSource implements IProjectDataSource { - private dataSource: IProjectDataSource - private repository: IProjectRepository - - constructor(config: { dataSource: IProjectDataSource, repository: IProjectRepository }) { - this.dataSource = config.dataSource - this.repository = config.repository + private dataSource: IProjectDataSource; + private repository: IProjectRepository; + + constructor(config: { + dataSource: IProjectDataSource; + repository: IProjectRepository; + }) { + this.dataSource = config.dataSource; + this.repository = config.repository; } - + async getProjects(): Promise { - const projects = await this.dataSource.getProjects() - await this.repository.set(projects) - return projects + const cache = await this.repository.get(); + if (cache && cache.length > 0) { + return cache; + } + const projects = await this.dataSource.getProjects(); + await this.repository.set(projects); + return projects; + } + + async refreshProjects(): Promise { + const projects = await this.dataSource.getProjects(); + await this.repository.set(projects); + return projects; } -} \ No newline at end of file +} diff --git a/src/features/projects/domain/ProjectRepository.ts b/src/features/projects/domain/ProjectRepository.ts index 75ad4680..0b68bfdb 100644 --- a/src/features/projects/domain/ProjectRepository.ts +++ b/src/features/projects/domain/ProjectRepository.ts @@ -15,16 +15,17 @@ export default class ProjectRepository implements IProjectRepository { this.repository = config.repository } - async get(): Promise { + async get(): Promise { const userId = await this.userIDReader.getUserId() const string = await this.repository.get(userId) + if (!string) { return undefined } try { return ZodJSONCoder.decode(ProjectSchema.array(), string) - } catch (err) { - console.error(err) + } catch { // swallow decode errors and treat as missing cache + console.warn("[ProjectRepository] Failed to decode cached projects – treating as cache miss") return undefined } } diff --git a/src/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx index 93213d75..639363e1 100644 --- a/src/features/projects/view/ProjectsContextProvider.tsx +++ b/src/features/projects/view/ProjectsContextProvider.tsx @@ -1,39 +1,64 @@ -"use client" +"use client"; -import { useState } from "react" -import { ProjectsContext } from "@/common" -import { Project } from "@/features/projects/domain" +import { useState, useEffect, useRef } from "react"; +import { ProjectsContext } from "@/common"; +import { Project } from "@/features/projects/domain"; const ProjectsContextProvider = ({ initialProjects, - children + children, }: { - initialProjects?: Project[], - children?: React.ReactNode + initialProjects?: Project[]; + children?: React.ReactNode; }) => { - const [refreshed, setRefreshed] = useState(false) - const [projects, setProjects] = useState(initialProjects || []) - - const hasProjectChanged = (value: Project[]) => value.some((project, index) => { - // Compare by project id and version (or any other key fields) - return project.id !== projects[index]?.id || project.versions !== projects[index]?.versions - }) + const [projects, setProjects] = useState(initialProjects || []); + const [refreshing, setRefreshing] = useState(false); + const isLoadingRef = useRef(false); const setProjectsAndRefreshed = (value: Project[]) => { - setProjects(value) - // If any project has changed, update the state and mark as refreshed - if (hasProjectChanged(value)) setRefreshed(true) + setProjects(value); + }; + + // Trigger background refresh after initial mount + useEffect(() => { + const refreshProjects = () => { + if (isLoadingRef.current) { + return; + } + isLoadingRef.current = true; + setRefreshing(true); + fetch("/api/refresh-projects", { method: "POST" }) + .then((res) => res.json()) + .then(({ projects }) => { + if (projects) setProjectsAndRefreshed(projects); + }) + .catch((error) => console.error("Failed to refresh projects", error)) + .finally(() => { + isLoadingRef.current = false; + setRefreshing(false); + }); + }; + // Initial refresh + refreshProjects(); + const handleVisibilityChange = () => { + if (!document.hidden) refreshProjects(); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => + document.removeEventListener("visibilitychange", handleVisibilityChange); + }, []); - } return ( - + {children} - ) -} + ); +}; -export default ProjectsContextProvider \ No newline at end of file +export default ProjectsContextProvider; diff --git a/src/features/sidebar/view/SplitView.tsx b/src/features/sidebar/view/SplitView.tsx index e179c6ff..04bd4eb9 100644 --- a/src/features/sidebar/view/SplitView.tsx +++ b/src/features/sidebar/view/SplitView.tsx @@ -1,5 +1,4 @@ import ClientSplitView from "./internal/ClientSplitView" -import { projectDataSource } from "@/composition" import BaseSidebar from "./internal/sidebar/Sidebar" import ProjectList from "./internal/sidebar/projects/ProjectList" import { env } from "@/common" @@ -21,7 +20,7 @@ const Sidebar = () => { return ( // The site name and help URL are passed as a properties to ensure the environment variables are read server-side. - + ) } diff --git a/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx b/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx index fa65f901..a9d16e39 100644 --- a/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx +++ b/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx @@ -1,20 +1,12 @@ "use client" - -import { useContext, useEffect } 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) - useEffect(() => { - setProjects(projects) - }, [projects, setProjects]) return ( - {projects.map(project => ( + {projects.map(project => ( ))} diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx index 83ee8a03..0215d9e8 100644 --- a/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx +++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx @@ -1,36 +1,24 @@ -import { Suspense } from "react" +'use client' + +import { Suspense, useContext } from "react" import { Typography } from "@mui/material" import ProjectListFallback from "./ProjectListFallback" import PopulatedProjectList from "./PopulatedProjectList" -import { IProjectDataSource } from "@/features/projects/domain" +import { ProjectsContext } from "@/common" + +const ProjectList = () => { + + const { projects } = useContext(ProjectsContext) -const ProjectList = ({ - projectDataSource -}: { - projectDataSource: IProjectDataSource -}) => { return ( }> - + {projects.length > 0 ? : } ) } export default ProjectList -const DataFetchingProjectList = async ({ - projectDataSource -}: { - projectDataSource: IProjectDataSource -}) => { - const projects = await projectDataSource.getProjects() - if (projects.length > 0) { - return - } else { - return - } -} - const EmptyProjectList = () => { return (