Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
version: '3.8'
services:
cache:
image: redis:6.2-alpine
Expand All @@ -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
driver: local
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 24 additions & 15 deletions src/app/(authed)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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();


return (
<ErrorHandler>
<SessionProvider>
<SessionBarrier>
<ProjectsContextProvider initialProjects={projects}>
<SidebarTogglableContextProvider>
<SplitView>
{children}
</SplitView>
<SplitView>{children}</SplitView>
</SidebarTogglableContextProvider>
</ProjectsContextProvider>
</SessionBarrier>
</SessionProvider>
</ErrorHandler>
)
);
}
16 changes: 11 additions & 5 deletions src/app/api/blob/[owner]/[repository]/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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]
}

export async function GET(req: NextRequest, { params }: { params: Promise<GetBlobParams> }) {
export async function GET(req: NextRequest, { params }: { params: Promise<GetBlobParams> } ) {
const isAuthenticated = await session.getIsAuthenticated()
if (!isAuthenticated) {
return makeUnauthenticatedAPIErrorResponse()
Expand All @@ -23,14 +25,18 @@ export async function GET(req: NextRequest, { params }: { params: Promise<GetBlo
})
const url = new URL(item.downloadURL)
const imageRegex = /\.(jpg|jpeg|png|webp|avif|gif)$/;
const file = await fetch(url).then(r => 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 })
}
2 changes: 1 addition & 1 deletion src/app/api/hooks/github/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ import { gitHubHookHandler } from "@/composition"
export const POST = async (req: NextRequest): Promise<NextResponse> => {
await gitHubHookHandler.handle(req)
return NextResponse.json({ status: "OK" })
}
}
13 changes: 13 additions & 0 deletions src/app/api/projects/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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 })
}
6 changes: 3 additions & 3 deletions src/common/utils/fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export class FetcherError extends Error {
export class FetcherError extends Error {
readonly status: number

constructor(status: number, message: string) {
Expand All @@ -12,9 +12,9 @@
input: RequestInfo,
init?: RequestInit
): Promise<JSON> {
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()
}
}
2 changes: 1 addition & 1 deletion src/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,4 @@ export const gitHubHookHandler = new GitHubHookHandler({
})
})
})
})
})
2 changes: 1 addition & 1 deletion src/features/docs/view/Stoplight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,4 @@ const ElementsAPI = ({
})
}

export default Stoplight
export default Stoplight
2 changes: 1 addition & 1 deletion src/features/docs/view/Swagger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,4 @@ const Swagger = ({ url }: { url: string }) => {
)
}

export default Swagger
export default Swagger
44 changes: 30 additions & 14 deletions src/features/projects/domain/CachingProjectDataSource.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
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<Project[]> {
const projects = await this.dataSource.getProjects()
await this.repository.set(projects)
return projects
const cache = await this.repository.get();

if (cache) return cache;
else {
const projects = await this.dataSource.getProjects();
await this.repository.set(projects);

return projects;
}
}

async refreshProjects(): Promise<Project[]> {
const projects = await this.dataSource.getProjects();
await this.repository.set(projects);
return projects;
}
}
}
4 changes: 2 additions & 2 deletions src/features/projects/domain/ProjectRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@ export default class ProjectRepository implements IProjectRepository {
this.repository = config.repository
}

async get(): Promise<Project[] | undefined> {
async get(): Promise<Project[] | undefined> {
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)
return undefined
}
}
Expand Down
87 changes: 63 additions & 24 deletions src/features/projects/view/ProjectsContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,78 @@
"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<boolean>(false)
const [projects, setProjects] = useState<Project[]>(initialProjects || [])
const [refreshed, setRefreshed] = useState<boolean>(true);
const [projects, setProjects] = useState<Project[]>(initialProjects || []);
const isLoadingRef = useRef(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 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 = () => {
if (isLoadingRef.current) return;
isLoadingRef.current = true;

fetch("/api/projects")
.then((res) => res.json())
.then(
({ projects }) =>
projects &&
hasProjectChanged(projects) &&
setProjectsAndRefreshed(projects)
)
.catch((error) => console.error("Failed to refresh projects", error))
.finally(() => {
isLoadingRef.current = false;
});
};
// Initial refresh
refreshProjects();

// Refresh when tab becomes active
const handleVisibilityChange = () => {
if (!document.hidden) refreshProjects();
};

document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);

}
return (
<ProjectsContext.Provider value={{
refreshed,
projects,
setProjects: setProjectsAndRefreshed
}}>
<ProjectsContext.Provider
value={{
refreshed,
projects,
setProjects: setProjectsAndRefreshed,
}}
>
{children}
</ProjectsContext.Provider>
)
}
);
};

export default ProjectsContextProvider
export default ProjectsContextProvider;
3 changes: 1 addition & 2 deletions src/features/sidebar/view/SplitView.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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.
<BaseSidebar siteName={SITE_NAME} helpURL={HELP_URL}>
<ProjectList projectDataSource={projectDataSource} />
<ProjectList />
</BaseSidebar>
)
}
Loading
Loading