From 792d1d121a8994d99adb3e4c2edbe81793f9bccf Mon Sep 17 00:00:00 2001 From: Oscar Date: Mon, 22 Sep 2025 14:41:08 +0200 Subject: [PATCH 01/14] Enhance project caching Implements project caching and refresh logic to optimize data retrieval and ensure up-to-date project information. Updates various components to support these enhancements, including the `ProjectsContextProvider` and `CachingProjectDataSource`. Add revalidation logic to API endpoints to keep cached data in sync. --- docker-compose.yaml | 23 +++++- package-lock.json | 2 +- src/app/(authed)/layout.tsx | 39 +++++---- .../[owner]/[repository]/[...path]/route.ts | 16 ++-- src/app/api/hooks/github/route.ts | 20 +++-- src/app/api/projects/route.ts | 7 ++ src/common/utils/fetcher.ts | 6 +- src/composition.ts | 2 +- src/features/docs/view/Stoplight.tsx | 2 +- src/features/docs/view/Swagger.tsx | 2 +- .../domain/CachingProjectDataSource.ts | 49 ++++++++--- .../projects/domain/ProjectRepository.ts | 4 +- .../projects/view/ProjectsContextProvider.tsx | 81 +++++++++++++------ src/features/sidebar/view/SplitView.tsx | 3 +- .../sidebar/projects/PopulatedProjectList.tsx | 10 +-- .../internal/sidebar/projects/ProjectList.tsx | 21 ++--- tsconfig.json | 29 ++++--- 17 files changed, 214 insertions(+), 102 deletions(-) create mode 100644 src/app/api/projects/route.ts 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)/layout.tsx b/src/app/(authed)/layout.tsx index 4f004aa8..0c11152b 100644 --- a/src/app/(authed)/layout.tsx +++ b/src/app/(authed)/layout.tsx @@ -1,30 +1,39 @@ -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 } from "@/composition"; +import ErrorHandler from "@/common/ui/ErrorHandler"; +import SessionBarrier from "@/features/auth/view/SessionBarrier"; +import ProjectsContextProvider from "@/features/projects/view/ProjectsContextProvider"; +import { projectDataSource } from "@/composition"; +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(); + console.log("Loaded projects:", projects); + 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..a59b8740 100644 --- a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts +++ b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server" import { session, userGitHubClient } from "@/composition" import { makeUnauthenticatedAPIErrorResponse } from "@/common" +import { revalidatePath } from "next/cache" + interface GetBlobParams { owner: string @@ -8,7 +10,7 @@ interface GetBlobParams { path: [string] } -export async function GET(req: NextRequest, { params }: { params: Promise }) { +export async function GET(req: NextRequest, { params }: { params: Promise } ) { const isAuthenticated = await session.getIsAuthenticated() if (!isAuthenticated) { return makeUnauthenticatedAPIErrorResponse() @@ -23,14 +25,18 @@ export async function GET(req: NextRequest, { params }: { params: Promise r.blob()) + const res = await fetch(url, { next: { revalidate: 6000 } }) + const file = await res.blob() + revalidatePath('/(authed)/projects') const headers = new Headers() + if (res.status !== 200 ) { + headers.set("Content-Type", "text/plain"); + headers.set("Cache-Control", `max-age=3000`) + } if (new RegExp(imageRegex).exec(path)) { const cacheExpirationInSeconds = 60 * 60 * 24 * 30 // 30 days headers.set("Content-Type", "image/*"); headers.set("Cache-Control", `max-age=${cacheExpirationInSeconds}`) - } else { - headers.set("Content-Type", "text/plain"); - } + } return new NextResponse(file, { status: 200, headers }) } diff --git a/src/app/api/hooks/github/route.ts b/src/app/api/hooks/github/route.ts index 6f359950..aee439d5 100644 --- a/src/app/api/hooks/github/route.ts +++ b/src/app/api/hooks/github/route.ts @@ -1,7 +1,15 @@ -import { NextRequest, NextResponse } from "next/server" -import { gitHubHookHandler } from "@/composition" +import { NextRequest, NextResponse } from "next/server"; +import { gitHubHookHandler } from "@/composition"; +import { revalidatePath } from "next/cache"; +import { projectDataSource } from "@/composition"; -export const POST = async (req: NextRequest): Promise => { - await gitHubHookHandler.handle(req) - return NextResponse.json({ status: "OK" }) -} +// I GitHubHookHandler eller composition +export const POST = async (req: NextRequest) => { + await gitHubHookHandler.handle(req); + + // Opdater projects cache når webhook modtages + await projectDataSource.refreshProjects(); + + revalidatePath("/(authed)/projects"); + return NextResponse.json({ status: "OK" }); +}; diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 00000000..4cd6c396 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server" +import { projectDataSource } from "@/composition" + +export async function GET() { + const projects = await projectDataSource.refreshProjects() + return NextResponse.json({ projects }) +} 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..aa659bcf 100644 --- a/src/features/projects/domain/CachingProjectDataSource.ts +++ b/src/features/projects/domain/CachingProjectDataSource.ts @@ -1,19 +1,44 @@ -import Project from "./Project" -import IProjectDataSource from "./IProjectDataSource" -import IProjectRepository from "./IProjectRepository" +import Project from "./Project"; +import IProjectDataSource from "./IProjectDataSource"; +import IProjectRepository from "./IProjectRepository"; +import { revalidatePath } from "next/cache"; + 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 { + + /* async getProjects(): Promise { const projects = await this.dataSource.getProjects() await this.repository.set(projects) return projects + } */ + + + async getProjects(): Promise { + const cache = await this.repository.get(); + console.log("Loaded projects from cache:", cache); + if (cache) return cache; + else { + const projects = await this.dataSource.getProjects(); + await this.repository.set(projects); + console.log("fetching projects:", projects); + return projects; + } + } + +async refreshProjects(): Promise { + const projects = await this.dataSource.getProjects(); + await this.repository.set(projects); + console.log("refreshed projects:", 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..f7167677 100644 --- a/src/features/projects/domain/ProjectRepository.ts +++ b/src/features/projects/domain/ProjectRepository.ts @@ -15,9 +15,11 @@ export default class ProjectRepository implements IProjectRepository { this.repository = config.repository } - async get(): Promise { + async get(): Promise { const userId = await this.userIDReader.getUserId() + console.log("Fetching projects for user ID:", userId) const string = await this.repository.get(userId) + console.log("Fetched projects string:", string) if (!string) { return undefined } diff --git a/src/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx index 93213d75..6bffc73f 100644 --- a/src/features/projects/view/ProjectsContextProvider.tsx +++ b/src/features/projects/view/ProjectsContextProvider.tsx @@ -1,39 +1,72 @@ -"use client" +"use client"; -import { useState } from "react" -import { ProjectsContext } from "@/common" -import { Project } from "@/features/projects/domain" +import { useState, useEffect } 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 [refreshed, setRefreshed] = useState(true); + 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 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 setProjectsAndRefreshed = (value: Project[]) => { - setProjects(value) + setProjects(value); // If any project has changed, update the state and mark as refreshed - if (hasProjectChanged(value)) setRefreshed(true) + if (hasProjectChanged(value)) setRefreshed(true); + }; + + // Trigger background refresh after initial mount + useEffect(() => { + const refreshProjects = () => { + fetch("/api/projects") + .then((res) => res.json()) + .then( + ({ projects }) => + projects && + hasProjectChanged(projects) && + setProjectsAndRefreshed(projects) + ) + .catch(() => {}); + }; + + // Initial refresh + refreshProjects(); + + // Refresh when tab becomes active + 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..19f17257 100644 --- a/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx +++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx @@ -1,24 +1,25 @@ -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 ({ +/* const DataFetchingProjectList = async ({ projectDataSource }: { projectDataSource: IProjectDataSource @@ -29,7 +30,7 @@ const DataFetchingProjectList = async ({ } else { return } -} +} */ const EmptyProjectList = () => { return ( diff --git a/tsconfig.json b/tsconfig.json index a851ef76..acc1e226 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -11,22 +15,29 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, - "plugins": [{ - "name": "next" - }], + "plugins": [ + { + "name": "next" + } + ], "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, "include": [ "next-env.d.ts", - "**/*.ts", - "**/*.tsx", + "**/*.ts", + "**/*.tsx", ".next/types/**/*.ts", "types/*.d.ts" ], - "exclude": ["node_modules", "infrastructure"] + "exclude": [ + "node_modules", + "infrastructure" + ] } From 0b8f984a83cecf440cf9a6c0367ff9a021890cdb Mon Sep 17 00:00:00 2001 From: Oscar Date: Mon, 29 Sep 2025 09:09:34 +0200 Subject: [PATCH 02/14] copolit-comments updates --- src/app/(authed)/layout.tsx | 2 +- src/app/api/hooks/github/route.ts | 20 ++++++------------- .../domain/CachingProjectDataSource.ts | 15 +++----------- .../projects/domain/ProjectRepository.ts | 4 +--- .../projects/view/ProjectsContextProvider.tsx | 2 +- 5 files changed, 12 insertions(+), 31 deletions(-) diff --git a/src/app/(authed)/layout.tsx b/src/app/(authed)/layout.tsx index 0c11152b..fd59a51d 100644 --- a/src/app/(authed)/layout.tsx +++ b/src/app/(authed)/layout.tsx @@ -21,7 +21,7 @@ export default async function Layout({ } const projects = await projectDataSource.getProjects(); - console.log("Loaded projects:", projects); + return ( diff --git a/src/app/api/hooks/github/route.ts b/src/app/api/hooks/github/route.ts index aee439d5..b1b42444 100644 --- a/src/app/api/hooks/github/route.ts +++ b/src/app/api/hooks/github/route.ts @@ -1,15 +1,7 @@ -import { NextRequest, NextResponse } from "next/server"; -import { gitHubHookHandler } from "@/composition"; -import { revalidatePath } from "next/cache"; -import { projectDataSource } from "@/composition"; +import { NextRequest, NextResponse } from "next/server" +import { gitHubHookHandler } from "@/composition" -// I GitHubHookHandler eller composition -export const POST = async (req: NextRequest) => { - await gitHubHookHandler.handle(req); - - // Opdater projects cache når webhook modtages - await projectDataSource.refreshProjects(); - - revalidatePath("/(authed)/projects"); - return NextResponse.json({ status: "OK" }); -}; +export const POST = async (req: NextRequest): Promise => { + await gitHubHookHandler.handle(req) + return NextResponse.json({ status: "OK" }) +} \ No newline at end of file diff --git a/src/features/projects/domain/CachingProjectDataSource.ts b/src/features/projects/domain/CachingProjectDataSource.ts index aa659bcf..7c8aeff1 100644 --- a/src/features/projects/domain/CachingProjectDataSource.ts +++ b/src/features/projects/domain/CachingProjectDataSource.ts @@ -3,7 +3,6 @@ import IProjectDataSource from "./IProjectDataSource"; import IProjectRepository from "./IProjectRepository"; import { revalidatePath } from "next/cache"; - export default class CachingProjectDataSource implements IProjectDataSource { private dataSource: IProjectDataSource; private repository: IProjectRepository; @@ -16,29 +15,21 @@ export default class CachingProjectDataSource implements IProjectDataSource { this.repository = config.repository; } - /* async getProjects(): Promise { - const projects = await this.dataSource.getProjects() - await this.repository.set(projects) - return projects - } */ - - async getProjects(): Promise { const cache = await this.repository.get(); - console.log("Loaded projects from cache:", cache); + if (cache) return cache; else { const projects = await this.dataSource.getProjects(); await this.repository.set(projects); - console.log("fetching projects:", projects); + return projects; } } -async refreshProjects(): Promise { + async refreshProjects(): Promise { const projects = await this.dataSource.getProjects(); await this.repository.set(projects); - console.log("refreshed projects:", projects); return projects; } } diff --git a/src/features/projects/domain/ProjectRepository.ts b/src/features/projects/domain/ProjectRepository.ts index f7167677..74b036b9 100644 --- a/src/features/projects/domain/ProjectRepository.ts +++ b/src/features/projects/domain/ProjectRepository.ts @@ -17,16 +17,14 @@ export default class ProjectRepository implements IProjectRepository { async get(): Promise { const userId = await this.userIDReader.getUserId() - console.log("Fetching projects for user ID:", userId) const string = await this.repository.get(userId) - console.log("Fetched projects string:", string) + if (!string) { return undefined } try { return ZodJSONCoder.decode(ProjectSchema.array(), string) } catch (err) { - console.error(err) return undefined } } diff --git a/src/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx index 6bffc73f..5c6e114e 100644 --- a/src/features/projects/view/ProjectsContextProvider.tsx +++ b/src/features/projects/view/ProjectsContextProvider.tsx @@ -40,7 +40,7 @@ const ProjectsContextProvider = ({ hasProjectChanged(projects) && setProjectsAndRefreshed(projects) ) - .catch(() => {}); + .catch((error) => console.log("Failed to refresh projects", error)); }; // Initial refresh From 2777b8b3bad106718e8ef6adf398a62aa7d1a795 Mon Sep 17 00:00:00 2001 From: Oscar Date: Mon, 29 Sep 2025 09:11:38 +0200 Subject: [PATCH 03/14] route.ts - trailing spaces --- src/app/api/blob/[owner]/[repository]/[...path]/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts index a59b8740..1249b821 100644 --- a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts +++ b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts @@ -29,7 +29,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise Date: Mon, 29 Sep 2025 09:34:03 +0200 Subject: [PATCH 04/14] refresfed state sset to false removed revalidatePath from blob --- .../[owner]/[repository]/[...path]/route.ts | 55 +++++++++---------- .../projects/view/ProjectsContextProvider.tsx | 2 +- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts index 1249b821..471746ea 100644 --- a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts +++ b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts @@ -1,42 +1,41 @@ -import { NextRequest, NextResponse } from "next/server" -import { session, userGitHubClient } from "@/composition" -import { makeUnauthenticatedAPIErrorResponse } from "@/common" -import { revalidatePath } from "next/cache" - +import { NextRequest, NextResponse } from "next/server"; +import { session, userGitHubClient } from "@/composition"; +import { makeUnauthenticatedAPIErrorResponse } from "@/common"; interface GetBlobParams { - owner: string - repository: string - path: [string] + owner: string; + repository: string; + path: [string]; } -export async function GET(req: NextRequest, { params }: { params: Promise } ) { - const isAuthenticated = await session.getIsAuthenticated() +export async function GET( + req: NextRequest, + { params }: { params: Promise } +) { + const isAuthenticated = await session.getIsAuthenticated(); if (!isAuthenticated) { - return makeUnauthenticatedAPIErrorResponse() + return makeUnauthenticatedAPIErrorResponse(); } - const { path: paramsPath, owner, repository } = await params - const path = paramsPath.join("/") + const { path: paramsPath, owner, repository } = await params; + const path = paramsPath.join("/"); const item = await userGitHubClient.getRepositoryContent({ repositoryOwner: owner, repositoryName: repository, path: path, - ref: req.nextUrl.searchParams.get("ref") ?? undefined - }) - const url = new URL(item.downloadURL) + ref: req.nextUrl.searchParams.get("ref") ?? undefined, + }); + const url = new URL(item.downloadURL); const imageRegex = /\.(jpg|jpeg|png|webp|avif|gif)$/; - const res = await fetch(url, { next: { revalidate: 6000 } }) - const file = await res.blob() - revalidatePath('/(authed)/projects') - const headers = new Headers() - if (res.status !== 200) { - headers.set("Content-Type", "text/plain"); - headers.set("Cache-Control", `max-age=3000`) - } + const res = await fetch(url); + const file = await res.blob(); + + const headers = new Headers(); if (new RegExp(imageRegex).exec(path)) { - const cacheExpirationInSeconds = 60 * 60 * 24 * 30 // 30 days + const cacheExpirationInSeconds = 60 * 60 * 24 * 30; // 30 days headers.set("Content-Type", "image/*"); - headers.set("Cache-Control", `max-age=${cacheExpirationInSeconds}`) - } - return new NextResponse(file, { status: 200, headers }) + headers.set("Cache-Control", `max-age=${cacheExpirationInSeconds}`); + } else { + headers.set("Content-Type", "text/plain"); + } + return new NextResponse(file, { status: 200, headers }); } diff --git a/src/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx index 5c6e114e..9eb6fc70 100644 --- a/src/features/projects/view/ProjectsContextProvider.tsx +++ b/src/features/projects/view/ProjectsContextProvider.tsx @@ -11,7 +11,7 @@ const ProjectsContextProvider = ({ initialProjects?: Project[]; children?: React.ReactNode; }) => { - const [refreshed, setRefreshed] = useState(true); + const [refreshed, setRefreshed] = useState(false); const [projects, setProjects] = useState(initialProjects || []); const hasProjectChanged = (value: Project[]) => From 02dfb39601f6f09d66b53edd9a84cf68d6ab1989 Mon Sep 17 00:00:00 2001 From: Oscar Date: Mon, 29 Sep 2025 11:37:08 +0200 Subject: [PATCH 05/14] Improves API caching and error handling Enhances caching mechanisms in API responses by introducing revalidation and refining cache-control headers for better performance. Adds a loading state in the ProjectsContextProvider to prevent redundant fetch calls and ensures error handling logs are more descriptive. Simplifies code style by removing semicolons and adjusts default state initialization to optimize user experience. --- .../[owner]/[repository]/[...path]/route.ts | 55 ++++++++++--------- src/app/api/projects/route.ts | 12 +++- .../domain/CachingProjectDataSource.ts | 2 +- .../projects/view/ProjectsContextProvider.tsx | 14 +++-- 4 files changed, 48 insertions(+), 35 deletions(-) diff --git a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts index 471746ea..1249b821 100644 --- a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts +++ b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts @@ -1,41 +1,42 @@ -import { NextRequest, NextResponse } from "next/server"; -import { session, userGitHubClient } from "@/composition"; -import { makeUnauthenticatedAPIErrorResponse } from "@/common"; +import { NextRequest, NextResponse } from "next/server" +import { session, userGitHubClient } from "@/composition" +import { makeUnauthenticatedAPIErrorResponse } from "@/common" +import { revalidatePath } from "next/cache" + interface GetBlobParams { - owner: string; - repository: string; - path: [string]; + owner: string + repository: string + path: [string] } -export async function GET( - req: NextRequest, - { params }: { params: Promise } -) { - const isAuthenticated = await session.getIsAuthenticated(); +export async function GET(req: NextRequest, { params }: { params: Promise } ) { + const isAuthenticated = await session.getIsAuthenticated() if (!isAuthenticated) { - return makeUnauthenticatedAPIErrorResponse(); + return makeUnauthenticatedAPIErrorResponse() } - const { path: paramsPath, owner, repository } = await params; - const path = paramsPath.join("/"); + const { path: paramsPath, owner, repository } = await params + const path = paramsPath.join("/") const item = await userGitHubClient.getRepositoryContent({ repositoryOwner: owner, repositoryName: repository, path: path, - ref: req.nextUrl.searchParams.get("ref") ?? undefined, - }); - const url = new URL(item.downloadURL); + ref: req.nextUrl.searchParams.get("ref") ?? undefined + }) + const url = new URL(item.downloadURL) const imageRegex = /\.(jpg|jpeg|png|webp|avif|gif)$/; - const res = await fetch(url); - const file = await res.blob(); - - const headers = new Headers(); - if (new RegExp(imageRegex).exec(path)) { - const cacheExpirationInSeconds = 60 * 60 * 24 * 30; // 30 days - headers.set("Content-Type", "image/*"); - headers.set("Cache-Control", `max-age=${cacheExpirationInSeconds}`); - } else { + const res = await fetch(url, { next: { revalidate: 6000 } }) + const file = await res.blob() + revalidatePath('/(authed)/projects') + const headers = new Headers() + if (res.status !== 200) { headers.set("Content-Type", "text/plain"); + headers.set("Cache-Control", `max-age=3000`) } - return new NextResponse(file, { status: 200, headers }); + if (new RegExp(imageRegex).exec(path)) { + const cacheExpirationInSeconds = 60 * 60 * 24 * 30 // 30 days + headers.set("Content-Type", "image/*"); + headers.set("Cache-Control", `max-age=${cacheExpirationInSeconds}`) + } + return new NextResponse(file, { status: 200, headers }) } diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 4cd6c396..51ae96b9 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -1,7 +1,13 @@ -import { NextResponse } from "next/server" -import { projectDataSource } from "@/composition" +import { NextResponse } from "next/server"; +import { projectDataSource } from "@/composition"; + export async function GET() { + const projects = await projectDataSource.getProjects() + return NextResponse.json({ projects}) +} + +export async function POST() { const projects = await projectDataSource.refreshProjects() return NextResponse.json({ projects }) -} +} \ No newline at end of file diff --git a/src/features/projects/domain/CachingProjectDataSource.ts b/src/features/projects/domain/CachingProjectDataSource.ts index 7c8aeff1..05bd8291 100644 --- a/src/features/projects/domain/CachingProjectDataSource.ts +++ b/src/features/projects/domain/CachingProjectDataSource.ts @@ -1,7 +1,7 @@ import Project from "./Project"; import IProjectDataSource from "./IProjectDataSource"; import IProjectRepository from "./IProjectRepository"; -import { revalidatePath } from "next/cache"; + export default class CachingProjectDataSource implements IProjectDataSource { private dataSource: IProjectDataSource; diff --git a/src/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx index 9eb6fc70..ef171767 100644 --- a/src/features/projects/view/ProjectsContextProvider.tsx +++ b/src/features/projects/view/ProjectsContextProvider.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { ProjectsContext } from "@/common"; import { Project } from "@/features/projects/domain"; @@ -11,8 +11,9 @@ const ProjectsContextProvider = ({ initialProjects?: Project[]; children?: React.ReactNode; }) => { - const [refreshed, setRefreshed] = useState(false); + const [refreshed, setRefreshed] = useState(true); const [projects, setProjects] = useState(initialProjects || []); + const isLoadingRef = useRef(false); const hasProjectChanged = (value: Project[]) => value.some((project, index) => { @@ -32,6 +33,9 @@ const ProjectsContextProvider = ({ // Trigger background refresh after initial mount useEffect(() => { const refreshProjects = () => { + if (isLoadingRef.current) return; + isLoadingRef.current = true; + fetch("/api/projects") .then((res) => res.json()) .then( @@ -40,9 +44,11 @@ const ProjectsContextProvider = ({ hasProjectChanged(projects) && setProjectsAndRefreshed(projects) ) - .catch((error) => console.log("Failed to refresh projects", error)); + .catch((error) => console.error("Failed to refresh projects", error)) + .finally(() => { + isLoadingRef.current = false; + }); }; - // Initial refresh refreshProjects(); From bc99998cd3920f00c0e2a8cefd6e49b4812ad3ad Mon Sep 17 00:00:00 2001 From: Oscar Date: Mon, 29 Sep 2025 12:58:52 +0200 Subject: [PATCH 06/14] post til refreshProjects --- src/app/api/projects/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 51ae96b9..030af9c5 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -4,7 +4,7 @@ import { projectDataSource } from "@/composition"; export async function GET() { const projects = await projectDataSource.getProjects() - return NextResponse.json({ projects}) + return NextResponse.json({ projects }) } export async function POST() { From ff9d7274d82d3f0e2d172f2287bdb7bd6151396b Mon Sep 17 00:00:00 2001 From: Oscar Date: Mon, 29 Sep 2025 13:24:45 +0200 Subject: [PATCH 07/14] Updates fetch request to use POST method Changes the HTTP method from GET to POST for the projects API fetch request. This adjustment may address API requirements or enhance compatibility with server-side logic. --- src/features/projects/view/ProjectsContextProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx index ef171767..f8bf2f38 100644 --- a/src/features/projects/view/ProjectsContextProvider.tsx +++ b/src/features/projects/view/ProjectsContextProvider.tsx @@ -36,7 +36,7 @@ const ProjectsContextProvider = ({ if (isLoadingRef.current) return; isLoadingRef.current = true; - fetch("/api/projects") + fetch("/api/projects", { method: "POST" }) .then((res) => res.json()) .then( ({ projects }) => From 315a43e34114bbc0beb1f9d2afcbba535061d968 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Mon, 29 Sep 2025 20:15:28 +0200 Subject: [PATCH 08/14] Use refreshing state to show loading while project cache is refreshed --- .../(project-doc)/[...slug]/layout.tsx | 25 ++++++------------- .../(authed)/(project-doc)/[...slug]/page.tsx | 12 ++++++--- src/common/context/ProjectsContext.ts | 4 +-- .../projects/view/ProjectsContextProvider.tsx | 10 ++++---- 4 files changed, 23 insertions(+), 28 deletions(-) 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/common/context/ProjectsContext.ts b/src/common/context/ProjectsContext.ts index cc0fe966..8a2cf66f 100644 --- a/src/common/context/ProjectsContext.ts +++ b/src/common/context/ProjectsContext.ts @@ -6,13 +6,13 @@ 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/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx index f8bf2f38..e9371ac2 100644 --- a/src/features/projects/view/ProjectsContextProvider.tsx +++ b/src/features/projects/view/ProjectsContextProvider.tsx @@ -11,9 +11,9 @@ const ProjectsContextProvider = ({ initialProjects?: Project[]; children?: React.ReactNode; }) => { - const [refreshed, setRefreshed] = useState(true); const [projects, setProjects] = useState(initialProjects || []); const isLoadingRef = useRef(false); + const [refreshing, setRefreshing] = useState(false); const hasProjectChanged = (value: Project[]) => value.some((project, index) => { @@ -26,8 +26,6 @@ const ProjectsContextProvider = ({ const setProjectsAndRefreshed = (value: Project[]) => { setProjects(value); - // If any project has changed, update the state and mark as refreshed - if (hasProjectChanged(value)) setRefreshed(true); }; // Trigger background refresh after initial mount @@ -35,7 +33,8 @@ const ProjectsContextProvider = ({ const refreshProjects = () => { if (isLoadingRef.current) return; isLoadingRef.current = true; - + setRefreshing(true); + fetch("/api/projects", { method: "POST" }) .then((res) => res.json()) .then( @@ -47,6 +46,7 @@ const ProjectsContextProvider = ({ .catch((error) => console.error("Failed to refresh projects", error)) .finally(() => { isLoadingRef.current = false; + setRefreshing(false); }); }; // Initial refresh @@ -65,8 +65,8 @@ const ProjectsContextProvider = ({ return ( From 42ee24442b20386358037ff5fcb989a583e27a76 Mon Sep 17 00:00:00 2001 From: Oscar Date: Tue, 30 Sep 2025 11:25:15 +0200 Subject: [PATCH 09/14] Removes unused ref and simplifies project refresh logic Eliminates the `isLoadingRef` as it was no longer in use and comments out the `hasProjectChanged` function to simplify the code. Improves the project refresh mechanism by directly updating the state when new projects are fetched, removing unnecessary checks and reducing complexity. This enhances maintainability and ensures clearer logic flow. --- src/app/api/projects/route.ts | 5 ----- src/app/api/refreshedProjects/route.ts | 11 ++++++++++ .../projects/view/ProjectsContextProvider.tsx | 20 +++---------------- 3 files changed, 14 insertions(+), 22 deletions(-) create mode 100644 src/app/api/refreshedProjects/route.ts diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 030af9c5..f5090822 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -6,8 +6,3 @@ export async function GET() { const projects = await projectDataSource.getProjects() return NextResponse.json({ projects }) } - -export async function POST() { - const projects = await projectDataSource.refreshProjects() - return NextResponse.json({ projects }) -} \ No newline at end of file diff --git a/src/app/api/refreshedProjects/route.ts b/src/app/api/refreshedProjects/route.ts new file mode 100644 index 00000000..29bf97fb --- /dev/null +++ b/src/app/api/refreshedProjects/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { projectDataSource } from "@/composition"; + + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const shouldRefresh = searchParams.get("refresh") === "true"; + const projects = shouldRefresh + ? await projectDataSource.refreshProjects() + : await projectDataSource.getProjects(); + return NextResponse.json({ projects });} \ No newline at end of file diff --git a/src/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx index e9371ac2..ae11839f 100644 --- a/src/features/projects/view/ProjectsContextProvider.tsx +++ b/src/features/projects/view/ProjectsContextProvider.tsx @@ -12,18 +12,8 @@ const ProjectsContextProvider = ({ children?: React.ReactNode; }) => { const [projects, setProjects] = useState(initialProjects || []); - const isLoadingRef = useRef(false); const [refreshing, setRefreshing] = useState(false); - 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 setProjectsAndRefreshed = (value: Project[]) => { setProjects(value); }; @@ -31,21 +21,17 @@ const ProjectsContextProvider = ({ // Trigger background refresh after initial mount useEffect(() => { const refreshProjects = () => { - if (isLoadingRef.current) return; - isLoadingRef.current = true; setRefreshing(true); fetch("/api/projects", { method: "POST" }) .then((res) => res.json()) .then( - ({ projects }) => - projects && - hasProjectChanged(projects) && - setProjectsAndRefreshed(projects) + ({ projects }) => { + if (projects) setProjectsAndRefreshed(projects); + } ) .catch((error) => console.error("Failed to refresh projects", error)) .finally(() => { - isLoadingRef.current = false; setRefreshing(false); }); }; From e1a2a9313533c10fecc6986f96e90565e32f9df2 Mon Sep 17 00:00:00 2001 From: Oscar Date: Tue, 30 Sep 2025 12:09:06 +0200 Subject: [PATCH 10/14] Refactors project API endpoints to support refresh logic Updates the `GET /api/projects` endpoint to handle a `refresh` query parameter, enabling conditional fetching of refreshed projects. Replaces the `GET /api/refreshedProjects` endpoint with a `POST` method that directly triggers a refresh operation. Adjusts the `ProjectsContextProvider` to utilize the new `POST /api/refreshedProjects` endpoint, aligning with the updated API design. Simplifies API behavior and improves clarity between fetching current vs. refreshed project data. Refactors project API endpoints for improved refresh logic Updates project-related API endpoints to simplify behavior and enhance clarity: - Deprecates the `GET /api/projects` endpoint, replacing it with a `POST /api/refreshedProjects` endpoint for triggering project refresh operations. - Modifies `ProjectsContextProvider` to utilize the new API endpoint, ensuring consistency with the updated design. - Adds a loading guard to prevent duplicate refresh requests. Improves maintainability and aligns API operations with the intended logic for fetching refreshed project data. --- src/app/api/projects/route.ts | 3 ++- src/app/api/refreshedProjects/route.ts | 11 ++++------- .../projects/view/ProjectsContextProvider.tsx | 18 ++++++++++-------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index f5090822..fc911b0b 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +/* import { NextResponse } from "next/server"; import { projectDataSource } from "@/composition"; @@ -6,3 +6,4 @@ export async function GET() { const projects = await projectDataSource.getProjects() return NextResponse.json({ projects }) } + */ \ No newline at end of file diff --git a/src/app/api/refreshedProjects/route.ts b/src/app/api/refreshedProjects/route.ts index 29bf97fb..3dbab639 100644 --- a/src/app/api/refreshedProjects/route.ts +++ b/src/app/api/refreshedProjects/route.ts @@ -2,10 +2,7 @@ import { NextResponse } from "next/server"; import { projectDataSource } from "@/composition"; -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const shouldRefresh = searchParams.get("refresh") === "true"; - const projects = shouldRefresh - ? await projectDataSource.refreshProjects() - : await projectDataSource.getProjects(); - return NextResponse.json({ projects });} \ No newline at end of file +export async function POST() { + const projects = await projectDataSource.refreshProjects() + return NextResponse.json({ projects }) +} \ No newline at end of file diff --git a/src/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx index ae11839f..37553f02 100644 --- a/src/features/projects/view/ProjectsContextProvider.tsx +++ b/src/features/projects/view/ProjectsContextProvider.tsx @@ -13,6 +13,7 @@ const ProjectsContextProvider = ({ }) => { const [projects, setProjects] = useState(initialProjects || []); const [refreshing, setRefreshing] = useState(false); + const isLoadingRef = useRef(false); const setProjectsAndRefreshed = (value: Project[]) => { setProjects(value); @@ -21,24 +22,25 @@ const ProjectsContextProvider = ({ // Trigger background refresh after initial mount useEffect(() => { const refreshProjects = () => { + if (isLoadingRef.current) { + return; + } + isLoadingRef.current = true; setRefreshing(true); - - fetch("/api/projects", { method: "POST" }) + fetch("/api/refreshedProjects", { method: "POST" }) .then((res) => res.json()) - .then( - ({ projects }) => { - if (projects) setProjectsAndRefreshed(projects); - } - ) + .then(({ projects }) => { + if (projects) setProjectsAndRefreshed(projects); + }) .catch((error) => console.error("Failed to refresh projects", error)) .finally(() => { + isLoadingRef.current = false; setRefreshing(false); }); }; // Initial refresh refreshProjects(); - // Refresh when tab becomes active const handleVisibilityChange = () => { if (!document.hidden) refreshProjects(); }; From d577e9df4ffd10e8b479b5f1df4db21c1247dee5 Mon Sep 17 00:00:00 2001 From: Oscar Date: Tue, 30 Sep 2025 13:40:07 +0200 Subject: [PATCH 11/14] Refactors project data handling and API structure Consolidates project data handling by improving caching logic and removing unused API routes. Updates API endpoint paths for better consistency. Simplifies error handling and streamlines blob retrieval in the GET route. Enhances readability and reliability by refining conditional checks and eliminating redundant code. --- src/app/(authed)/layout.tsx | 3 +-- .../[owner]/[repository]/[...path]/route.ts | 18 ++++++------------ src/app/api/projects/route.ts | 9 --------- .../route.ts | 3 ++- .../domain/CachingProjectDataSource.ts | 12 +++++------- .../projects/view/ProjectsContextProvider.tsx | 3 +-- .../internal/sidebar/projects/ProjectList.tsx | 13 ------------- 7 files changed, 15 insertions(+), 46 deletions(-) delete mode 100644 src/app/api/projects/route.ts rename src/app/api/{refreshedProjects => refresh-projects}/route.ts (80%) diff --git a/src/app/(authed)/layout.tsx b/src/app/(authed)/layout.tsx index fd59a51d..798d2fe5 100644 --- a/src/app/(authed)/layout.tsx +++ b/src/app/(authed)/layout.tsx @@ -1,10 +1,9 @@ import { redirect } from "next/navigation"; import { SessionProvider } from "next-auth/react"; -import { session } from "@/composition"; +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 { projectDataSource } from "@/composition"; import { SidebarTogglableContextProvider, SplitView, diff --git a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts index 1249b821..f6f06b0c 100644 --- a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts +++ b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts @@ -1,8 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { session, userGitHubClient } from "@/composition" import { makeUnauthenticatedAPIErrorResponse } from "@/common" -import { revalidatePath } from "next/cache" - interface GetBlobParams { owner: string @@ -10,7 +8,7 @@ interface GetBlobParams { path: [string] } -export async function GET(req: NextRequest, { params }: { params: Promise } ) { +export async function GET(req: NextRequest, { params }: { params: Promise }) { const isAuthenticated = await session.getIsAuthenticated() if (!isAuthenticated) { return makeUnauthenticatedAPIErrorResponse() @@ -25,18 +23,14 @@ export async function GET(req: NextRequest, { params }: { params: Promise r.blob()) const headers = new Headers() - if (res.status !== 200) { - headers.set("Content-Type", "text/plain"); - headers.set("Cache-Control", `max-age=3000`) - } if (new RegExp(imageRegex).exec(path)) { const cacheExpirationInSeconds = 60 * 60 * 24 * 30 // 30 days headers.set("Content-Type", "image/*"); headers.set("Cache-Control", `max-age=${cacheExpirationInSeconds}`) - } + } else { + headers.set("Content-Type", "text/plain"); + } return new NextResponse(file, { status: 200, headers }) -} +} \ No newline at end of file diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts deleted file mode 100644 index fc911b0b..00000000 --- a/src/app/api/projects/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* import { NextResponse } from "next/server"; -import { projectDataSource } from "@/composition"; - - -export async function GET() { - const projects = await projectDataSource.getProjects() - return NextResponse.json({ projects }) -} - */ \ No newline at end of file diff --git a/src/app/api/refreshedProjects/route.ts b/src/app/api/refresh-projects/route.ts similarity index 80% rename from src/app/api/refreshedProjects/route.ts rename to src/app/api/refresh-projects/route.ts index 3dbab639..fbaedd4c 100644 --- a/src/app/api/refreshedProjects/route.ts +++ b/src/app/api/refresh-projects/route.ts @@ -1,4 +1,5 @@ -import { NextResponse } from "next/server"; + +import { NextResponse } from "next/server" import { projectDataSource } from "@/composition"; diff --git a/src/features/projects/domain/CachingProjectDataSource.ts b/src/features/projects/domain/CachingProjectDataSource.ts index 05bd8291..73f85281 100644 --- a/src/features/projects/domain/CachingProjectDataSource.ts +++ b/src/features/projects/domain/CachingProjectDataSource.ts @@ -17,14 +17,12 @@ export default class CachingProjectDataSource implements IProjectDataSource { async getProjects(): Promise { const cache = await this.repository.get(); - - if (cache) return cache; - else { - const projects = await this.dataSource.getProjects(); - await this.repository.set(projects); - - return projects; + if (cache && cache.length > 0) { + return cache; } + const projects = await this.dataSource.getProjects(); + await this.repository.set(projects); + return projects; } async refreshProjects(): Promise { diff --git a/src/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx index 37553f02..0a72f240 100644 --- a/src/features/projects/view/ProjectsContextProvider.tsx +++ b/src/features/projects/view/ProjectsContextProvider.tsx @@ -27,7 +27,7 @@ const ProjectsContextProvider = ({ } isLoadingRef.current = true; setRefreshing(true); - fetch("/api/refreshedProjects", { method: "POST" }) + fetch("/api/refresh-projects", { method: "POST" }) .then((res) => res.json()) .then(({ projects }) => { if (projects) setProjectsAndRefreshed(projects); @@ -40,7 +40,6 @@ const ProjectsContextProvider = ({ }; // Initial refresh refreshProjects(); - const handleVisibilityChange = () => { if (!document.hidden) refreshProjects(); }; diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx index 19f17257..0215d9e8 100644 --- a/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx +++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx @@ -19,19 +19,6 @@ const ProjectList = () => { export default ProjectList -/* const DataFetchingProjectList = async ({ - projectDataSource -}: { - projectDataSource: IProjectDataSource -}) => { - const projects = await projectDataSource.getProjects() - if (projects.length > 0) { - return - } else { - return - } -} */ - const EmptyProjectList = () => { return ( Date: Tue, 30 Sep 2025 13:48:42 +0200 Subject: [PATCH 12/14] Handles decode errors as missing cache Updates error handling to treat decode failures as missing cache instead of logging or propagating errors. This ensures robustness when decoding cached data and prevents unintended application breakdowns due to invalid cache contents. --- src/features/projects/domain/ProjectRepository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/projects/domain/ProjectRepository.ts b/src/features/projects/domain/ProjectRepository.ts index 74b036b9..40264af2 100644 --- a/src/features/projects/domain/ProjectRepository.ts +++ b/src/features/projects/domain/ProjectRepository.ts @@ -24,7 +24,7 @@ export default class ProjectRepository implements IProjectRepository { } try { return ZodJSONCoder.decode(ProjectSchema.array(), string) - } catch (err) { + } catch (_err) { // swallow decode errors and treat as missing cache return undefined } } From a9a64de8562ab7cdb3b7b33300cf0165903c0fe4 Mon Sep 17 00:00:00 2001 From: Oscar Date: Tue, 30 Sep 2025 13:58:21 +0200 Subject: [PATCH 13/14] complerte removal of catch error --- src/features/projects/domain/ProjectRepository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/projects/domain/ProjectRepository.ts b/src/features/projects/domain/ProjectRepository.ts index 40264af2..5730625d 100644 --- a/src/features/projects/domain/ProjectRepository.ts +++ b/src/features/projects/domain/ProjectRepository.ts @@ -24,7 +24,7 @@ export default class ProjectRepository implements IProjectRepository { } try { return ZodJSONCoder.decode(ProjectSchema.array(), string) - } catch (_err) { // swallow decode errors and treat as missing cache + } catch { // swallow decode errors and treat as missing cache return undefined } } From f1c4d29c3ceae803b891ddea060c9b76179b1652 Mon Sep 17 00:00:00 2001 From: Oscar Date: Tue, 30 Sep 2025 14:41:02 +0200 Subject: [PATCH 14/14] Removes unused setProjects function from context Eliminates the unused setProjects function from the ProjectsContext to simplify the context structure and remove unnecessary code. This enhances maintainability and reduces potential confusion. Removes unused setProjects function from context Simplifies the ProjectsContext by removing the unused setProjects function. This improves maintainability, reduces potential confusion, and eliminates unnecessary code. Adds a warning log to ProjectRepository for better debugging when cached project decoding fails. --- src/common/context/ProjectsContext.ts | 2 -- src/features/projects/domain/ProjectRepository.ts | 1 + src/features/projects/view/ProjectsContextProvider.tsx | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/common/context/ProjectsContext.ts b/src/common/context/ProjectsContext.ts index 8a2cf66f..f4d30699 100644 --- a/src/common/context/ProjectsContext.ts +++ b/src/common/context/ProjectsContext.ts @@ -8,11 +8,9 @@ export const SidebarTogglableContext = createContext(true) type ProjectsContextValue = { refreshing: boolean, projects: Project[], - setProjects: (projects: Project[]) => void } export const ProjectsContext = createContext({ refreshing: false, projects: [], - setProjects: () => {} }) diff --git a/src/features/projects/domain/ProjectRepository.ts b/src/features/projects/domain/ProjectRepository.ts index 5730625d..0b68bfdb 100644 --- a/src/features/projects/domain/ProjectRepository.ts +++ b/src/features/projects/domain/ProjectRepository.ts @@ -25,6 +25,7 @@ export default class ProjectRepository implements IProjectRepository { try { return ZodJSONCoder.decode(ProjectSchema.array(), string) } 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 0a72f240..639363e1 100644 --- a/src/features/projects/view/ProjectsContextProvider.tsx +++ b/src/features/projects/view/ProjectsContextProvider.tsx @@ -54,7 +54,6 @@ const ProjectsContextProvider = ({ value={{ projects, refreshing, - setProjects: setProjectsAndRefreshed, }} > {children}