mirror of
https://github.com/jakobkordez/s5_practice.git
synced 2025-05-15 16:20:31 +00:00
Minor redesign and other changes
This commit is contained in:
parent
5c97b5d402
commit
79060159e0
@ -16,20 +16,20 @@
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@serwist/next": "^9.0.3",
|
||||
"@vercel/analytics": "^1.0.2",
|
||||
"katex": "^0.16.11",
|
||||
"next": "^14.1.4",
|
||||
"postcss": "^8.4.30",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-katex": "^3.0.1",
|
||||
"sharp": "^0.32.6",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "^20.6.3",
|
||||
"@types/react": "^18.2.22",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@types/react-katex": "^3.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||
"@typescript-eslint/parser": "^7.4.0",
|
||||
"autoprefixer": "^10.4.15",
|
||||
|
@ -4,11 +4,6 @@ import Generator from './generator';
|
||||
export const metadata: Metadata = {
|
||||
title: 'Generator izpitnih pol',
|
||||
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() {
|
||||
|
@ -5,6 +5,7 @@ import { getExamQuestions } from '@/util/question-util';
|
||||
import { create } from 'zustand';
|
||||
import QuestionCard from '@/components/question_card';
|
||||
import { scrollToTop } from '@/components/scroll-to-top-button';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
enum QuizState {
|
||||
Loading,
|
||||
@ -19,18 +20,20 @@ interface IzpitQuizStore {
|
||||
questions?: Question[];
|
||||
answers?: number[][];
|
||||
correctCount?: number;
|
||||
endTime?: Date;
|
||||
|
||||
load: () => Promise<void>;
|
||||
finish: (correctCount: number) => Promise<void>;
|
||||
finish: () => Promise<void>;
|
||||
reset: () => Promise<void>;
|
||||
}
|
||||
|
||||
const useStore = create<IzpitQuizStore>((set) => ({
|
||||
const useStore = create<IzpitQuizStore>((set, get) => ({
|
||||
state: QuizState.Ready,
|
||||
|
||||
questions: undefined,
|
||||
answers: undefined,
|
||||
correctCount: undefined,
|
||||
endTime: undefined,
|
||||
|
||||
load: async () => {
|
||||
set({ state: QuizState.Loading });
|
||||
@ -41,10 +44,17 @@ const useStore = create<IzpitQuizStore>((set) => ({
|
||||
state: QuizState.InProgress,
|
||||
questions,
|
||||
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 });
|
||||
scrollToTop();
|
||||
},
|
||||
@ -55,12 +65,21 @@ const useStore = create<IzpitQuizStore>((set) => ({
|
||||
}));
|
||||
|
||||
export default function IzpitQuiz() {
|
||||
const [state, questions, answers, correctCount, load, finish, reset] =
|
||||
useStore((state) => [
|
||||
const [
|
||||
state,
|
||||
questions,
|
||||
answers,
|
||||
correctCount,
|
||||
endTime,
|
||||
load,
|
||||
finish,
|
||||
reset,
|
||||
] = useStore((state) => [
|
||||
state.state,
|
||||
state.questions,
|
||||
state.answers,
|
||||
state.correctCount,
|
||||
state.endTime,
|
||||
state.load,
|
||||
state.finish,
|
||||
state.reset,
|
||||
@ -101,19 +120,18 @@ export default function IzpitQuiz() {
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="button"
|
||||
onClick={() =>
|
||||
finish(
|
||||
questions!
|
||||
.map((q, qi) => q.correct === answers![qi][0])
|
||||
.reduce((acc, cur) => acc + (cur ? 1 : 0), 0),
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="sticky inset-0 top-auto mb-10 flex select-none p-5">
|
||||
<div className="mx-auto flex items-center gap-6 rounded-lg border bg-white px-6 py-4 shadow-lg">
|
||||
<div className="text-lg">
|
||||
{answers!.filter(([v]) => v >= 0).length} / {answers!.length}
|
||||
</div>
|
||||
<Countdown timeEnd={endTime!} />
|
||||
<button className="button text-sm" onClick={() => finish()}>
|
||||
Zaključi
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
@ -3,19 +3,23 @@ import IzpitQuiz from './izpit-quiz';
|
||||
import { SubHeader } from '@/components/sub_header';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Simulator izpita',
|
||||
description: 'Pripomoček za simuliranje izpita za radioamaterski izpit',
|
||||
openGraph: {
|
||||
title: 'Simulator izpita',
|
||||
description: 'Pripomoček za simuliranje izpita za radioamaterski izpit',
|
||||
},
|
||||
title: 'Preizkusni izpit',
|
||||
description:
|
||||
'Rešite preizkusni radioamaterski izpit in preverite svoje znanje iz radioamaterstva',
|
||||
keywords: [
|
||||
'izpit',
|
||||
'preizkus',
|
||||
'preverjanje znanje',
|
||||
'preizkusni izpit',
|
||||
'radioamaterski izpit',
|
||||
],
|
||||
};
|
||||
|
||||
export default function Priprave() {
|
||||
return (
|
||||
<>
|
||||
<SubHeader>
|
||||
<h1>Simulator izpita</h1>
|
||||
<h1>Preizkusni izpit</h1>
|
||||
<p>
|
||||
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.
|
||||
|
@ -64,16 +64,16 @@ export default function CallsignTool() {
|
||||
<div className="flex flex-row gap-4">
|
||||
<button
|
||||
onClick={() => setClas(0)}
|
||||
className={`flex-1 rounded-lg border-4 bg-light py-2 font-semibold text-dark shadow ${
|
||||
!clas ? 'border-dark' : 'border-transparent'
|
||||
className={`flex-1 rounded-lg border-2 bg-light py-2 font-semibold text-dark ${
|
||||
!clas ? 'border-primary bg-primary/20' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
N razred
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setClas(1)}
|
||||
className={`flex-1 rounded-lg border-4 bg-light py-2 font-semibold text-dark shadow ${
|
||||
clas ? 'border-dark' : 'border-transparent'
|
||||
className={`flex-1 rounded-lg border-2 bg-light py-2 font-semibold text-dark ${
|
||||
clas ? 'border-primary bg-primary/20' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
A razred
|
||||
@ -85,7 +85,7 @@ export default function CallsignTool() {
|
||||
<label className="text-sm font-semibold">Vnesi klicni znak</label>
|
||||
<input
|
||||
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}
|
||||
onChange={(e) => setCallsign(e.target.value)}
|
||||
placeholder='npr. "S50HQ"'
|
||||
@ -153,23 +153,32 @@ export default function CallsignTool() {
|
||||
|
||||
{showSimilar ? (
|
||||
<div>
|
||||
<h4 className="mb-1 text-xl font-semibold">
|
||||
<h4 className="mb-3 text-xl font-semibold">
|
||||
Podobni prosti klicni znaki
|
||||
</h4>
|
||||
<div
|
||||
className={`grid grid-cols-4 gap-2 md:grid-cols-5 ${robotoMono.className}`}
|
||||
>
|
||||
{free
|
||||
?.map((c) => [levenshteinDistance(callsign, c), c])
|
||||
.sort()
|
||||
?.map((c) => ({
|
||||
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)
|
||||
.map((c) => (
|
||||
.map(({ call }) => (
|
||||
<button
|
||||
key={c[1]}
|
||||
onClick={() => setCallsign(c[1] as string)}
|
||||
key={call}
|
||||
onClick={() => setCallsign(call)}
|
||||
className="rounded-lg border bg-light p-1 text-center hover:border-dark"
|
||||
>
|
||||
{c[1]}
|
||||
{call}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
@ -4,11 +4,13 @@ import CallsignTool from './callsign-tool';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Izbira klicnega znaka',
|
||||
description: 'Pomoč pri izbiri klicnega znaka',
|
||||
openGraph: {
|
||||
title: 'Izbira klicnega znaka',
|
||||
description: 'Pomoč pri izbiri klicnega znaka',
|
||||
},
|
||||
description: 'Orodje, ki vam pomaga pri izbiri lastnega klicnega znaka',
|
||||
keywords: [
|
||||
'klicni znak',
|
||||
'callsign',
|
||||
'call sign',
|
||||
'radioamaterski klicni znak',
|
||||
],
|
||||
};
|
||||
|
||||
export default function Callsign() {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Analytics } from '@vercel/analytics/react';
|
||||
import Header from '@/components/header';
|
||||
import { Header } from '@/components/header';
|
||||
import { Metadata } from 'next';
|
||||
import '@/styles/globals.scss';
|
||||
import { inter, morse } from '@/fonts/fonts';
|
||||
@ -17,7 +17,27 @@ export const metadata: Metadata = {
|
||||
default: '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: {
|
||||
icon: '/logo/icon_512.png',
|
||||
shortcut: '/logo/icon_512.png',
|
||||
@ -26,14 +46,9 @@ export const metadata: Metadata = {
|
||||
manifest: '/manifest.json',
|
||||
metadataBase: new URL('https://izpit.jkob.cc'),
|
||||
openGraph: {
|
||||
title: {
|
||||
default: 'Radioamaterski izpit',
|
||||
template: '%s | Radioamaterski izpit',
|
||||
},
|
||||
description: 'Priprava na radioamaterski izpit',
|
||||
url: 'https://izpit.jkob.cc/',
|
||||
locale: 'sl_SL',
|
||||
locale: 'sl',
|
||||
type: 'website',
|
||||
siteName: 'Radioamaterski tečaj',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
|
@ -1,15 +1,22 @@
|
||||
import { SubHeader } from '@/components/sub_header';
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import RandomCallsign from './random_callsign';
|
||||
import RandomCallsign from './random-callsign';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Radioamatersko dovoljenje',
|
||||
description: 'O radioamaterskem dovoljenju in klicnem znaku',
|
||||
openGraph: {
|
||||
title: 'Radioamatersko dovoljenje',
|
||||
description: 'O radioamaterskem dovoljenju in klicnem znaku',
|
||||
},
|
||||
description:
|
||||
'Radioamatersko dovoljenje (CEPT licenca), ki vam omogoča, da začnete uporabljati radioamaterske frekvence',
|
||||
keywords: [
|
||||
'dovoljenje',
|
||||
'radioamatersko dovoljenje',
|
||||
'licenca',
|
||||
'CEPT licenca',
|
||||
'radioamaterska licenca',
|
||||
'klicni znak',
|
||||
'callsign',
|
||||
'call sign',
|
||||
],
|
||||
};
|
||||
|
||||
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.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<ul>
|
||||
@ -90,7 +97,7 @@ export default function License() {
|
||||
<li>S58AAA - S58XZZ</li>
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
<ul>
|
||||
|
@ -4,11 +4,9 @@ import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description: 'Informacije o radioamaterstvu in izpitu za pridobitev licence',
|
||||
openGraph: {
|
||||
title: 'Radioamaterstvo, izpit in dovoljenje',
|
||||
description:
|
||||
'Informacije o radioamaterstvu in izpitu za pridobitev licence',
|
||||
},
|
||||
'Izvedite kaj pomeni biti radioamater, kako začeti s hobijem, kako se pripraviti na izpit in kako pridobiti radioamatersko dovoljenje',
|
||||
};
|
||||
|
||||
const povezave = [
|
||||
@ -82,8 +80,13 @@ export default function Home() {
|
||||
vzpostavljanjem radijskih zvez, nekateri radi tekmujejo v
|
||||
vzpostavljanju radijskih zvez ali pa iskanjem skritih oddajnikov.
|
||||
Radioamaterji so tudi pomočniki v primeru naravnih nesreč, ko se
|
||||
porušijo komunikacijske povezave. Radioamaterji uporabljajo določene
|
||||
frekvence, ki so jim dodeljene s strani mednarodne organizacije ITU.
|
||||
porušijo komunikacijske povezave.
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
@ -2,12 +2,17 @@ import { Metadata } from 'next';
|
||||
import VajaQuiz from './vaja-quiz';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Priprave na izpit',
|
||||
description: 'Naloge za pripravo na izpit',
|
||||
openGraph: {
|
||||
title: 'Priprave na izpit',
|
||||
description: 'Naloge za pripravo na izpit',
|
||||
},
|
||||
title: 'Vaja',
|
||||
description:
|
||||
'Pripravite se na radioamaterski izpit z vajo reševanja vprašanj, ki se lahko pojavijo na izpitu',
|
||||
keywords: [
|
||||
'vaja za izpit',
|
||||
'radioamaterske naloge',
|
||||
'radioamaterske vaje',
|
||||
'priprava na izpit',
|
||||
'vprašanja za izpit',
|
||||
'izpitna vprašanja',
|
||||
],
|
||||
};
|
||||
|
||||
export default function Priprave() {
|
||||
|
@ -1,15 +1,16 @@
|
||||
export default function sitemap() {
|
||||
const pages = [
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
'',
|
||||
'/licenca',
|
||||
'/klicni-znak',
|
||||
'/zbirka',
|
||||
'/priprave',
|
||||
'/izpit-sim',
|
||||
].map((page) => ({
|
||||
].map((page, index) => ({
|
||||
url: `https://izpit.jkob.cc${page}`,
|
||||
lastModified: new Date().toISOString(),
|
||||
priority: index === 0 ? 1 : 0.8,
|
||||
}));
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
@ -4,11 +4,15 @@ import { SubHeader } from '@/components/sub_header';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Zbirka vprašanj',
|
||||
description: 'Zbirka vprašanj, ki se lahko pojavijo na izpitu.',
|
||||
openGraph: {
|
||||
title: 'Zbirka vprašanj',
|
||||
description: 'Zbirka vprašanj, ki se lahko pojavijo na izpitu.',
|
||||
},
|
||||
description:
|
||||
'Pregled cele zbirke vprašanj, ki se lahko pojavijo na radioamaterskem izpitu',
|
||||
keywords: [
|
||||
'vprašanja',
|
||||
'naloge',
|
||||
'radioamaterski izpit',
|
||||
'zbirka vprašanj',
|
||||
'zbirka nalog',
|
||||
],
|
||||
};
|
||||
|
||||
export default function QuestionPool() {
|
||||
|
@ -1,11 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { faAngleDown } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const nav = [
|
||||
{ href: '/', label: 'Domov' },
|
||||
@ -13,46 +11,55 @@ const nav = [
|
||||
{
|
||||
label: 'Vaja',
|
||||
children: [
|
||||
{ href: '/zbirka', label: 'Zbirka' },
|
||||
{ href: '/priprave', label: 'Priprave' },
|
||||
{ href: '/izpit-sim', label: 'Simulator izpita' },
|
||||
{ href: '/zbirka', label: 'Zbirka izpitnih vprašanj' },
|
||||
{ href: '/priprave', label: 'Vaja vprašanj' },
|
||||
{ href: '/izpit-sim', label: 'Preizkusni izpit' },
|
||||
],
|
||||
},
|
||||
// { href: "/izpit-gen", label: "Generator izpitnih pol" },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
export function Header() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
setIsMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<header className="select-none text-white">
|
||||
<div className="bg-darker py-6 font-bold">
|
||||
<div className="container">
|
||||
<div className="flex flex-row items-center justify-start gap-8 px-4">
|
||||
<Image src="/logo/logo_192.png" alt="Logo" height={32} width={32} />
|
||||
<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="flex flex-1 flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center justify-start gap-6 px-8 py-6">
|
||||
<Image src="/logo/logo_192.png" alt="Logo" height={24} width={24} />
|
||||
<div>
|
||||
<Link href="/">
|
||||
<h1 className="text-3xl sm:text-4xl">Radioamaterski izpit</h1>
|
||||
<Link href="/" className="text-2xl sm:text-3xl">
|
||||
Radioamaterski izpit
|
||||
</Link>
|
||||
<div data-nosnippet className="morse mt-1 text-sm text-gray-400">
|
||||
CQ|DE|HAMS
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark">
|
||||
<nav className="container flex flex-row flex-wrap justify-start">
|
||||
<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
|
||||
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 ? (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`px-4 py-2 transition-colors ${
|
||||
href === pathname
|
||||
? 'cursor-default bg-white/10'
|
||||
: 'hover:bg-white/10 active:bg-white/30'
|
||||
className={`px-6 py-4 transition-colors lg:rounded-lg lg:px-4 lg:py-2 ${
|
||||
href === pathname ? 'bg-dark' : 'hover:bg-dark active:bg-black'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@ -63,10 +70,10 @@ export default function Header() {
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`px-4 py-2 transition-colors ${
|
||||
className={`px-6 py-4 transition-colors lg:rounded-lg lg:px-4 lg:py-2 ${
|
||||
href === pathname
|
||||
? 'cursor-default bg-white/10'
|
||||
: 'hover:bg-white/10 active:bg-white/30'
|
||||
? 'bg-dark'
|
||||
: 'hover:bg-dark active:bg-black'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@ -75,8 +82,12 @@ export default function Header() {
|
||||
</Dropdown>
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`fixed inset-0 !z-10 !bg-black/50 ${isMenuOpen ? 'block lg:hidden' : 'hidden'}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -87,29 +98,30 @@ interface DropdownProps {
|
||||
}
|
||||
|
||||
function Dropdown({ label, children }: DropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(0);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(0);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<div onMouseLeave={() => setOpen(false)} className="relative">
|
||||
<div onMouseLeave={() => setOpen(0)} className="relative">
|
||||
<button
|
||||
className={`flex flex-row items-center gap-2 px-4 py-2 transition-colors hover:bg-white/10 active:bg-white/30 ${
|
||||
open ? 'bg-white/10' : ''
|
||||
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 ? 'lg:bg-dark' : ''
|
||||
}`}
|
||||
onMouseOver={() => setOpen(true)}
|
||||
onClick={() => setOpen(!open)}
|
||||
onMouseOver={() => setOpen(open | 1)}
|
||||
onClick={() => setOpen(open ^ 2)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<FontAwesomeIcon icon={faAngleDown} className="pt-1" />
|
||||
{label}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
onClick={() => setOpen(false)}
|
||||
className="absolute left-0 top-full z-10 flex w-40 flex-col bg-darker shadow-lg"
|
||||
>
|
||||
<div className={`right-0 lg:absolute lg:pt-2 ${open ? '' : 'lg:hidden'}`}>
|
||||
<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">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
15
src/components/lazy-tex.tsx
Normal file
15
src/components/lazy-tex.tsx
Normal 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} />
|
||||
),
|
||||
);
|
||||
}
|
@ -1,12 +1,11 @@
|
||||
import { Question } from '@/interfaces/question';
|
||||
import { InlineMath } from 'react-katex';
|
||||
import styles from '@/styles/Quiz.module.scss';
|
||||
import { MaybeTeX } from './lazy-tex';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface QuestionCardProps {
|
||||
question: Question;
|
||||
reveal: boolean;
|
||||
selected: number[];
|
||||
selected: number[] | number;
|
||||
onClick?: (answer: number) => void;
|
||||
}
|
||||
|
||||
@ -19,19 +18,21 @@ export default function QuestionCard({
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="text-xl text-gray-700">
|
||||
<span className="font-bold text-primary">
|
||||
A{question.id.toString().padStart(3, '0')}:{' '}
|
||||
<span className="font-medium text-primary">
|
||||
<span className="text-sm">#</span>
|
||||
{question.id.toString().padStart(3, '0')}:{' '}
|
||||
</span>
|
||||
<MaybeTeX text={question.question} />
|
||||
</div>
|
||||
|
||||
{question.image && (
|
||||
<Image
|
||||
className={styles.image}
|
||||
className="max-h-80 max-w-full object-contain"
|
||||
src={`/question_images/${question.image}`}
|
||||
alt={question.image}
|
||||
height={500}
|
||||
width={500}
|
||||
style={{ width: '100%', height: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -43,7 +44,9 @@ export default function QuestionCard({
|
||||
answer={answer}
|
||||
reveal={reveal}
|
||||
isCorrect={question.correct === i}
|
||||
isSelected={selected.includes(i)}
|
||||
isSelected={
|
||||
selected instanceof Array ? selected.includes(i) : selected === i
|
||||
}
|
||||
onClick={!onClick ? undefined : () => onClick(i)}
|
||||
/>
|
||||
))}
|
||||
@ -71,41 +74,21 @@ function Answer({
|
||||
}: AnswerProps) {
|
||||
return (
|
||||
<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
|
||||
? 'border-gray-300'
|
||||
? 'bg-light'
|
||||
: !reveal
|
||||
? 'border-sky-500 bg-sky-100'
|
||||
? 'bg-primary/30'
|
||||
: isCorrect
|
||||
? 'border-green-500 bg-green-100'
|
||||
: 'border-red-600 bg-red-100'
|
||||
}`}
|
||||
disabled={!onClick}
|
||||
? 'bg-green-200 text-green-950'
|
||||
: 'bg-red-200 text-red-950'
|
||||
} ${!onClick || isSelected ? 'cursor-default' : 'hover:bg-primary/10'}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{!reveal && <input type="radio" checked={isSelected} readOnly />}
|
||||
<div
|
||||
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">
|
||||
<div className="text-sm font-bold">{String.fromCharCode(65 + index)}</div>
|
||||
<div className="py-2 text-left text-base">
|
||||
<MaybeTeX text={answer} />
|
||||
</div>
|
||||
</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
15
src/components/tex.tsx
Normal 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 }} />;
|
||||
}
|
@ -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;
|
||||
}
|
@ -5,7 +5,7 @@
|
||||
@import 'katex/dist/katex.min.css';
|
||||
|
||||
.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 {
|
||||
@ -17,8 +17,7 @@
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
@apply py-12;
|
||||
}
|
||||
|
||||
main > :not(.section):last-child {
|
||||
@ -33,16 +32,10 @@ main > .section:last-child {
|
||||
}
|
||||
|
||||
.prose {
|
||||
h1 {
|
||||
@apply text-3xl font-semibold;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl font-semibold;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
@apply text-xl font-semibold;
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
a {
|
||||
|
@ -44,34 +44,43 @@ export function generateAllCallsigns({
|
||||
}
|
||||
|
||||
export function levenshteinDistance(from: string, to: string): number {
|
||||
const addWeight = 5;
|
||||
const removeWeight = 0;
|
||||
const addWeight = 1.6;
|
||||
const removeWeight = 0.4;
|
||||
const replaceWeight = 1;
|
||||
const swapWeight = 0.5;
|
||||
|
||||
from = from.toUpperCase();
|
||||
to = to.toUpperCase();
|
||||
|
||||
let prev: number[] = [];
|
||||
for (let i = 0; i < to.length + 1; i++) prev[i] = i;
|
||||
const table: number[][] = [[]];
|
||||
for (let i = 0; i < to.length + 1; i++) table[0][i] = i * addWeight;
|
||||
|
||||
for (let i = 1; i < from.length + 1; i++) {
|
||||
const curr: number[] = [];
|
||||
curr[0] = i;
|
||||
curr[0] = i * removeWeight;
|
||||
|
||||
for (let j = 1; j < to.length + 1; j++) {
|
||||
const cost =
|
||||
from[i - 1] === to[j - 1] || from[i - 1] === '*' ? 0 : replaceWeight;
|
||||
curr[j] = Math.min(
|
||||
prev[j] + removeWeight,
|
||||
table[i - 1][j] + removeWeight,
|
||||
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 {
|
||||
|
@ -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
36
tailwind.config.ts
Normal 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')],
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user