Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.env*
!.env.example
59 changes: 59 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: SystemCraft CI

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

permissions:
contents: read

jobs:
lint-and-typecheck:
name: Lint & Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run ESLint
run: npm run lint

- name: Typecheck
run: npx tsc --noEmit

docker-build-test:
name: Docker Build Test
runs-on: ubuntu-latest
needs: [lint-and-typecheck]
steps:
- uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build Docker Image
uses: docker/build-push-action@v5
with:
context: .
push: false # We only want to test the build, not push it to a registry yet
tags: systemcraft-web:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
NEXT_TELEMETRY_DISABLED=1
NEXT_PUBLIC_FIREBASE_API_KEY=${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID=${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts

docs/
docs/
eslint_output.txt
37 changes: 37 additions & 0 deletions DOCKER_INSTRUCTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# SystemCraft Docker Guide

SystemCraft is fully containerized using a highly optimized, multi-stage Next.js Dockerfile constraint to `node:20-alpine`.

## Prerequisites
- [Docker](https://docs.docker.com/get-docker/) installed and running on your local machine.
- A populated `.env` file in the root directory (copy from `.env.example`). Your Next.js static build requires the `NEXT_PUBLIC_FIREBASE_*` variables to be present during the build process.

## Running Locally

The easiest way to build and run the application is using Docker Compose.

1. **Start the application (Builds if necessary):**
```bash
docker-compose up --build -d
```
*The `-d` flag runs the container in the background (detached mode).*

2. **Access the application:**
Open your browser and navigate to `http://localhost:3000`.

3. **Stop the application:**
```bash
docker-compose down
```

4. **View live logs:**
If you ran the container in detached mode, you can still view the server logs:
```bash
docker-compose logs -f
```

## Production Architecture Notes

- **Standalone Mode:** The `next.config.ts` is configured with `output: "standalone"`. This prevents Docker from copying the entire bulk of `node_modules` into the final runner image, drastically saving space.
- **Environment Variables:** During the `builder` stage, Next.js "bakes" `NEXT_PUBLIC_` variables into the static frontend files. Ensure your `.env` is properly populated before running `docker-compose build`. If you change your `.env` file, you **must rebuild** the image for those changes to take effect on the client side.
- **Secret Management:** Server-side secrets (like MongoDB connections) are passed at runtime by `docker-compose.yml` reading the local `.env` file.
53 changes: 53 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
FROM node:20-alpine AS base

FROM base AS deps

RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ARG NEXT_PUBLIC_FIREBASE_API_KEY
ARG NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
ARG NEXT_PUBLIC_FIREBASE_PROJECT_ID
ARG NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET
ARG NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
ARG NEXT_PUBLIC_FIREBASE_APP_ID

ENV NEXT_PUBLIC_FIREBASE_API_KEY=$NEXT_PUBLIC_FIREBASE_API_KEY
ENV NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=$NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
ENV NEXT_PUBLIC_FIREBASE_PROJECT_ID=$NEXT_PUBLIC_FIREBASE_PROJECT_ID
ENV NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=$NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET
ENV NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=$NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
ENV NEXT_PUBLIC_FIREBASE_APP_ID=$NEXT_PUBLIC_FIREBASE_APP_ID
ENV NEXT_TELEMETRY_DISABLED 1

RUN npm run build

FROM base AS runner
WORKDIR /app

ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

RUN mkdir .next
RUN chown nextjs:nodejs .next

COPY --from=builder /app/public ./public

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000

ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
1 change: 1 addition & 0 deletions app/api/user/analytics/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import dbConnect from '@/src/lib/db/mongoose';
import User from '@/src/lib/db/models/User';
Expand Down
8 changes: 4 additions & 4 deletions app/dashboard/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default function AnalyticsPage() {
const [retryCounter, setRetryCounter] = useState(0);

useEffect(() => {
if (!isAuthenticated || !user) return;
if (!isAuthenticated || !user?.uid) return;

const controller = new AbortController();

Expand All @@ -54,8 +54,8 @@ export default function AnalyticsPage() {
if (!controller.signal.aborted) {
setData(result);
}
} catch (err: any) {
if (err.name === 'AbortError') return;
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return;
console.error(err);
if (!controller.signal.aborted) {
setError(err instanceof Error ? err.message : 'Unknown error');
Expand All @@ -72,7 +72,7 @@ export default function AnalyticsPage() {
return () => {
controller.abort();
};
}, [isAuthenticated, user?.uid, retryCounter]);
}, [isAuthenticated, user, retryCounter]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Using full user object in dependency array risks infinite re-renders and redundant fetches.

The codebase pattern in app/interview/page.tsx uses user?.uid as the dependency specifically to prevent re-creation when the user object reference changes. The useRequireAuth hook returns a new object reference on every render, and the Firebase User object has many properties that can trigger unnecessary effect re-runs.

Using user instead of user?.uid means:

  • The effect may fire repeatedly even when the authenticated user hasn't changed
  • Potential for excessive API calls and degraded performance
  • Risk of render loops if state updates cause user reference changes
🐛 Recommended fix
-    }, [isAuthenticated, user, retryCounter]);
+    }, [isAuthenticated, user?.uid, retryCounter]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
}, [isAuthenticated, user, retryCounter]);
}, [isAuthenticated, user?.uid, retryCounter]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/dashboard/analytics/page.tsx` at line 76, The useEffect dependency array
in app/dashboard/analytics/page.tsx currently includes the full user object
which can change references and cause redundant re-renders; update the
dependency to use a stable identifier instead (replace user with user?.uid) so
the effect only re-runs when the authenticated user's UID actually changes;
locate the effect that depends on isAuthenticated, user, retryCounter (created
alongside useRequireAuth) and change the dependency list to [isAuthenticated,
user?.uid, retryCounter], leaving the effect body intact.


if (authLoading || isLoading) {
return (
Expand Down
5 changes: 3 additions & 2 deletions app/interview/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useEffect, useState, useCallback, use, useRef } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useRequireAuth } from '@/src/hooks/useRequireAuth';
import { authFetch } from '@/src/lib/firebase/authClient';
Expand Down Expand Up @@ -291,12 +292,12 @@ export default function InterviewCanvasPage({ params }: PageProps) {
<span className="material-symbols-outlined text-5xl text-red-500 mb-4">error</span>
<h2 className="text-xl font-bold text-white mb-2">Failed to Load</h2>
<p className="text-slate-400 mb-6">{error || 'Session not found'}</p>
<a
<Link
href="/interview"
className="px-4 py-2 bg-primary hover:bg-primary/90 text-white rounded-lg font-medium"
>
Back to Interviews
</a>
</Link>
</div>
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions app/interview/[id]/result/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useRequireAuth } from '@/src/hooks/useRequireAuth';
import { authFetch } from '@/src/lib/firebase/authClient';
import { IInterviewQuestion, IEvaluation } from '@/src/lib/db/models/InterviewSession';
import { IInterviewQuestion, IEvaluation, IRuleResult } from '@/src/lib/db/models/InterviewSession';
import { DesignCanvas, CanvasNode, Connection } from '@/components/canvas/DesignCanvas';

interface InterviewSessionData {
Expand Down Expand Up @@ -248,7 +248,7 @@ export default function InterviewResultPage({ params }: PageProps) {
</div>

<div className="grid grid-cols-1 gap-3">
{structural.details.map((detail: any, i: number) => (
{structural.details.map((detail: IRuleResult, i: number) => (
<div
key={i}
className={`p-4 rounded-2xl border transition-all hover:translate-x-1 ${detail.status === 'pass'
Expand Down Expand Up @@ -341,7 +341,7 @@ export default function InterviewResultPage({ params }: PageProps) {
<div className="space-y-2">
{reasoning.suggestions.map((s: string, i: number) => (
<div key={i} className="p-3 bg-white/5 border border-white/5 rounded-xl text-sm text-slate-300 leading-relaxed italic">
"{s}"
<q>{s}</q>
</div>
))}
</div>
Expand Down
1 change: 1 addition & 0 deletions components/ArchitectureField.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable */
"use client";

import React, { useRef, useMemo } from "react";
Expand Down
12 changes: 6 additions & 6 deletions components/canvas/DesignCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ export function DesignCanvas({
} catch (err) {
console.error('Failed to parse dropped component:', err);
}
}, [nodes, connections, zoom, panOffset, saveToHistory]);
}, [nodes, connections, zoom, panOffset, saveToHistory, readOnly]);

const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
Expand Down Expand Up @@ -476,7 +476,7 @@ export function DesignCanvas({
x: e.clientX / scale - node.x,
y: e.clientY / scale - node.y,
});
}, [nodes, connections, toolMode, zoom, saveToHistory]);
}, [nodes, connections, toolMode, zoom, saveToHistory, readOnly]);

// Handle completing a connection (mouse up on another node)
const handleNodeMouseUp = useCallback((e: React.MouseEvent, nodeId: string) => {
Expand Down Expand Up @@ -509,7 +509,7 @@ export function DesignCanvas({
setIsDrawingConnection(false);
setConnectionStart(null);
setDraggedNodeId(null);
}, [isDrawingConnection, connectionStart, connections, nodes, draggedNodeId, tempNodes, saveToHistory]);
}, [isDrawingConnection, connectionStart, connections, nodes, draggedNodeId, tempNodes, saveToHistory, readOnly]);

// Handle mouse move
const handleMouseMove = useCallback((e: React.MouseEvent) => {
Expand Down Expand Up @@ -546,7 +546,7 @@ export function DesignCanvas({
) ?? null
);
}
}, [draggedNodeId, dragOffset, isDrawingConnection, isPanning, panStart, toolMode, zoom]);
}, [draggedNodeId, dragOffset, isDrawingConnection, isPanning, panStart, toolMode, zoom, readOnly]);

// Handle mouse up on canvas
const handleMouseUp = useCallback(() => {
Expand Down Expand Up @@ -669,7 +669,7 @@ export function DesignCanvas({
saveToHistory(nodes, newConnections);
setSelectedConnectionId(null);
}
}, [selectedNodeId, selectedConnectionId, nodes, connections, saveToHistory]);
}, [selectedNodeId, selectedConnectionId, nodes, connections, saveToHistory, readOnly]);

// Keyboard shortcuts
useEffect(() => {
Expand Down Expand Up @@ -704,7 +704,7 @@ export function DesignCanvas({

window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedNodeId, selectedConnectionId, handleDeleteSelected, handleUndo, handleRedo]);
}, [selectedNodeId, selectedConnectionId, handleDeleteSelected, handleUndo, handleRedo, readOnly]);

return (
<main
Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useRef, useEffect, useCallback } from 'react';
import { useRef, useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useSidebar } from './SidebarContext';
Expand Down
21 changes: 13 additions & 8 deletions components/dashboard/SidebarContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,16 @@ const SidebarContext = createContext<SidebarContextType>({
});

export function SidebarProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [isOpenState, setIsOpenState] = useState(false);
const [openedByPathname, setOpenedByPathname] = useState<string | null>(null);
const pathname = usePathname();

// Close sidebar on route change
useEffect(() => {
setIsOpen(false);
}, [pathname]);
const isOpen = isOpenState && openedByPathname === pathname;

// Close on Escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsOpen(false);
if (e.key === 'Escape') setIsOpenState(false);
};
if (isOpen) document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
Expand All @@ -46,8 +44,15 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
return (
<SidebarContext.Provider value={{
isOpen,
toggle: () => setIsOpen(prev => !prev),
close: () => setIsOpen(false),
toggle: () => {
if (isOpen) {
setIsOpenState(false);
} else {
setIsOpenState(true);
setOpenedByPathname(pathname);
}
},
close: () => setIsOpenState(false),
}}>
{children}
</SidebarContext.Provider>
Expand Down
2 changes: 1 addition & 1 deletion components/interview/InterviewerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export function InterviewerPanel({ messages, isThinking, onSendReply, isOpen, se
</div>
<p className="text-sm tracking-wide text-slate-400 font-medium mb-1">No messages yet</p>
<p className="text-xs text-slate-500 leading-relaxed">
Start designing your system. I'll check in periodically with hints, questions, and feedback.
Start designing your system. I&apos;ll check in periodically with hints, questions, and feedback.
</p>
</div>
) : (
Expand Down
13 changes: 13 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
version: '3.8'
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
env_file:
- .env
environment:
- NODE_ENV=production
restart: unless-stopped
Loading