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 (