Complete Next.js migration with SSR and Docker deployment
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 1m26s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 1m48s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped

- Migrate from vanilla JavaScript SPA to Next.js 16 with App Router
- Add server-side rendering for all pages (Home, Compare, Rankings)
- Create individual school pages with dynamic routing (/school/[urn])
- Implement Chart.js and Leaflet map integrations
- Add comprehensive SEO with sitemap, robots.txt, and JSON-LD
- Set up Docker multi-service architecture (PostgreSQL, FastAPI, Next.js)
- Update CI/CD pipeline to build both backend and frontend images
- Fix Dockerfile to include devDependencies for TypeScript compilation
- Add Jest testing configuration
- Implement performance optimizations (code splitting, caching)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-02-02 20:34:35 +00:00
parent f4919db3b9
commit ff7f5487e6
72 changed files with 18636 additions and 20 deletions

View File

@@ -0,0 +1,87 @@
/**
* Custom hook for managing school comparison state
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import useSWR from 'swr';
import { fetcher } from '@/lib/api';
import { getFromLocalStorage, setToLocalStorage } from '@/lib/utils';
import type { School, ComparisonResponse } from '@/lib/types';
const STORAGE_KEY = 'selectedSchools';
const MAX_SCHOOLS = 5;
export function useComparison() {
const [selectedSchools, setSelectedSchools] = useState<School[]>([]);
const [isInitialized, setIsInitialized] = useState(false);
// Load from localStorage on mount
useEffect(() => {
const stored = getFromLocalStorage<School[]>(STORAGE_KEY, []);
setSelectedSchools(stored);
setIsInitialized(true);
}, []);
// Save to localStorage when schools change
useEffect(() => {
if (isInitialized) {
setToLocalStorage(STORAGE_KEY, selectedSchools);
}
}, [selectedSchools, isInitialized]);
// Fetch comparison data for selected schools
const urns = selectedSchools.map((s) => s.urn).join(',');
const { data, error, isLoading, mutate } = useSWR<ComparisonResponse>(
selectedSchools.length > 0 ? `/compare?urns=${urns}` : null,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 10000, // 10 seconds
}
);
const addSchool = useCallback((school: School) => {
setSelectedSchools((prev) => {
// Check if already selected
if (prev.some((s) => s.urn === school.urn)) {
return prev;
}
// Check max limit
if (prev.length >= MAX_SCHOOLS) {
alert(`Maximum ${MAX_SCHOOLS} schools can be compared`);
return prev;
}
return [...prev, school];
});
}, []);
const removeSchool = useCallback((urn: number) => {
setSelectedSchools((prev) => prev.filter((s) => s.urn !== urn));
}, []);
const clearAll = useCallback(() => {
setSelectedSchools([]);
}, []);
const isSelected = useCallback(
(urn: number) => selectedSchools.some((s) => s.urn === urn),
[selectedSchools]
);
return {
selectedSchools,
comparisonData: data?.comparison,
isLoading,
error,
addSchool,
removeSchool,
clearAll,
isSelected,
canAddMore: selectedSchools.length < MAX_SCHOOLS,
mutate,
};
}

View File

@@ -0,0 +1,29 @@
/**
* Custom hook for fetching filter options with SWR
*/
'use client';
import useSWR from 'swr';
import { fetcher } from '@/lib/api';
import type { FiltersResponse } from '@/lib/types';
export function useFilters() {
const { data, error, isLoading } = useSWR<FiltersResponse>(
'/filters',
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 60000, // 1 minute
}
);
return {
filters: data?.filters,
localAuthorities: data?.filters.local_authorities || [],
schoolTypes: data?.filters.school_types || [],
years: data?.filters.years || [],
isLoading,
error,
};
}

View File

@@ -0,0 +1,28 @@
/**
* Custom hook for fetching metric definitions with SWR
*/
'use client';
import useSWR from 'swr';
import { fetcher } from '@/lib/api';
import type { MetricsResponse } from '@/lib/types';
export function useMetrics() {
const { data, error, isLoading } = useSWR<MetricsResponse>(
'/metrics',
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 60000, // 1 minute
}
);
return {
metrics: data?.metrics || {},
metricsList: data?.metrics ? Object.values(data.metrics) : [],
getMetric: (key: string) => data?.metrics?.[key],
isLoading,
error,
};
}

View File

@@ -0,0 +1,28 @@
/**
* Custom hook for fetching school details with SWR
*/
'use client';
import useSWR from 'swr';
import { fetcher } from '@/lib/api';
import type { SchoolDetailsResponse } from '@/lib/types';
export function useSchoolDetails(urn: number | null) {
const { data, error, isLoading, mutate } = useSWR<SchoolDetailsResponse>(
urn ? `/schools/${urn}` : null,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 30000, // 30 seconds
}
);
return {
schoolInfo: data?.school_info,
yearlyData: data?.yearly_data || [],
isLoading,
error,
mutate,
};
}

View File

@@ -0,0 +1,46 @@
/**
* Custom hook for fetching schools with SWR
*/
'use client';
import useSWR from 'swr';
import { fetcher } from '@/lib/api';
import type { SchoolsResponse, SchoolSearchParams } from '@/lib/types';
export function useSchools(params: SchoolSearchParams = {}, shouldFetch: boolean = true) {
const queryParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.set(key, String(value));
}
});
const queryString = queryParams.toString();
const url = `/schools${queryString ? `?${queryString}` : ''}`;
const { data, error, isLoading, mutate } = useSWR<SchoolsResponse>(
shouldFetch ? url : null,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 5000, // 5 seconds
}
);
return {
schools: data?.schools || [],
pagination: data ? {
page: data.page,
page_size: data.page_size,
total: data.total,
total_pages: data.total_pages,
} : null,
searchMode: data?.search_mode,
locationInfo: data?.location_info,
isLoading,
error,
mutate,
};
}