From fb1becc24451822de8f8286af0729d1143b3ffa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Tue, 21 Oct 2025 11:39:08 +0200 Subject: [PATCH 1/7] feat: vast majority of light/dark theme work --- app/exam/page.tsx | 16 +- app/layout.tsx | 29 +- app/modes/page.tsx | 8 +- app/not-found.tsx | 45 ++++ app/page.tsx | 14 +- app/practice/page.tsx | 17 +- components/Button.tsx | 15 +- components/Cookie.tsx | 16 +- components/ExamLink.tsx | 23 +- components/ExamResult.tsx | 6 +- components/Footer.tsx | 26 +- components/Header.tsx | 47 ++-- components/LoadingIndicator.tsx | 13 +- components/NameLink.tsx | 21 +- components/NumberInputComponent.tsx | 2 +- components/ParticlesFooter.tsx | 147 ++++++++++ components/QuizExamForm.tsx | 2 +- components/QuizExamFormUF.tsx | 26 +- components/QuizForm.tsx | 73 ++++- components/SelectionInput.tsx | 10 +- components/ThemeSwitch.tsx | 70 +++++ contexts/ThemeContext.tsx | 59 ++++ package-lock.json | 25 +- package.json | 4 +- public/logoBlack.svg | 61 +++++ public/{logo.svg => logoWhite.svg} | 0 styles/globals.css | 404 +++++++++++++++++++++++++++- tailwind.config.js | 34 ++- 28 files changed, 1089 insertions(+), 124 deletions(-) create mode 100644 app/not-found.tsx create mode 100644 components/ParticlesFooter.tsx create mode 100644 components/ThemeSwitch.tsx create mode 100644 contexts/ThemeContext.tsx create mode 100644 public/logoBlack.svg rename public/{logo.svg => logoWhite.svg} (100%) diff --git a/app/exam/page.tsx b/app/exam/page.tsx index a788375..4844acf 100644 --- a/app/exam/page.tsx +++ b/app/exam/page.tsx @@ -97,8 +97,8 @@ const Exam: NextPage<{ searchParams: { url: string; name: string } }> = ({ // Block access if trial expired if (isAccessBlocked) { return ( -
-
+
+
⏰ Trial expired. Please sign in to continue taking exams.
{showUserMenu && ( -
+
-
+
Signed in as
-
+
{user.email}
@@ -159,10 +162,13 @@ const Header = () => { )} + {/* Theme Switch - Rightmost position */} + + {/* Mobile menu button */}
)} diff --git a/components/QuizExamFormUF.tsx b/components/QuizExamFormUF.tsx index 854e36c..87f31dc 100644 --- a/components/QuizExamFormUF.tsx +++ b/components/QuizExamFormUF.tsx @@ -176,7 +176,7 @@ const QuizExamForm: FC = ({
-

+

{currentQuestionIndex + 1}. {question}

@@ -185,7 +185,7 @@ const QuizExamForm: FC = ({ {images.map((image) => (
  • setSelectedImage(image)} > = ({ /> @@ -296,7 +296,7 @@ const QuizExamForm: FC = ({
    )} diff --git a/components/QuizForm.tsx b/components/QuizForm.tsx index 82eaedf..fefb375 100644 --- a/components/QuizForm.tsx +++ b/components/QuizForm.tsx @@ -125,7 +125,55 @@ const QuizForm: FC = ({ if (isLoading) return ; if (!questionSet) { - handleNextQuestion(1); + // Check if we're trying to load a question beyond the available range + if (currentQuestionIndex > totalQuestions) { + return ( +
    +
    +
    +
    + + + +
    +

    + 🎉 Practice Complete! +

    +

    + You've completed all {totalQuestions} questions in this practice + session. +

    +
    + +
    + + +
    +
    +
    + ); + } + return (

    @@ -134,6 +182,9 @@ const QuizForm: FC = ({

    Please try refreshing the page or check your internet connection.

    +

    + Debug: Question {currentQuestionIndex} of {totalQuestions} +

    ); } @@ -176,7 +227,7 @@ const QuizForm: FC = ({ viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" - className="w-6 h-6 text-slate-300 group-disabled:text-transparent" + className="w-6 h-6 text-gray-500 dark:text-slate-300 group-disabled:text-transparent" > = ({
    - + Q&A = ({ currentQuestionIndex={currentQuestionIndex} handleNextQuestion={handleNextQuestion} /> -

    +

    {totalQuestions}

    @@ -218,7 +269,7 @@ const QuizForm: FC = ({ viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" - className="w-6 h-6 text-slate-300 group-disabled:text-transparent" + className="w-6 h-6 text-gray-500 dark:text-slate-300 group-disabled:text-transparent" > = ({
  • -

    {question}

    +

    + {question} +

    {images && (
      {images.map((image) => (
    • setSelectedImage(image)} > = ({ } setShowCorrectAnswer(false); setExplanation(null); - if (currentQuestionIndex === totalQuestions) { - handleNextQuestion(1); - setLastIndex(1); - } else { + // Only navigate if we're not on the last question + if (currentQuestionIndex < totalQuestions) { handleNextQuestion(currentQuestionIndex + 1); setLastIndex(currentQuestionIndex + 1); } diff --git a/components/SelectionInput.tsx b/components/SelectionInput.tsx index b26f32f..fae18f4 100644 --- a/components/SelectionInput.tsx +++ b/components/SelectionInput.tsx @@ -40,14 +40,14 @@ const SelectionInput = forwardRef( /> diff --git a/components/ThemeSwitch.tsx b/components/ThemeSwitch.tsx new file mode 100644 index 0000000..0efd230 --- /dev/null +++ b/components/ThemeSwitch.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useTheme } from "@azure-fundamentals/contexts/ThemeContext"; +import "@theme-toggles/react/css/Lightbulb.css"; +import { Lightbulb } from "@theme-toggles/react"; +import { useRef, useEffect } from "react"; + +export default function ThemeSwitch() { + const { theme, setTheme } = useTheme(); + const containerRef = useRef(null); + + const handleToggle = () => { + setTheme(theme === "light" ? "dark" : "light"); + // Reset hover state after theme change + setTimeout(() => { + if (containerRef.current) { + const lightbulb = containerRef.current.querySelector("svg"); + if (lightbulb) { + lightbulb.style.color = theme === "light" ? "#9ca3af" : "#6b7280"; + } + } + }, 50); + }; + + const handleMouseEnter = (e: React.MouseEvent) => { + const lightbulb = e.currentTarget.querySelector("svg"); + if (lightbulb) { + lightbulb.style.color = theme === "dark" ? "#f3f4f6" : "#111827"; + } + }; + + const handleMouseLeave = (e: React.MouseEvent) => { + const lightbulb = e.currentTarget.querySelector("svg"); + if (lightbulb) { + lightbulb.style.color = theme === "dark" ? "#9ca3af" : "#6b7280"; + } + }; + + // Reset color when theme changes + useEffect(() => { + if (containerRef.current) { + const lightbulb = containerRef.current.querySelector("svg"); + if (lightbulb) { + lightbulb.style.color = theme === "dark" ? "#9ca3af" : "#6b7280"; + } + } + }, [theme]); + + return ( +
      + +
      + ); +} diff --git a/contexts/ThemeContext.tsx b/contexts/ThemeContext.tsx new file mode 100644 index 0000000..8d7299b --- /dev/null +++ b/contexts/ThemeContext.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React, { createContext, useContext, useEffect, useState } from "react"; + +type Theme = "light" | "dark"; + +interface ThemeContextType { + theme: Theme; + setTheme: (theme: Theme) => void; +} + +const ThemeContext = createContext(undefined); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useState("dark"); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + // Get theme from localStorage or default to dark + const savedTheme = localStorage.getItem("theme") as Theme; + if (savedTheme) { + setTheme(savedTheme); + } + }, []); + + useEffect(() => { + if (mounted) { + localStorage.setItem("theme", theme); + + // Update document class and CSS variables + if (theme === "dark") { + document.documentElement.classList.add("dark"); + document.documentElement.classList.remove("light"); + } else { + document.documentElement.classList.add("light"); + document.documentElement.classList.remove("dark"); + } + } + }, [theme, mounted]); + + if (!mounted) { + return null; + } + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/package-lock.json b/package-lock.json index c3435c8..4e87111 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "practice-exams-platform", - "version": "1.4.4", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "practice-exams-platform", - "version": "1.4.4", + "version": "1.5.0", "dependencies": { "@apollo/client": "^3.7.9", "@apollo/server": "^4.11.0", "@as-integrations/next": "^3.0.0", "@azure/cosmos": "^3.17.2", "@next/third-parties": "^14.1.3", + "@theme-toggles/react": "^4.1.0", "@types/node": "18.13.0", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", @@ -28,6 +29,7 @@ "next": "^14.2.26", "next-pwa": "^5.6.0", "node-appwrite": "^19.0.0", + "particles.js": "^2.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-github-btn": "^1.4.0", @@ -3782,6 +3784,20 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@theme-toggles/react": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@theme-toggles/react/-/react-4.1.0.tgz", + "integrity": "sha512-h3SuJMsej8DfelHt5fjNIlaMfJOK52Vku4pPDVoHaTwjAcoTr4fn8hzeur2oiqWBYFYfKugvv1RdQaBFXaiPKg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/alfiejones" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "react-dom": "^16 || ^17 || ^18" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -10826,6 +10842,11 @@ "node": ">= 0.8" } }, + "node_modules/particles.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/particles.js/-/particles.js-2.0.0.tgz", + "integrity": "sha512-8e0JIqkRbMMPlFBnF9f+92hX1s07jdkd3tqB8uHE9L+cwGGjIYjQM7QLgt0FQ5MZp6SFFYYDm/Y48pqK3ZvJOQ==" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", diff --git a/package.json b/package.json index 22c7789..d9c2e85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "practice-exams-platform", - "version": "1.4.4", + "version": "1.5.0", "private": true, "engines": { "node": "20.x" @@ -23,6 +23,7 @@ "@as-integrations/next": "^3.0.0", "@azure/cosmos": "^3.17.2", "@next/third-parties": "^14.1.3", + "@theme-toggles/react": "^4.1.0", "@types/node": "18.13.0", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", @@ -38,6 +39,7 @@ "next": "^14.2.26", "next-pwa": "^5.6.0", "node-appwrite": "^19.0.0", + "particles.js": "^2.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-github-btn": "^1.4.0", diff --git a/public/logoBlack.svg b/public/logoBlack.svg new file mode 100644 index 0000000..4bae0fd --- /dev/null +++ b/public/logoBlack.svg @@ -0,0 +1,61 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logo.svg b/public/logoWhite.svg similarity index 100% rename from public/logo.svg rename to public/logoWhite.svg diff --git a/styles/globals.css b/styles/globals.css index b3e1e41..0b6c443 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -1,6 +1,34 @@ @import 'tailwindcss/base'; @import 'tailwindcss/components'; @import 'tailwindcss/utilities'; +@import '@theme-toggles/react/css/Around.css'; + +/* CSS Variables for theming - matching blog exactly */ +:root { + /* Light theme colors using hex values matching blog */ + --color-primary: #3f51b5; + --color-primary-hover: #3949ab; + --color-background: #fafafa; /* gray-50 */ + --color-surface: #f5f5f5; /* gray-100 */ + --color-text-primary: #171717; /* gray-900 */ + --color-text-secondary: #737373; /* gray-500 */ + --color-border: #e5e5e5; /* gray-200 */ + --color-border-hover: #d4d4d4; /* gray-300 */ +} + +.dark { + /* Dark theme colors using hex values matching blog */ + --color-primary: #3f51b5; + --color-primary-hover: #a5b4fc; + --color-background: #0a0a0a; /* gray-950 */ + --color-surface: #171717; /* gray-900 */ + --color-text-primary: #f5f5f5; /* gray-100 */ + --color-text-secondary: #a3a3a3; /* gray-400 */ + --color-border: #262626; /* gray-800 */ + --color-border-hover: #404040; /* gray-700 */ +} + +/* Theme toggle styling - using @theme-toggles/react package */ /* External link styling */ .external-link-icon { @@ -8,25 +36,25 @@ margin-left: 0.25rem; width: 0.75rem; height: 0.75rem; - color: #3f51b5; + color: var(--color-primary); transition: color 200ms ease-in-out; } /* Enhanced external link hover effects */ a[target="_blank"]:hover .external-link-icon { - color: #5c6bc0; + color: var(--color-primary-hover); } /* Primary button styling */ .btn-primary { - background-color: #3f51b5; - border-color: #3f51b5; + background-color: var(--color-primary); + border-color: var(--color-primary); transition: background-color 200ms ease-in-out; } .btn-primary:hover { - background-color: #5c6bc0; - border-color: #5c6bc0; + background-color: var(--color-primary-hover); + border-color: var(--color-primary-hover); } .loading-container { @@ -39,6 +67,7 @@ a[target="_blank"]:hover .external-link-icon { justify-content: center; align-items: center; z-index: 9999; + background-color: rgba(0, 0, 0, 0.1); } .spinner { @@ -66,3 +95,366 @@ a[target="_blank"]:hover .external-link-icon { transform: rotate(360deg); } } + +/* Book Loading Animation */ +.loading-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; + background-color: rgba(0, 0, 0, 0.2); + backdrop-filter: blur(4px); +} + +.book { + --color: var(--color-text-primary); + --duration: 6.8s; + width: 32px; + height: 12px; + position: relative; + margin: 32px 0 0 0; + zoom: 1.5; +} + +.book .inner { + width: 32px; + height: 12px; + position: relative; + transform-origin: 2px 2px; + transform: rotateZ(-90deg); + animation: book var(--duration) ease infinite; +} + +.book .inner .left, +.book .inner .right { + width: 60px; + height: 4px; + top: 0; + border-radius: 2px; + background: var(--color); + position: absolute; +} + +.book .inner .left:before, +.book .inner .right:before { + content: ''; + width: 48px; + height: 4px; + border-radius: 2px; + background: inherit; + position: absolute; + top: -10px; + left: 6px; +} + +.book .inner .left { + right: 28px; + transform-origin: 58px 2px; + transform: rotateZ(90deg); + animation: left var(--duration) ease infinite; +} + +.book .inner .right { + left: 28px; + transform-origin: 2px 2px; + transform: rotateZ(-90deg); + animation: right var(--duration) ease infinite; +} + +.book .inner .middle { + width: 32px; + height: 12px; + border: 4px solid var(--color); + border-top: 0; + border-radius: 0 0 9px 9px; + transform: translateY(2px); +} + +.book ul { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + left: 50%; + top: 0; +} + +.book ul li { + height: 4px; + border-radius: 2px; + transform-origin: 100% 2px; + width: 48px; + right: 0; + top: -10px; + position: absolute; + background: var(--color); + transform: rotateZ(0deg) translateX(-18px); + animation-duration: var(--duration); + animation-timing-function: ease; + animation-iteration-count: infinite; +} + +/* Individual page animations */ +.book ul li:nth-child(1) { animation-name: page-1; } +.book ul li:nth-child(2) { animation-name: page-2; } +.book ul li:nth-child(3) { animation-name: page-3; } +.book ul li:nth-child(4) { animation-name: page-4; } +.book ul li:nth-child(5) { animation-name: page-5; } +.book ul li:nth-child(6) { animation-name: page-6; } +.book ul li:nth-child(7) { animation-name: page-7; } +.book ul li:nth-child(8) { animation-name: page-8; } +.book ul li:nth-child(9) { animation-name: page-9; } +.book ul li:nth-child(10) { animation-name: page-10; } +.book ul li:nth-child(11) { animation-name: page-11; } +.book ul li:nth-child(12) { animation-name: page-12; } +.book ul li:nth-child(13) { animation-name: page-13; } +.book ul li:nth-child(14) { animation-name: page-14; } +.book ul li:nth-child(15) { animation-name: page-15; } +.book ul li:nth-child(16) { animation-name: page-16; } +.book ul li:nth-child(17) { animation-name: page-17; } +.book ul li:nth-child(18) { animation-name: page-18; } + +/* Page animations */ +@keyframes page-1 { 4% { transform: rotateZ(0deg) translateX(-18px); } 13%, 54% { transform: rotateZ(180deg) translateX(-18px); } 63% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-2 { 5.86% { transform: rotateZ(0deg) translateX(-18px); } 14.74%, 55.86% { transform: rotateZ(180deg) translateX(-18px); } 64.74% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-3 { 7.72% { transform: rotateZ(0deg) translateX(-18px); } 16.48%, 57.72% { transform: rotateZ(180deg) translateX(-18px); } 66.48% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-4 { 9.58% { transform: rotateZ(0deg) translateX(-18px); } 18.22%, 59.58% { transform: rotateZ(180deg) translateX(-18px); } 68.22% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-5 { 11.44% { transform: rotateZ(0deg) translateX(-18px); } 19.96%, 61.44% { transform: rotateZ(180deg) translateX(-18px); } 69.96% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-6 { 13.3% { transform: rotateZ(0deg) translateX(-18px); } 21.7%, 63.3% { transform: rotateZ(180deg) translateX(-18px); } 71.7% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-7 { 15.16% { transform: rotateZ(0deg) translateX(-18px); } 23.44%, 65.16% { transform: rotateZ(180deg) translateX(-18px); } 73.44% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-8 { 17.02% { transform: rotateZ(0deg) translateX(-18px); } 25.18%, 67.02% { transform: rotateZ(180deg) translateX(-18px); } 75.18% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-9 { 18.88% { transform: rotateZ(0deg) translateX(-18px); } 26.92%, 68.88% { transform: rotateZ(180deg) translateX(-18px); } 76.92% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-10 { 20.74% { transform: rotateZ(0deg) translateX(-18px); } 28.66%, 70.74% { transform: rotateZ(180deg) translateX(-18px); } 78.66% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-11 { 22.6% { transform: rotateZ(0deg) translateX(-18px); } 30.4%, 72.6% { transform: rotateZ(180deg) translateX(-18px); } 80.4% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-12 { 24.46% { transform: rotateZ(0deg) translateX(-18px); } 32.14%, 74.46% { transform: rotateZ(180deg) translateX(-18px); } 82.14% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-13 { 26.32% { transform: rotateZ(0deg) translateX(-18px); } 33.88%, 76.32% { transform: rotateZ(180deg) translateX(-18px); } 83.88% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-14 { 28.18% { transform: rotateZ(0deg) translateX(-18px); } 35.62%, 78.18% { transform: rotateZ(180deg) translateX(-18px); } 85.62% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-15 { 30.04% { transform: rotateZ(0deg) translateX(-18px); } 37.36%, 80.04% { transform: rotateZ(180deg) translateX(-18px); } 87.36% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-16 { 31.9% { transform: rotateZ(0deg) translateX(-18px); } 39.1%, 81.9% { transform: rotateZ(180deg) translateX(-18px); } 89.1% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-17 { 33.76% { transform: rotateZ(0deg) translateX(-18px); } 40.84%, 83.76% { transform: rotateZ(180deg) translateX(-18px); } 90.84% { transform: rotateZ(0deg) translateX(-18px); } } +@keyframes page-18 { 35.62% { transform: rotateZ(0deg) translateX(-18px); } 42.58%, 85.62% { transform: rotateZ(180deg) translateX(-18px); } 92.58% { transform: rotateZ(0deg) translateX(-18px); } } + +@keyframes left { + 4% { transform: rotateZ(90deg); } + 10%, 40% { transform: rotateZ(0deg); } + 46%, 54% { transform: rotateZ(90deg); } + 60%, 90% { transform: rotateZ(0deg); } + 96% { transform: rotateZ(90deg); } +} + +@keyframes right { + 4% { transform: rotateZ(-90deg); } + 10%, 40% { transform: rotateZ(0deg); } + 46%, 54% { transform: rotateZ(-90deg); } + 60%, 90% { transform: rotateZ(0deg); } + 96% { transform: rotateZ(-90deg); } +} + +@keyframes book { + 4% { transform: rotateZ(-90deg); } + 10%, 40% { transform: rotateZ(0deg); transform-origin: 2px 2px; } + 40.01%, 59.99% { transform-origin: 30px 2px; } + 46%, 54% { transform: rotateZ(90deg); } + 60%, 90% { transform: rotateZ(0deg); transform-origin: 2px 2px; } + 96% { transform: rotateZ(-90deg); } +} + +/* 404 Page Gradient Numbers */ +.error-container { + text-align: center; + font-size: 106px; + font-weight: 800; + margin: 70px 15px; + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 20px; +} + +.error-container > span { + display: inline-block; + position: relative; +} + +.error-container > span.four { + width: 136px; + height: 43px; + border-radius: 999px; + background: + linear-gradient(140deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 43%, transparent 44%, transparent 100%), + linear-gradient(105deg, transparent 0%, transparent 40%, rgba(0, 0, 0, 0.06) 41%, rgba(0, 0, 0, 0.07) 76%, transparent 77%, transparent 100%), + linear-gradient(to right, #3f51b5, #5c6bc0); +} + +.error-container > span.four:before, +.error-container > span.four:after { + content: ''; + display: block; + position: absolute; + border-radius: 999px; +} + +.error-container > span.four:before { + width: 43px; + height: 156px; + left: 60px; + bottom: -43px; + background: + linear-gradient(128deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 40%, transparent 41%, transparent 100%), + linear-gradient(116deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 50%, transparent 51%, transparent 100%), + linear-gradient(to top, #1e3a8a, #3b82f6, #60a5fa, #93c5fd, #dbeafe); +} + +.error-container > span.four:after { + width: 137px; + height: 43px; + transform: rotate(-49.5deg); + left: -18px; + bottom: 36px; + background: linear-gradient(to right, #1e3a8a, #3b82f6, #60a5fa, #93c5fd, #dbeafe); +} + +.error-container > span.zero { + vertical-align: text-top; + width: 156px; + height: 156px; + border-radius: 999px; + background: linear-gradient(-45deg, transparent 0%, rgba(0, 0, 0, 0.06) 50%, transparent 51%, transparent 100%), + linear-gradient(to top right, #1e3a8a, #1e3a8a, #3b82f6, #60a5fa, #93c5fd, #dbeafe, #dbeafe); + overflow: hidden; + animation: bgshadow 5s infinite; +} + +.error-container > span.zero:before { + content: ''; + display: block; + position: absolute; + transform: rotate(45deg); + width: 90px; + height: 90px; + background-color: transparent; + left: 0px; + bottom: 0px; + background: + linear-gradient(95deg, transparent 0%, transparent 8%, rgba(0, 0, 0, 0.07) 9%, transparent 50%, transparent 100%), + linear-gradient(85deg, transparent 0%, transparent 19%, rgba(0, 0, 0, 0.05) 20%, rgba(0, 0, 0, 0.07) 91%, transparent 92%, transparent 100%); +} + +.error-container > span.zero:after { + content: ''; + display: block; + position: absolute; + border-radius: 999px; + width: 70px; + height: 70px; + left: 43px; + bottom: 43px; + background: var(--color-background); + box-shadow: -2px 2px 2px 0px rgba(0, 0, 0, 0.1); +} + +/* Mobile responsive 404 layout */ +@media (max-width: 640px) { + .error-container { + flex-direction: column; + font-size: 80px; + margin: 40px 10px; + gap: 15px; + } + + .error-container > span.four { + width: 100px; + height: 32px; + } + + .error-container > span.four:before { + width: 32px; + height: 115px; + left: 45px; + bottom: -32px; + } + + .error-container > span.four:after { + width: 100px; + height: 32px; + left: -13px; + bottom: 27px; + } + + .error-container > span.zero { + width: 115px; + height: 115px; + } + + .error-container > span.zero:before { + width: 65px; + height: 65px; + } + + .error-container > span.zero:after { + width: 50px; + height: 50px; + left: 32px; + bottom: 32px; + } +} + +.screen-reader-text { + position: absolute; + top: -9999em; + left: -9999em; +} + +@keyframes bgshadow { + 0% { + box-shadow: inset -160px 160px 0px 5px rgba(0, 0, 0, 0.4); + } + 45% { + box-shadow: inset 0px 0px 0px 0px rgba(0, 0, 0, 0.1); + } + 55% { + box-shadow: inset 0px 0px 0px 0px rgba(0, 0, 0, 0.1); + } + 100% { + box-shadow: inset 160px -160px 0px 5px rgba(0, 0, 0, 0.4); + } +} + +.zoom-area { + max-width: 490px; + margin: 30px auto 30px; + font-size: 19px; + text-align: center; +} + +.link-container { + text-align: center; +} + +a.more-link { + text-transform: uppercase; + font-size: 13px; + background-color: #3f51b5; + padding: 10px 15px; + border-radius: 0; + color: #fff; + display: inline-block; + margin-right: 5px; + margin-bottom: 5px; + line-height: 1.5; + text-decoration: none; + margin-top: 50px; + letter-spacing: 1px; + transition: background-color 0.3s ease; +} + +a.more-link:hover { + background-color: #3949ab; +} + diff --git a/tailwind.config.js b/tailwind.config.js index 021a414..79f8258 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,8 +8,40 @@ module.exports = { // Or if using `src` directory: "./src/**/*.{js,ts,jsx,tsx}", ], + darkMode: "class", theme: { - extend: {}, + extend: { + colors: { + // Brand color #3f51b5 with hex equivalents (matching blog colors) + primary: { + 50: "#f8f9ff", + 100: "#f0f2ff", + 200: "#e0e7ff", + 300: "#c7d2fe", + 400: "#a5b4fc", + 500: "#3f51b5", + 600: "#3949ab", + 700: "#303f9f", + 800: "#283593", + 900: "#1a237e", + 950: "#0f0f23", + }, + // Gray scale with hex equivalents matching blog exactly + gray: { + 50: "#fafafa", + 100: "#f5f5f5", + 200: "#e5e5e5", + 300: "#d4d4d4", + 400: "#a3a3a3", + 500: "#737373", + 600: "#525252", + 700: "#404040", + 800: "#262626", + 900: "#171717", + 950: "#0a0a0a", + }, + }, + }, }, plugins: [], }; From df22b930889f7fed7d90e423b9df9269b2e03bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Wed, 22 Oct 2025 07:36:26 +0200 Subject: [PATCH 2/7] fix: build errors --- components/ParticlesFooter.tsx | 198 +++++++++++++++++---------------- components/QuizExamForm.tsx | 5 +- components/QuizExamFormUF.tsx | 5 +- components/QuizForm.tsx | 9 +- public/sw.js | 102 +---------------- 5 files changed, 115 insertions(+), 204 deletions(-) diff --git a/components/ParticlesFooter.tsx b/components/ParticlesFooter.tsx index a1930fe..d865bd1 100644 --- a/components/ParticlesFooter.tsx +++ b/components/ParticlesFooter.tsx @@ -13,116 +13,117 @@ export default function ParticlesFooter() { const particlesRef = useRef(null); const { theme } = useTheme(); - useEffect(() => { - const loadParticles = async () => { - // Load particles.js script - if (!window.particlesJS) { - const script = document.createElement("script"); - script.src = - "https://cdn.jsdelivr.net/particles.js/2.0.0/particles.min.js"; - script.onload = () => { - initializeParticles(); - }; - document.head.appendChild(script); - } else { - initializeParticles(); - } - }; - - const initializeParticles = () => { - if (particlesRef.current && window.particlesJS) { - window.particlesJS("particles-footer", { - particles: { - number: { - value: 70, - density: { - enable: true, - value_area: 1400, - }, + const initializeParticles = () => { + const particlesElement = particlesRef.current; + if (particlesElement && window.particlesJS) { + window.particlesJS("particles-footer", { + particles: { + number: { + value: 70, + density: { + enable: true, + value_area: 1400, }, - color: { - value: "#3f51b5", + }, + color: { + value: "#3f51b5", + }, + shape: { + type: "polygon", + stroke: { + width: 1, + color: "#3f51b5", }, - shape: { - type: "polygon", - stroke: { - width: 1, - color: "#3f51b5", - }, - polygon: { - nb_sides: 6, - }, + polygon: { + nb_sides: 6, }, - opacity: { - value: 1, - random: true, - anim: { - enable: true, - speed: 0.8, - opacity_min: 0.25, - sync: true, - }, + }, + opacity: { + value: 1, + random: true, + anim: { + enable: true, + speed: 0.8, + opacity_min: 0.25, + sync: true, }, - size: { - value: 2, - random: true, - anim: { - enable: true, - speed: 10, - size_min: 1.25, - sync: true, - }, + }, + size: { + value: 2, + random: true, + anim: { + enable: true, + speed: 10, + size_min: 1.25, + sync: true, }, - line_linked: { + }, + line_linked: { + enable: true, + distance: 150, + color: "#3f51b5", + opacity: 1, + width: 1, + }, + move: { + enable: true, + speed: 8, + direction: "none", + random: true, + straight: false, + out_mode: "out", + bounce: true, + attract: { enable: true, - distance: 150, - color: "#3f51b5", - opacity: 1, - width: 1, + rotateX: 2000, + rotateY: 2000, + }, + }, + }, + interactivity: { + detect_on: "canvas", + events: { + onhover: { + enable: true, + mode: "grab", }, - move: { + onclick: { enable: true, - speed: 8, - direction: "none", - random: true, - straight: false, - out_mode: "out", - bounce: true, - attract: { - enable: true, - rotateX: 2000, - rotateY: 2000, - }, + mode: "repulse", }, + resize: true, }, - interactivity: { - detect_on: "canvas", - events: { - onhover: { - enable: true, - mode: "grab", - }, - onclick: { - enable: true, - mode: "repulse", + modes: { + grab: { + distance: 200, + line_linked: { + opacity: 3, }, - resize: true, }, - modes: { - grab: { - distance: 200, - line_linked: { - opacity: 3, - }, - }, - repulse: { - distance: 250, - duration: 2, - }, + repulse: { + distance: 250, + duration: 2, }, }, - retina_detect: true, - }); + }, + retina_detect: true, + }); + } + }; + + useEffect(() => { + const loadParticles = async () => { + // Load particles.js script + if (!window.particlesJS) { + const script = document.createElement("script"); + script.src = + "https://cdn.jsdelivr.net/particles.js/2.0.0/particles.min.js"; + script.onload = () => { + initializeParticles(); + }; + document.head.appendChild(script); + } else { + initializeParticles(); } }; @@ -130,8 +131,9 @@ export default function ParticlesFooter() { // Cleanup return () => { - if (particlesRef.current) { - particlesRef.current.innerHTML = ""; + const particlesElement = particlesRef.current; + if (particlesElement) { + particlesElement.innerHTML = ""; } }; }, [theme]); diff --git a/components/QuizExamForm.tsx b/components/QuizExamForm.tsx index 1d7088d..ab06f54 100644 --- a/components/QuizExamForm.tsx +++ b/components/QuizExamForm.tsx @@ -221,10 +221,13 @@ const QuizExamForm: FC = ({ )} {selectedImage && (
      - {selectedImage.alt}
      @@ -307,10 +307,13 @@ const QuizForm: FC = ({ onClick={handleClickOutside} className="fixed top-0 left-0 z-50 w-full h-full flex justify-center items-center bg-black bg-opacity-50" > - {selectedImage.alt} + + 📚 Go to Home + +
    + + {/* Help text */} +

    + If this problem persists, please{" "} + + report it on GitHub + + . +

    +
    + ); +} diff --git a/app/exam/page.tsx b/app/exam/page.tsx index b03ac76..b515713 100644 --- a/app/exam/page.tsx +++ b/app/exam/page.tsx @@ -27,11 +27,12 @@ const questionsQuery = gql` } `; -const Exam: NextPage<{ searchParams: { url: string; name: string } }> = ({ - searchParams, -}) => { +const Exam: NextPage = () => { const { isAccessBlocked, isInTrial } = useTrialAccess(); - const { url } = searchParams; + const [searchParams, setSearchParams] = useState( + null, + ); + const url = searchParams?.get("url") || ""; const { minutes, seconds } = { minutes: 15, seconds: 0, @@ -45,16 +46,23 @@ const Exam: NextPage<{ searchParams: { url: string; name: string } }> = ({ const [countAnswered, setCountAnswered] = useState(0); const { data, loading, error } = useQuery(questionsQuery, { variables: { range: 30, link: url }, + skip: !url, // Skip query if URL is not available }); const [resultPoints, setResultPoints] = useState(0); const [passed, setPassed] = useState(false); const [windowWidth, setWindowWidth] = useState(0); - const editedUrl = url.substring(0, url.lastIndexOf("/") + 1); + const editedUrl = + url && url.includes("/") ? url.substring(0, url.lastIndexOf("/") + 1) : ""; const elapsedSeconds = totalTimeInSeconds - (parseInt(remainingTime.split(":")[0]) * 60 + parseInt(remainingTime.split(":")[1])); + useEffect(() => { + const param = new URLSearchParams(window.location.search); + setSearchParams(param); + }, []); + const handleCountAnswered = () => { setCountAnswered(countAnswered + 1); }; @@ -89,30 +97,92 @@ const Exam: NextPage<{ searchParams: { url: string; name: string } }> = ({ setCurrentQuestion(data?.randomQuestions[0]); }, [data]); - // Show loading while checking trial access - if (isAccessBlocked === undefined) { + // Show loading while checking trial access or waiting for URL + if (isAccessBlocked === undefined || !searchParams) { return ; } + // Check if URL is missing + if (!url) { + return ( +
    +
    +
    + ⚠️ Exam URL is missing. Please select an exam from the home page. +
    + +
    +
    + ); + } + // Block access if trial expired if (isAccessBlocked) { return ( -
    -
    - ⏰ Trial expired. Please sign in to continue taking exams. +
    +
    +
    + ⏰ Trial expired. Please sign in to continue taking exams. +
    +
    -
    ); } if (loading) return ; - if (error) return

    Oh no... {error.message}

    ; + if (error) { + return ( +
    +
    +
    + ⚠️ Error loading exam questions +
    +

    + {error.message} +

    + +
    +
    + ); + } + + if (!data?.randomQuestions || data.randomQuestions.length === 0) { + return ( +
    +
    +
    + ⚠️ No questions found for this exam +
    +

    + The exam questions could not be loaded. Please try again later or + select a different exam. +

    + +
    +
    + ); + } const numberOfQuestions = data.randomQuestions.length || 0; diff --git a/app/modes/page.tsx b/app/modes/page.tsx index f436a64..fd66638 100644 --- a/app/modes/page.tsx +++ b/app/modes/page.tsx @@ -1,15 +1,55 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import type { NextPage } from "next"; import ExamLink from "@azure-fundamentals/components/ExamLink"; -const Modes: NextPage<{ searchParams: { url: string; name: string } }> = ({ - searchParams, -}) => { - const { url, name } = searchParams; +const Modes: NextPage = () => { + const [searchParams, setSearchParams] = useState( + null, + ); + const url = searchParams?.get("url") || ""; + const name = searchParams?.get("name") || ""; const [hoveredCard, setHoveredCard] = useState(null); + useEffect(() => { + const param = new URLSearchParams(window.location.search); + setSearchParams(param); + }, []); + + // Show loading while waiting for URL + if (!searchParams) { + return ( +
    +
    + Loading... +
    +
    + ); + } + + // Check if URL or name is missing + if (!url || !name) { + return ( +
    +
    +
    +
    + ⚠️ Exam information is missing. Please select an exam from the + home page. +
    + +
    +
    +
    + ); + } + return (

    diff --git a/app/practice/page.tsx b/app/practice/page.tsx index 95f6814..e9c6550 100644 --- a/app/practice/page.tsx +++ b/app/practice/page.tsx @@ -41,10 +41,12 @@ const Practice: NextPage = () => { const seq = seqParam ? parseInt(seqParam) : 1; const [currentQuestionIndex, setCurrentQuestionIndex] = useState(seq); - const editedUrl = url.substring(0, url.lastIndexOf("/") + 1); + const editedUrl = + url && url.includes("/") ? url.substring(0, url.lastIndexOf("/") + 1) : ""; const { loading, error, data } = useQuery(questionQuery, { variables: { id: currentQuestionIndex - 1, link: url }, + skip: !url, // Skip query if URL is not available }); useEffect(() => { @@ -58,6 +60,7 @@ const Practice: NextPage = () => { error: questionsError, } = useQuery(questionsQuery, { variables: { link: url }, + skip: !url, // Skip query if URL is not available }); const setThisSeqIntoURL = useCallback((seq: number) => { @@ -85,30 +88,90 @@ const Practice: NextPage = () => { } }; - // Show loading while checking trial access - if (isAccessBlocked === undefined) { + // Show loading while checking trial access or waiting for URL + if (isAccessBlocked === undefined || !searchParams) { return ; } + // Check if URL is missing + if (!url) { + return ( +
    +
    +
    + ⚠️ Practice URL is missing. Please select an exam from the home + page. +
    + +
    +
    + ); + } + // Block access if trial expired if (isAccessBlocked) { return ( -
    -
    - ⏰ Trial expired. Please sign in to continue practicing. +
    +
    +
    + ⏰ Trial expired. Please sign in to continue practicing. +
    +
    -
    ); } - if (error) return

    Oh no... {error.message}

    ; - if (questionsError) return

    Oh no... {questionsError.message}

    ; + if (error) { + return ( +
    +
    +
    + ⚠️ Error loading question +
    +

    + {error.message} +

    + +
    +
    + ); + } + if (questionsError) { + return ( +
    +
    +
    + ⚠️ Error loading questions +
    +

    + {questionsError.message} +

    + +
    +
    + ); + } return (
    diff --git a/components/NameLink.tsx b/components/NameLink.tsx index 2211f9b..a12e969 100644 --- a/components/NameLink.tsx +++ b/components/NameLink.tsx @@ -24,7 +24,13 @@ const NameLink = ({ return (

    Date: Fri, 7 Nov 2025 18:30:24 +0400 Subject: [PATCH 5/7] fix: minor improvements from first testing --- components/Cookie.tsx | 2 +- components/Header.tsx | 7 ------- components/TrialWarning.tsx | 14 ++++---------- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/components/Cookie.tsx b/components/Cookie.tsx index 8dfa17a..0e3a3d1 100644 --- a/components/Cookie.tsx +++ b/components/Cookie.tsx @@ -25,7 +25,7 @@ const Cookie: FC = () => { return ( <> -

    From 3cb41f98a47ca321b777868df61f718c485966b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Sat, 8 Nov 2025 03:42:37 +0400 Subject: [PATCH 6/7] style: update Sign Out button styles for improved accessibility and consistency --- components/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Header.tsx b/components/Header.tsx index 49953e3..1872ac8 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -247,7 +247,7 @@ const Header = () => { handleSignOut(); setIsMobileMenuOpen(false); }} - className="w-full bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200" + className="w-full text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200" > Sign Out From 3256e7c34b76018e28b197f4e313c5cdb59649b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Sat, 8 Nov 2025 03:47:18 +0400 Subject: [PATCH 7/7] style: enhance Header component layout and visibility for user menu --- components/Header.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/Header.tsx b/components/Header.tsx index 1872ac8..a12cf40 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -101,7 +101,7 @@ const Header = () => { {/* Authentication */} {isAuthenticated && user ? ( -
    +