Minor redesign and other changes

This commit is contained in:
Jakob Kordež 2024-10-21 23:37:09 +02:00
parent 5c97b5d402
commit 79060159e0
23 changed files with 1106 additions and 1029 deletions

View File

@ -16,20 +16,20 @@
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@serwist/next": "^9.0.3", "@serwist/next": "^9.0.3",
"@vercel/analytics": "^1.0.2", "@vercel/analytics": "^1.0.2",
"katex": "^0.16.11",
"next": "^14.1.4", "next": "^14.1.4",
"postcss": "^8.4.30", "postcss": "^8.4.30",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-katex": "^3.0.1",
"sharp": "^0.32.6", "sharp": "^0.32.6",
"zustand": "^4.4.1" "zustand": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.10",
"@types/katex": "^0.16.7",
"@types/node": "^20.6.3", "@types/node": "^20.6.3",
"@types/react": "^18.2.22", "@types/react": "^18.2.22",
"@types/react-dom": "^18.2.6", "@types/react-dom": "^18.2.6",
"@types/react-katex": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.4.0", "@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0", "@typescript-eslint/parser": "^7.4.0",
"autoprefixer": "^10.4.15", "autoprefixer": "^10.4.15",

View File

@ -4,11 +4,6 @@ import Generator from './generator';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Generator izpitnih pol', title: 'Generator izpitnih pol',
description: 'Pripomoček za generiranje izpitnih pol za radioamaterski izpit', description: 'Pripomoček za generiranje izpitnih pol za radioamaterski izpit',
openGraph: {
title: 'Generator izpitnih pol',
description:
'Pripomoček za generiranje izpitnih pol za radioamaterski izpit',
},
}; };
export default function Izpit() { export default function Izpit() {

View File

@ -5,6 +5,7 @@ import { getExamQuestions } from '@/util/question-util';
import { create } from 'zustand'; import { create } from 'zustand';
import QuestionCard from '@/components/question_card'; import QuestionCard from '@/components/question_card';
import { scrollToTop } from '@/components/scroll-to-top-button'; import { scrollToTop } from '@/components/scroll-to-top-button';
import { useEffect, useState } from 'react';
enum QuizState { enum QuizState {
Loading, Loading,
@ -19,18 +20,20 @@ interface IzpitQuizStore {
questions?: Question[]; questions?: Question[];
answers?: number[][]; answers?: number[][];
correctCount?: number; correctCount?: number;
endTime?: Date;
load: () => Promise<void>; load: () => Promise<void>;
finish: (correctCount: number) => Promise<void>; finish: () => Promise<void>;
reset: () => Promise<void>; reset: () => Promise<void>;
} }
const useStore = create<IzpitQuizStore>((set) => ({ const useStore = create<IzpitQuizStore>((set, get) => ({
state: QuizState.Ready, state: QuizState.Ready,
questions: undefined, questions: undefined,
answers: undefined, answers: undefined,
correctCount: undefined, correctCount: undefined,
endTime: undefined,
load: async () => { load: async () => {
set({ state: QuizState.Loading }); set({ state: QuizState.Loading });
@ -41,10 +44,17 @@ const useStore = create<IzpitQuizStore>((set) => ({
state: QuizState.InProgress, state: QuizState.InProgress,
questions, questions,
answers: Array(questions.length).fill([-1]), answers: Array(questions.length).fill([-1]),
endTime: new Date(Date.now() + 1000 * 60 * 90),
}); });
}, },
finish: async (correctCount: number) => { finish: async () => {
const { questions, answers } = get();
const correctCount = questions!
.map((q, qi) => q.correct === answers![qi][0])
.reduce((acc, cur) => acc + (cur ? 1 : 0), 0);
set({ state: QuizState.Finished, correctCount }); set({ state: QuizState.Finished, correctCount });
scrollToTop(); scrollToTop();
}, },
@ -55,16 +65,25 @@ const useStore = create<IzpitQuizStore>((set) => ({
})); }));
export default function IzpitQuiz() { export default function IzpitQuiz() {
const [state, questions, answers, correctCount, load, finish, reset] = const [
useStore((state) => [ state,
state.state, questions,
state.questions, answers,
state.answers, correctCount,
state.correctCount, endTime,
state.load, load,
state.finish, finish,
state.reset, reset,
]); ] = useStore((state) => [
state.state,
state.questions,
state.answers,
state.correctCount,
state.endTime,
state.load,
state.finish,
state.reset,
]);
return ( return (
<> <>
@ -101,18 +120,17 @@ export default function IzpitQuiz() {
/> />
))} ))}
<button <div className="sticky inset-0 top-auto mb-10 flex select-none p-5">
className="button" <div className="mx-auto flex items-center gap-6 rounded-lg border bg-white px-6 py-4 shadow-lg">
onClick={() => <div className="text-lg">
finish( {answers!.filter(([v]) => v >= 0).length} / {answers!.length}
questions! </div>
.map((q, qi) => q.correct === answers![qi][0]) <Countdown timeEnd={endTime!} />
.reduce((acc, cur) => acc + (cur ? 1 : 0), 0), <button className="button text-sm" onClick={() => finish()}>
) Zaključi
} </button>
> </div>
Zaključi </div>
</button>
</div> </div>
); );
} }
@ -156,3 +174,30 @@ export default function IzpitQuiz() {
); );
} }
} }
export function Countdown({ timeEnd }: { timeEnd: Date }) {
const finish = useStore((state) => state.finish);
const [remaining, setRemaining] = useState(timeEnd.valueOf() - Date.now());
useEffect(() => {
const interval = setInterval(() => {
const newVal = Math.max(0, timeEnd.valueOf() - Date.now());
setRemaining(newVal);
if (newVal === 0) {
clearInterval(interval);
finish();
}
}, 500);
return () => clearInterval(interval);
}, [timeEnd, finish]);
return (
<div className="text-lg">
{Math.floor(remaining / 1000 / 60)}:
{Math.floor((remaining / 1000) % 60)
.toString()
.padStart(2, '0')}
</div>
);
}

View File

@ -3,19 +3,23 @@ import IzpitQuiz from './izpit-quiz';
import { SubHeader } from '@/components/sub_header'; import { SubHeader } from '@/components/sub_header';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Simulator izpita', title: 'Preizkusni izpit',
description: 'Pripomoček za simuliranje izpita za radioamaterski izpit', description:
openGraph: { 'Rešite preizkusni radioamaterski izpit in preverite svoje znanje iz radioamaterstva',
title: 'Simulator izpita', keywords: [
description: 'Pripomoček za simuliranje izpita za radioamaterski izpit', 'izpit',
}, 'preizkus',
'preverjanje znanje',
'preizkusni izpit',
'radioamaterski izpit',
],
}; };
export default function Priprave() { export default function Priprave() {
return ( return (
<> <>
<SubHeader> <SubHeader>
<h1>Simulator izpita</h1> <h1>Preizkusni izpit</h1>
<p> <p>
Izpit je sestavljen iz <strong>60 različnih vprašanj</strong>. Vsako Izpit je sestavljen iz <strong>60 različnih vprašanj</strong>. Vsako
vprašanje ima 3 možne odgovore, od katerih je samo en pravilen. vprašanje ima 3 možne odgovore, od katerih je samo en pravilen.

View File

@ -64,16 +64,16 @@ export default function CallsignTool() {
<div className="flex flex-row gap-4"> <div className="flex flex-row gap-4">
<button <button
onClick={() => setClas(0)} onClick={() => setClas(0)}
className={`flex-1 rounded-lg border-4 bg-light py-2 font-semibold text-dark shadow ${ className={`flex-1 rounded-lg border-2 bg-light py-2 font-semibold text-dark ${
!clas ? 'border-dark' : 'border-transparent' !clas ? 'border-primary bg-primary/20' : 'border-transparent'
}`} }`}
> >
N razred N razred
</button> </button>
<button <button
onClick={() => setClas(1)} onClick={() => setClas(1)}
className={`flex-1 rounded-lg border-4 bg-light py-2 font-semibold text-dark shadow ${ className={`flex-1 rounded-lg border-2 bg-light py-2 font-semibold text-dark ${
clas ? 'border-dark' : 'border-transparent' clas ? 'border-primary bg-primary/20' : 'border-transparent'
}`} }`}
> >
A razred A razred
@ -85,7 +85,7 @@ export default function CallsignTool() {
<label className="text-sm font-semibold">Vnesi klicni znak</label> <label className="text-sm font-semibold">Vnesi klicni znak</label>
<input <input
type="text" type="text"
className={`rounded-lg border border-light py-3 text-center text-3xl uppercase shadow-lg placeholder:font-sans placeholder:normal-case ${robotoMono.className}`} className={`rounded-lg border border-gray-200 py-3 text-center text-3xl uppercase outline-primary placeholder:font-sans placeholder:normal-case ${robotoMono.className}`}
value={callsign} value={callsign}
onChange={(e) => setCallsign(e.target.value)} onChange={(e) => setCallsign(e.target.value)}
placeholder='npr. "S50HQ"' placeholder='npr. "S50HQ"'
@ -153,23 +153,32 @@ export default function CallsignTool() {
{showSimilar ? ( {showSimilar ? (
<div> <div>
<h4 className="mb-1 text-xl font-semibold"> <h4 className="mb-3 text-xl font-semibold">
Podobni prosti klicni znaki Podobni prosti klicni znaki
</h4> </h4>
<div <div
className={`grid grid-cols-4 gap-2 md:grid-cols-5 ${robotoMono.className}`} className={`grid grid-cols-4 gap-2 md:grid-cols-5 ${robotoMono.className}`}
> >
{free {free
?.map((c) => [levenshteinDistance(callsign, c), c]) ?.map((c) => ({
.sort() call: c,
diff: levenshteinDistance(callsign, c),
}))
.filter(
({ call }) => call.toLowerCase() !== callsign.toLowerCase(),
)
.sort((a, b) => {
if (a.diff !== b.diff) return a.diff - b.diff;
return a.call.localeCompare(b.call);
})
.slice(0, 100) .slice(0, 100)
.map((c) => ( .map(({ call }) => (
<button <button
key={c[1]} key={call}
onClick={() => setCallsign(c[1] as string)} onClick={() => setCallsign(call)}
className="rounded-lg border bg-light p-1 text-center hover:border-dark" className="rounded-lg border bg-light p-1 text-center hover:border-dark"
> >
{c[1]} {call}
</button> </button>
))} ))}
</div> </div>

View File

@ -4,11 +4,13 @@ import CallsignTool from './callsign-tool';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Izbira klicnega znaka', title: 'Izbira klicnega znaka',
description: 'Pomoč pri izbiri klicnega znaka', description: 'Orodje, ki vam pomaga pri izbiri lastnega klicnega znaka',
openGraph: { keywords: [
title: 'Izbira klicnega znaka', 'klicni znak',
description: 'Pomoč pri izbiri klicnega znaka', 'callsign',
}, 'call sign',
'radioamaterski klicni znak',
],
}; };
export default function Callsign() { export default function Callsign() {

View File

@ -1,5 +1,5 @@
import { Analytics } from '@vercel/analytics/react'; import { Analytics } from '@vercel/analytics/react';
import Header from '@/components/header'; import { Header } from '@/components/header';
import { Metadata } from 'next'; import { Metadata } from 'next';
import '@/styles/globals.scss'; import '@/styles/globals.scss';
import { inter, morse } from '@/fonts/fonts'; import { inter, morse } from '@/fonts/fonts';
@ -17,7 +17,27 @@ export const metadata: Metadata = {
default: 'Radioamaterski izpit', default: 'Radioamaterski izpit',
template: '%s | Radioamaterski izpit', template: '%s | Radioamaterski izpit',
}, },
description: 'Priprava na radioamaterski izpit', description:
'Vse o radioamaterskem izpitu, pripravi na izpit in pridobitev radioamaterskega dovoljenja in klicnega znaka',
keywords: [
'radioamater',
'izpit',
'radio',
'KV',
'HF',
'VHF',
'UKV',
'dovoljenje',
'licenca',
'CEPT',
'AKOS',
'ZRS',
'Zveza radioamaterjev Slovenije',
'radiotehnika',
'radioamaterstvo',
'Morzejeva abeceda',
'Morse kod',
],
icons: { icons: {
icon: '/logo/icon_512.png', icon: '/logo/icon_512.png',
shortcut: '/logo/icon_512.png', shortcut: '/logo/icon_512.png',
@ -26,14 +46,9 @@ export const metadata: Metadata = {
manifest: '/manifest.json', manifest: '/manifest.json',
metadataBase: new URL('https://izpit.jkob.cc'), metadataBase: new URL('https://izpit.jkob.cc'),
openGraph: { openGraph: {
title: { locale: 'sl',
default: 'Radioamaterski izpit',
template: '%s | Radioamaterski izpit',
},
description: 'Priprava na radioamaterski izpit',
url: 'https://izpit.jkob.cc/',
locale: 'sl_SL',
type: 'website', type: 'website',
siteName: 'Radioamaterski tečaj',
}, },
robots: { robots: {
index: true, index: true,

View File

@ -1,15 +1,22 @@
import { SubHeader } from '@/components/sub_header'; import { SubHeader } from '@/components/sub_header';
import { Metadata } from 'next'; import { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import RandomCallsign from './random_callsign'; import RandomCallsign from './random-callsign';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Radioamatersko dovoljenje', title: 'Radioamatersko dovoljenje',
description: 'O radioamaterskem dovoljenju in klicnem znaku', description:
openGraph: { 'Radioamatersko dovoljenje (CEPT licenca), ki vam omogoča, da začnete uporabljati radioamaterske frekvence',
title: 'Radioamatersko dovoljenje', keywords: [
description: 'O radioamaterskem dovoljenju in klicnem znaku', 'dovoljenje',
}, 'radioamatersko dovoljenje',
'licenca',
'CEPT licenca',
'radioamaterska licenca',
'klicni znak',
'callsign',
'call sign',
],
}; };
export default function License() { export default function License() {
@ -82,7 +89,7 @@ export default function License() {
Klicni znak si lahko izbereš glede na razred izpita, ki si ga opravil. Klicni znak si lahko izbereš glede na razred izpita, ki si ga opravil.
</p> </p>
<div className="my-6 flex flex-col gap-6 md:flex-row"> <div className="my-6 flex flex-col gap-6 md:flex-row">
<div className="flex flex-1 flex-col gap-4 rounded-lg bg-light px-4 shadow-md"> <div className="flex flex-1 flex-col rounded-lg bg-light px-4 shadow-md">
<h3 className="text-center">N razred</h3> <h3 className="text-center">N razred</h3>
<ul> <ul>
@ -90,7 +97,7 @@ export default function License() {
<li>S58AAA - S58XZZ</li> <li>S58AAA - S58XZZ</li>
</ul> </ul>
</div> </div>
<div className="flex flex-1 flex-col gap-4 rounded-lg bg-light px-4 shadow-md"> <div className="flex flex-1 flex-col rounded-lg bg-light px-4 shadow-md">
<h3 className="text-center">A razred</h3> <h3 className="text-center">A razred</h3>
<ul> <ul>

View File

@ -4,11 +4,9 @@ import { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
export const metadata: Metadata = { export const metadata: Metadata = {
description: 'Informacije o radioamaterstvu in izpitu za pridobitev licence', title: 'Radioamaterstvo, izpit in dovoljenje',
openGraph: { description:
description: 'Izvedite kaj pomeni biti radioamater, kako začeti s hobijem, kako se pripraviti na izpit in kako pridobiti radioamatersko dovoljenje',
'Informacije o radioamaterstvu in izpitu za pridobitev licence',
},
}; };
const povezave = [ const povezave = [
@ -82,8 +80,13 @@ export default function Home() {
vzpostavljanjem radijskih zvez, nekateri radi tekmujejo v vzpostavljanjem radijskih zvez, nekateri radi tekmujejo v
vzpostavljanju radijskih zvez ali pa iskanjem skritih oddajnikov. vzpostavljanju radijskih zvez ali pa iskanjem skritih oddajnikov.
Radioamaterji so tudi pomočniki v primeru naravnih nesreč, ko se Radioamaterji so tudi pomočniki v primeru naravnih nesreč, ko se
porušijo komunikacijske povezave. Radioamaterji uporabljajo določene porušijo komunikacijske povezave.
frekvence, ki so jim dodeljene s strani mednarodne organizacije ITU. </p>
<p>
Radioamaterji uporabljajo določene frekvence, ki so jim dodeljene s
strani mednarodne organizacije ITU. Za uporabo teh frekvenc je
potrebno opraviti radioamaterski izpit in pridobiti radioamatersko
dovoljenje (CEPT licenco).
</p> </p>
</div> </div>

View File

@ -2,12 +2,17 @@ import { Metadata } from 'next';
import VajaQuiz from './vaja-quiz'; import VajaQuiz from './vaja-quiz';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Priprave na izpit', title: 'Vaja',
description: 'Naloge za pripravo na izpit', description:
openGraph: { 'Pripravite se na radioamaterski izpit z vajo reševanja vprašanj, ki se lahko pojavijo na izpitu',
title: 'Priprave na izpit', keywords: [
description: 'Naloge za pripravo na izpit', 'vaja za izpit',
}, 'radioamaterske naloge',
'radioamaterske vaje',
'priprava na izpit',
'vprašanja za izpit',
'izpitna vprašanja',
],
}; };
export default function Priprave() { export default function Priprave() {

View File

@ -1,15 +1,16 @@
export default function sitemap() { import { MetadataRoute } from 'next';
const pages = [
export default function sitemap(): MetadataRoute.Sitemap {
return [
'', '',
'/licenca', '/licenca',
'/klicni-znak', '/klicni-znak',
'/zbirka', '/zbirka',
'/priprave', '/priprave',
'/izpit-sim', '/izpit-sim',
].map((page) => ({ ].map((page, index) => ({
url: `https://izpit.jkob.cc${page}`, url: `https://izpit.jkob.cc${page}`,
lastModified: new Date().toISOString(), lastModified: new Date().toISOString(),
priority: index === 0 ? 1 : 0.8,
})); }));
return pages;
} }

View File

@ -4,11 +4,15 @@ import { SubHeader } from '@/components/sub_header';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Zbirka vprašanj', title: 'Zbirka vprašanj',
description: 'Zbirka vprašanj, ki se lahko pojavijo na izpitu.', description:
openGraph: { 'Pregled cele zbirke vprašanj, ki se lahko pojavijo na radioamaterskem izpitu',
title: 'Zbirka vprašanj', keywords: [
description: 'Zbirka vprašanj, ki se lahko pojavijo na izpitu.', 'vprašanja',
}, 'naloge',
'radioamaterski izpit',
'zbirka vprašanj',
'zbirka nalog',
],
}; };
export default function QuestionPool() { export default function QuestionPool() {

View File

@ -1,11 +1,9 @@
'use client'; 'use client';
import { faAngleDown } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useState } from 'react'; import { useEffect, useState } from 'react';
const nav = [ const nav = [
{ href: '/', label: 'Domov' }, { href: '/', label: 'Domov' },
@ -13,70 +11,83 @@ const nav = [
{ {
label: 'Vaja', label: 'Vaja',
children: [ children: [
{ href: '/zbirka', label: 'Zbirka' }, { href: '/zbirka', label: 'Zbirka izpitnih vprašanj' },
{ href: '/priprave', label: 'Priprave' }, { href: '/priprave', label: 'Vaja vprašanj' },
{ href: '/izpit-sim', label: 'Simulator izpita' }, { href: '/izpit-sim', label: 'Preizkusni izpit' },
], ],
}, },
// { href: "/izpit-gen", label: "Generator izpitnih pol" }, // { href: "/izpit-gen", label: "Generator izpitnih pol" },
]; ];
export default function Header() { export function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => {
setIsMenuOpen(false);
}, [pathname]);
return ( return (
<header className="select-none text-white"> <header className="relative flex select-none flex-col bg-darker font-medium text-white lg:flex-row lg:items-center [&>*]:z-30 [&>*]:bg-darker">
<div className="bg-darker py-6 font-bold"> <div className="flex flex-1 flex-row items-center justify-between">
<div className="container"> <div className="flex flex-row items-center justify-start gap-6 px-8 py-6">
<div className="flex flex-row items-center justify-start gap-8 px-4"> <Image src="/logo/logo_192.png" alt="Logo" height={24} width={24} />
<Image src="/logo/logo_192.png" alt="Logo" height={32} width={32} /> <div>
<div> <Link href="/" className="text-2xl sm:text-3xl">
<Link href="/"> Radioamaterski izpit
<h1 className="text-3xl sm:text-4xl">Radioamaterski izpit</h1> </Link>
</Link>
<div data-nosnippet className="morse mt-1 text-sm text-gray-400">
CQ|DE|HAMS
</div>
</div>
</div> </div>
</div> </div>
<button
className="flex flex-col justify-between gap-2 px-6 lg:hidden [&>*]:h-0.5 [&>*]:w-6 [&>*]:bg-white [&>*]:transition-all"
onClick={() => setIsMenuOpen(!isMenuOpen)}
title="Menu"
>
<div className={isMenuOpen ? 'translate-y-2.5 rotate-45' : ''} />
<div className={isMenuOpen ? 'opacity-0' : ''} />
<div className={isMenuOpen ? '-translate-y-2.5 -rotate-45' : ''} />
</button>
</div> </div>
<div className="bg-dark"> <div
<nav className="container flex flex-row flex-wrap justify-start"> className={`absolute left-0 right-0 top-full flex flex-col items-stretch border-t border-primary lg:relative lg:flex-row lg:gap-4 lg:border-none lg:pr-10 ${isMenuOpen ? '' : 'hidden lg:flex'}`}
{nav.map(({ href, label, children }) => >
href ? ( {nav.map(({ href, label, children }) =>
<Link href ? (
key={href} <Link
href={href} key={href}
className={`px-4 py-2 transition-colors ${ href={href}
href === pathname className={`px-6 py-4 transition-colors lg:rounded-lg lg:px-4 lg:py-2 ${
? 'cursor-default bg-white/10' href === pathname ? 'bg-dark' : 'hover:bg-dark active:bg-black'
: 'hover:bg-white/10 active:bg-white/30' }`}
}`} >
> {label}
{label} </Link>
</Link> ) : (
) : ( <Dropdown key={label} label={label}>
<Dropdown key={label} label={label}> {children?.map(({ href, label }) => (
{children?.map(({ href, label }) => ( <Link
<Link key={href}
key={href} href={href}
href={href} className={`px-6 py-4 transition-colors lg:rounded-lg lg:px-4 lg:py-2 ${
className={`px-4 py-2 transition-colors ${ href === pathname
href === pathname ? 'bg-dark'
? 'cursor-default bg-white/10' : 'hover:bg-dark active:bg-black'
: 'hover:bg-white/10 active:bg-white/30' }`}
}`} >
> {label}
{label} </Link>
</Link> ))}
))} </Dropdown>
</Dropdown> ),
), )}
)}
</nav>
</div> </div>
<div
className={`fixed inset-0 !z-10 !bg-black/50 ${isMenuOpen ? 'block lg:hidden' : 'hidden'}`}
onClick={() => setIsMenuOpen(false)}
/>
</header> </header>
); );
} }
@ -87,29 +98,30 @@ interface DropdownProps {
} }
function Dropdown({ label, children }: DropdownProps) { function Dropdown({ label, children }: DropdownProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(0);
const pathname = usePathname();
useEffect(() => {
setOpen(0);
}, [pathname]);
return ( return (
<div onMouseLeave={() => setOpen(false)} className="relative"> <div onMouseLeave={() => setOpen(0)} className="relative">
<button <button
className={`flex flex-row items-center gap-2 px-4 py-2 transition-colors hover:bg-white/10 active:bg-white/30 ${ className={`hidden cursor-default px-6 py-4 transition-colors lg:block lg:cursor-auto lg:rounded-lg lg:px-4 lg:py-2 lg:hover:bg-dark lg:active:bg-black ${
open ? 'bg-white/10' : '' open ? 'lg:bg-dark' : ''
}`} }`}
onMouseOver={() => setOpen(true)} onMouseOver={() => setOpen(open | 1)}
onClick={() => setOpen(!open)} onClick={() => setOpen(open ^ 2)}
> >
<span>{label}</span> {label}
<FontAwesomeIcon icon={faAngleDown} className="pt-1" />
</button> </button>
{open && ( <div className={`right-0 lg:absolute lg:pt-2 ${open ? '' : 'lg:hidden'}`}>
<div <div className="top-full z-10 flex flex-col border-dark shadow-lg lg:w-max lg:gap-2 lg:rounded-xl lg:border lg:bg-darker lg:p-4">
onClick={() => setOpen(false)}
className="absolute left-0 top-full z-10 flex w-40 flex-col bg-darker shadow-lg"
>
{children} {children}
</div> </div>
)} </div>
</div> </div>
); );
} }

View File

@ -0,0 +1,15 @@
import { lazy } from 'react';
export const LazyTeX = lazy(() => import('./tex'));
export function MaybeTeX({ text }: { text: string }) {
const parts = text.split(/(?<!\\)\$+/);
return parts.map((part, i) =>
!part ? null : i % 2 === 0 ? (
<span key={i}>{part}</span>
) : (
<LazyTeX key={i} math={part} />
),
);
}

View File

@ -1,12 +1,11 @@
import { Question } from '@/interfaces/question'; import { Question } from '@/interfaces/question';
import { InlineMath } from 'react-katex'; import { MaybeTeX } from './lazy-tex';
import styles from '@/styles/Quiz.module.scss';
import Image from 'next/image'; import Image from 'next/image';
interface QuestionCardProps { interface QuestionCardProps {
question: Question; question: Question;
reveal: boolean; reveal: boolean;
selected: number[]; selected: number[] | number;
onClick?: (answer: number) => void; onClick?: (answer: number) => void;
} }
@ -19,19 +18,21 @@ export default function QuestionCard({
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="text-xl text-gray-700"> <div className="text-xl text-gray-700">
<span className="font-bold text-primary"> <span className="font-medium text-primary">
A{question.id.toString().padStart(3, '0')}:{' '} <span className="text-sm">#</span>
{question.id.toString().padStart(3, '0')}:{' '}
</span> </span>
<MaybeTeX text={question.question} /> <MaybeTeX text={question.question} />
</div> </div>
{question.image && ( {question.image && (
<Image <Image
className={styles.image} className="max-h-80 max-w-full object-contain"
src={`/question_images/${question.image}`} src={`/question_images/${question.image}`}
alt={question.image} alt={question.image}
height={500} height={500}
width={500} width={500}
style={{ width: '100%', height: 'auto' }}
/> />
)} )}
@ -43,7 +44,9 @@ export default function QuestionCard({
answer={answer} answer={answer}
reveal={reveal} reveal={reveal}
isCorrect={question.correct === i} isCorrect={question.correct === i}
isSelected={selected.includes(i)} isSelected={
selected instanceof Array ? selected.includes(i) : selected === i
}
onClick={!onClick ? undefined : () => onClick(i)} onClick={!onClick ? undefined : () => onClick(i)}
/> />
))} ))}
@ -71,41 +74,21 @@ function Answer({
}: AnswerProps) { }: AnswerProps) {
return ( return (
<button <button
className={`flex w-full flex-row items-center gap-5 rounded border px-6 py-2 ${ className={`flex h-auto flex-nowrap items-center justify-start gap-6 rounded-lg px-6 py-1 font-normal normal-case ${
!isSelected !isSelected
? 'border-gray-300' ? 'bg-light'
: !reveal : !reveal
? 'border-sky-500 bg-sky-100' ? 'bg-primary/30'
: isCorrect : isCorrect
? 'border-green-500 bg-green-100' ? 'bg-green-200 text-green-950'
: 'border-red-600 bg-red-100' : 'bg-red-200 text-red-950'
}`} } ${!onClick || isSelected ? 'cursor-default' : 'hover:bg-primary/10'}`}
disabled={!onClick}
onClick={onClick} onClick={onClick}
> >
{!reveal && <input type="radio" checked={isSelected} readOnly />} <div className="text-sm font-bold">{String.fromCharCode(65 + index)}</div>
<div <div className="py-2 text-left text-base">
className={`border-r py-2 pr-5 text-sm font-bold ${
!isSelected ? 'border-gray-200' : 'border-inherit'
}`}
>
{String.fromCharCode(65 + index)}
</div>
<div className="text-left text-lg text-gray-600">
<MaybeTeX text={answer} /> <MaybeTeX text={answer} />
</div> </div>
</button> </button>
); );
} }
function MaybeTeX({ text }: { text: string }) {
const parts = text.split(/(?<!\\)\$+/);
return parts.map((part, i) =>
!part ? null : i % 2 === 0 ? (
<span key={i}>{part}</span>
) : (
<InlineMath key={i} math={part} />
),
);
}

15
src/components/tex.tsx Normal file
View File

@ -0,0 +1,15 @@
import 'katex/dist/katex.min.css';
import KaTeX from 'katex';
import { memo } from 'react';
interface TeXProps {
math: string;
}
export default memo(TeX);
export function TeX({ math }: TeXProps) {
const innerHtml = KaTeX.renderToString(math);
return <span dangerouslySetInnerHTML={{ __html: innerHtml }} />;
}

View File

@ -1,14 +0,0 @@
.image {
max-width: 100% important;
max-height: 20rem !important;
height: auto !important;
margin: auto !important;
object-fit: contain !important;
}
.answer {
justify-content: start !important;
text-align: start !important;
height: auto !important;
white-space: normal !important;
}

View File

@ -5,7 +5,7 @@
@import 'katex/dist/katex.min.css'; @import 'katex/dist/katex.min.css';
.button { .button {
@apply flex flex-row items-center justify-center rounded-lg bg-primary px-5 py-2 font-semibold text-white decoration-transparent shadow-lg transition-colors hover:bg-primary-dark active:bg-primary-darker disabled:opacity-70; @apply flex flex-row items-center justify-center rounded-lg bg-primary px-5 py-2 font-semibold text-white decoration-transparent transition-colors hover:bg-primary-dark active:bg-primary-darker disabled:opacity-70;
} }
.link { .link {
@ -17,8 +17,7 @@
} }
.section { .section {
padding-top: 3rem; @apply py-12;
padding-bottom: 3rem;
} }
main > :not(.section):last-child { main > :not(.section):last-child {
@ -33,16 +32,10 @@ main > .section:last-child {
} }
.prose { .prose {
h1 { h1,
@apply text-3xl font-semibold; h2,
}
h2 {
@apply text-2xl font-semibold;
}
h3 { h3 {
@apply text-xl font-semibold; @apply font-semibold;
} }
a { a {

View File

@ -44,34 +44,43 @@ export function generateAllCallsigns({
} }
export function levenshteinDistance(from: string, to: string): number { export function levenshteinDistance(from: string, to: string): number {
const addWeight = 5; const addWeight = 1.6;
const removeWeight = 0; const removeWeight = 0.4;
const replaceWeight = 1; const replaceWeight = 1;
const swapWeight = 0.5;
from = from.toUpperCase(); from = from.toUpperCase();
to = to.toUpperCase(); to = to.toUpperCase();
let prev: number[] = []; const table: number[][] = [[]];
for (let i = 0; i < to.length + 1; i++) prev[i] = i; for (let i = 0; i < to.length + 1; i++) table[0][i] = i * addWeight;
for (let i = 1; i < from.length + 1; i++) { for (let i = 1; i < from.length + 1; i++) {
const curr: number[] = []; const curr: number[] = [];
curr[0] = i; curr[0] = i * removeWeight;
for (let j = 1; j < to.length + 1; j++) { for (let j = 1; j < to.length + 1; j++) {
const cost = const cost =
from[i - 1] === to[j - 1] || from[i - 1] === '*' ? 0 : replaceWeight; from[i - 1] === to[j - 1] || from[i - 1] === '*' ? 0 : replaceWeight;
curr[j] = Math.min( curr[j] = Math.min(
prev[j] + removeWeight, table[i - 1][j] + removeWeight,
curr[j - 1] + addWeight, curr[j - 1] + addWeight,
prev[j - 1] + cost, table[i - 1][j - 1] + cost,
); );
if (
i > 1 &&
j > 1 &&
from[i - 2] === to[j - 1] &&
from[i - 1] === to[j - 2]
) {
curr[j] = Math.min(curr[j], table[i - 2][j - 2] + swapWeight);
}
} }
prev = curr; table.push(curr);
} }
return prev[to.length]; return table[from.length][to.length];
} }
export function cwWeight(text: string): number { export function cwWeight(text: string): number {

View File

@ -1,36 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
darker: "#222831",
dark: "#393E46",
primary: {
light: "#00F4FF",
DEFAULT: "#00ADB5",
dark: "#008288",
darker: "#00656A",
},
light: "#EEEEEE",
}
},
container: {
padding: '2rem',
center: true,
screens: {
DEFAULT: '100%',
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
},
},
},
plugins: [
require('@tailwindcss/typography'),
],
}

36
tailwind.config.ts Normal file
View File

@ -0,0 +1,36 @@
import defaultTheme from 'tailwindcss/defaultTheme';
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
colors: {
darker: '#222831',
dark: '#393E46',
primary: {
light: '#00F4FF',
DEFAULT: '#00ADB5',
dark: '#008288',
darker: '#00656A',
},
light: '#F8FAFA',
},
},
container: {
padding: '2rem',
center: true,
screens: {
DEFAULT: '100%',
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
},
},
fontFamily: {
sans: ['Inter', ...defaultTheme.fontFamily.sans],
},
},
plugins: [require('@tailwindcss/typography')],
};

1492
yarn.lock

File diff suppressed because it is too large Load Diff