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",
"@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",

View File

@ -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() {

View File

@ -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,16 +65,25 @@ const useStore = create<IzpitQuizStore>((set) => ({
}));
export default function IzpitQuiz() {
const [state, questions, answers, correctCount, load, finish, reset] =
useStore((state) => [
state.state,
state.questions,
state.answers,
state.correctCount,
state.load,
state.finish,
state.reset,
]);
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,
]);
return (
<>
@ -101,18 +120,17 @@ 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),
)
}
>
Zaključi
</button>
<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>
);
}

View File

@ -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.

View File

@ -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>

View File

@ -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() {

View File

@ -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,

View File

@ -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>

View File

@ -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: {
description:
'Informacije o radioamaterstvu in izpitu za pridobitev licence',
},
title: 'Radioamaterstvo, izpit in dovoljenje',
description:
'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>

View File

@ -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() {

View File

@ -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;
}

View File

@ -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() {

View File

@ -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,70 +11,83 @@ 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} />
<div>
<Link href="/">
<h1 className="text-3xl sm:text-4xl">Radioamaterski izpit</h1>
</Link>
<div data-nosnippet className="morse mt-1 text-sm text-gray-400">
CQ|DE|HAMS
</div>
</div>
<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="/" className="text-2xl sm:text-3xl">
Radioamaterski izpit
</Link>
</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 className="bg-dark">
<nav className="container flex flex-row flex-wrap justify-start">
{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'
}`}
>
{label}
</Link>
) : (
<Dropdown key={label} label={label}>
{children?.map(({ href, label }) => (
<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'
}`}
>
{label}
</Link>
))}
</Dropdown>
),
)}
</nav>
<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-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}
</Link>
) : (
<Dropdown key={label} label={label}>
{children?.map(({ href, label }) => (
<Link
key={href}
href={href}
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}
</Link>
))}
</Dropdown>
),
)}
</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>
);
}

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 { 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
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';
.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 {

View File

@ -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 {

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