This commit is contained in:
Philippe Torrel
2026-11-03 14:52:15 +01:00
parent ed2c0b9906
commit f475f40350
104 changed files with 12459 additions and 4509 deletions
@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
@@ -0,0 +1,10 @@
# Frontend Class: LexiFind Exercise Starter
Install the dependencies and devDependencies and start the server.
```bash
npm install
npm run dev
```
This will start the server on http://localhost:5173
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/LexiFind.webp" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LexiFind - Your Personal Dictionary</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.css" />
</head>
<body>
<div id="root"></div>
<div class="container py-5">
<div class="alert alert-primary" role="alert">
<h2>Übung 16: LexiFind Favorite Words</h2>
<p>
In dieser Übung musst du ein brandneues LexiFind mit einer neuen Funktion reparieren. Du kannst jetzt deine
Lieblingswörter direkt auf der Startseite speichern und alle deine Lieblingswörter auf einer separaten Seite
finden. Das ist richtig, LexiFind kommt jetzt mit Routing.
</p>
<p>
Wenn du auf das Herzsymbol auf der Startseite klickst, wird es zu deinen Favoriten hinzugefügt. Das leere
Herzsymbol wird ebenfalls ausgefüllt. Eine neue Seite namens Favoriten (/favorites) listet jetzt alle unsere
Lieblingswörter auf. Wenn du auf das Wort klickst, gelangst du zur Startseite mit dem Wort, das du angeklickt
hast.
</p>
<p>
Die App ist außerdem intelligent genug, um zu wissen, welche Lieblingswörter du ausgewählt hast. Alle
Lieblingswörter haben ein ausgefülltes Herzsymbol auf der Startseite und auf der Favoritenseite. Wenn du auf
ein gefülltes Herz klickst, wird es aus der Favoritenliste entfernt und das Herzsymbol wird leer.
</p>
<p>Aber aus irgendeinem Grund funktioniert der Code nicht so, wie er sollte...</p>
<ol class="list">
<li>
Wenn wir auf das Herzsymbol für ein Wort klicken, ist das Herz gefüllt. Aber wenn wir die Seite
aktualisieren, ist das Herz wieder leer. Es sollte ausgefüllt sein.
</li>
<li>
Das gleiche Verhalten können wir beobachten, wenn wir ein Wort mit einem Herz versehen und dann zur Seite
Favoriten gehen. Manchmal ist das Herz ausgefüllt, manchmal ist es leer. Sie sollten auf jeder Seite
ausgefüllt sein.
</li>
<l>
Wenn du auf das Wort in den Favoritenseiten klickst, z.B. "blue", kommst du auf die Startseite und es wird
"Course" statt "blue" angezeigt. Es sollte das richtige Wort angezeigt werden.
</l>
</ol>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,30 @@
{
"name": "lexifind-webmaster-exercise",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@types/react-router-dom": "^5.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"@vitejs/plugin-react": "^6.1.0",
"eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4",
"typescript": "^6.0.3",
"vite": "^8.2.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,57 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { HeartFilled, HeartIcon } from './Icons';
interface FavoriteWordProps {
word: string;
}
const FavoriteWord: React.FC<FavoriteWordProps> = ({ word }) => {
const [isFavorite, setIsFavorite] = useState(false);
const toggleFavorite = () => {
const favorites = JSON.parse(localStorage.getItem('favoriteWords') || '[]');
if (favorites.includes(word)) {
const filteredFavorites = favorites.filter(
(item: string) => item !== word
);
localStorage.setItem('favoriteWords', JSON.stringify(filteredFavorites));
} else {
favorites.push(word);
localStorage.setItem('favoriteWords', JSON.stringify(favorites));
}
setIsFavorite(!isFavorite);
};
// BUG: Missing useEffect
/* useEffect(() => {
const favoriteWords = JSON.parse(
localStorage.getItem('favoriteWords') || '[]'
);
setIsFavorite(favoriteWords.includes(word));
}, [word]); */
return (
<div className="fave-word-wrapper">
<Link
// to={`/?word=${word}`}
to="/" // BUG
className="fave-word-link"
>
<p>{word}</p>
</Link>
<button className="fave-icon-btn" onClick={toggleFavorite}>
{isFavorite ? (
<HeartFilled className="heart-icon-filled" aria-hidden="true" />
) : (
<HeartIcon className="heart-icon-unfilled" aria-hidden="true" />
)}
</button>
</div>
);
};
export default FavoriteWord;
@@ -0,0 +1,42 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { SERIF_FONTS, SANS_SERIF_FONTS } from '../lib/constants';
interface HeaderProps {
font: string;
setFont: React.Dispatch<React.SetStateAction<string>>;
}
const Header: React.FC<HeaderProps> = ({ font, setFont }) => {
return (
<header className="header-container">
<Link to="/" className="header-logo-wrapper">
<img
src="/LexiFind.webp"
alt="LexiFind Logo"
className="header-logo-img"
/>
<span className="header-logo-text">LexiFind</span>
</Link>
<div>
<Link to="/favorites" className="header-link-text">
Favorites
</Link>
</div>
<select
value={font}
onChange={(e) => setFont(e.target.value)}
className="header-select"
style={{ fontFamily: font }}
>
<option value={SERIF_FONTS}>Serif</option>
<option value={SANS_SERIF_FONTS}>Sans Serif</option>
</select>
</header>
);
};
export default Header;
@@ -0,0 +1,46 @@
export function HeartIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"
/>
</svg>
);
}
export function HeartFilled(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<path d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
</svg>
);
}
export function PlayIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
{...props}
>
<path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393" />
</svg>
);
}
@@ -0,0 +1,59 @@
import React from 'react';
interface SearchbarProps {
word: string;
setWord: React.Dispatch<React.SetStateAction<string>>;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
}
const Searchbar: React.FC<SearchbarProps> = ({
word,
setWord,
handleSubmit,
}) => {
return (
<div className="searchbar-wrapper">
<form onSubmit={handleSubmit}>
<label htmlFor="searchWord" className="screen-reader-text">
Search word
</label>
<div className="searchbar-container">
<input
type="text"
name="searchWord"
id="searchWord"
className="searchbar-input"
placeholder="Type a word to search"
value={word}
onChange={(e) => setWord(e.target.value)}
/>
<button
type="submit"
className="searchbar-magnifying-glass"
aria-label="Search"
>
<SearchIcon className="searchbar-icon" aria-hidden="true" />
</button>
</div>
</form>
</div>
);
};
function SearchIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
{...props}
>
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0" />
</svg>
);
}
export default Searchbar;
@@ -0,0 +1,51 @@
import React from 'react';
interface SourceFooterProps {
source: string;
}
const SourceFooter: React.FC<SourceFooterProps> = ({ source }) => {
return (
<>
<div className="footer-divider"></div>
<div className="footer-wrapper">
<h4 className="footer-title">Source</h4>
<div>
<a href={source} className="footer-link-wrapper">
{source}
<BoxArrowUpRightIcon
className="footer-link-icon"
aria-hidden="true"
/>
</a>
</div>
</div>
</>
);
};
function BoxArrowUpRightIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
{...props}
>
<path
fillRule="evenodd"
d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5"
/>
<path
fillRule="evenodd"
d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0z"
/>
</svg>
);
}
export default SourceFooter;
@@ -0,0 +1,66 @@
import React from 'react';
import { Meaning, WordData } from '../lib/datatypes';
interface WordDefinitionsProps {
wordData: WordData;
}
const WordDefinitions: React.FC<WordDefinitionsProps> = ({ wordData }) => {
return (
<div className="word-definitions-wrapper">
{wordData.meanings.map((meaning: any, index: number) => (
<WordDefinitionBlock key={index} data={meaning} />
))}
</div>
);
};
interface WordDefinitionBlockProps {
data: Meaning;
}
const WordDefinitionBlock: React.FC<WordDefinitionBlockProps> = ({ data }) => {
const { partOfSpeech, definitions, synonyms } = data;
return (
<div className="word-block-wrapper">
<div className="word-block-category-container">
<h2 className="word-block-category-name">{partOfSpeech}</h2>
<div className="word-block-category-divider"></div>
</div>
<div className="word-block-meaning-container">
<h3 className="word-block-meaning-title">Meaning</h3>
<ul className="word-block-meaning-list">
{definitions.map((definition: any, index: number) => (
<li key={index} className="word-block-meaning-list-item">
{definition.definition}{' '}
{definition.example && (
<span className="word-block-meaning-list-item-example">
"{definition.example}"
</span>
)}
</li>
))}
</ul>
</div>
{synonyms.length > 0 && (
<div className="word-block-synonyms-container">
<h3 className="word-block-synonyms-title">Synonyms</h3>
<ul className="word-block-synonyms-list">
{synonyms.map((synonym: string, index: number) => (
<li key={index} className="word-block-synonyms-list-item">
{synonym}
</li>
))}
</ul>
</div>
)}
</div>
);
};
export default WordDefinitions;
@@ -0,0 +1,82 @@
import React, { useState, useEffect, useRef } from 'react';
import { PlayIcon, HeartIcon, HeartFilled } from './Icons';
import { Phonetic } from '../lib/datatypes';
interface WordHeroProps {
data: {
word: string;
phonetics: Phonetic[];
};
}
const WordHero: React.FC<WordHeroProps> = ({ data }) => {
const { word, phonetics } = data;
const [isFavorite, setIsFavorite] = useState(false);
const americanEnglishPhonetic = phonetics.find((phonetic) =>
phonetic.audio?.includes('-us')
);
const finalPhonetic = americanEnglishPhonetic || phonetics[0];
const audioRef = useRef<HTMLAudioElement | null>(null);
const playAudio = () => {
if (audioRef.current) {
audioRef.current.play();
}
};
const toggleFavorite = () => {
const favorites = JSON.parse(localStorage.getItem('favoriteWords') || '[]');
if (favorites.includes(word)) {
const filteredFavorites = favorites.filter(
(item: string) => item !== word
);
localStorage.setItem('favoriteWords', JSON.stringify(filteredFavorites));
} else {
favorites.push(word);
localStorage.setItem('favoriteWords', JSON.stringify(favorites));
}
setIsFavorite(!isFavorite);
};
useEffect(() => {
const favoriteWords = JSON.parse(
localStorage.getItem('favortieWords') || '[]' // BUG favortieWords instead of (favoriteWords)
);
// setIsFavorite(favoriteWords.includes(word)); // BUG
}, [word]);
return (
<div className="wordhero-wrapper">
<div className="wordhero-word-container">
<button className="heart-icon-btn" onClick={toggleFavorite}>
{isFavorite ? (
<HeartFilled className="heart-icon-filled" aria-hidden="true" />
) : (
<HeartIcon className="heart-icon-unfilled" aria-hidden="true" />
)}
</button>
<h1 className="wordhero-word">{word}</h1>
{finalPhonetic?.text && (
<p className="wordhero-word-phonetic">{finalPhonetic.text}</p>
)}
</div>
{finalPhonetic?.audio && (
<div>
<button onClick={playAudio} className="wordhero-play-btn">
<PlayIcon className="wordhero-play-icon" aria-hidden="true" />
</button>
<audio ref={audioRef} src={finalPhonetic.audio}></audio>
</div>
)}
</div>
);
};
export default WordHero;
@@ -0,0 +1,430 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
*,
html,
body {
margin: 0;
padding: 0;
color: #171717;
}
button {
border: none;
}
a {
text-decoration: none;
}
.screen-reader-text {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.heart-icon-btn {
background-color: transparent;
margin-right: auto;
cursor: pointer;
}
.heart-icon-unfilled {
width: 2rem; /* 32px */
height: 2rem; /* 32px */
stroke: #9333ea;
background-color: transparent;
}
.heart-icon-filled {
width: 2rem; /* 32px */
height: 2rem; /* 32px */
fill: #9333ea;
background-color: transparent;
}
/* ===== App Component ===== */
.app-wrapper {
margin: 0 auto;
max-width: 42rem; /* 672px */
padding: 3.5rem 1.5rem; /* 56px 24px */
}
/* ===== Header Component ===== */
.header-container {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-logo-wrapper {
display: flex;
align-items: center;
}
.header-logo-img {
height: 2.5rem; /* 40px */
}
.header-logo-text {
font-family: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
font-size: 1.875rem; /* 30px */
line-height: 2.25rem; /* 36px */
font-weight: 600;
margin-left: 1rem; /* 16px */
color: #262626;
}
.header-select {
border-radius: 0.375rem; /* 6px */
border-width: 0px;
padding: 0.625rem 0.5rem; /* 10px 8px */
font-size: 1.125rem; /* 18px */
line-height: 1.75rem; /* 28px */
outline: 2px solid transparent;
outline-offset: 2px;
color: #262626;
}
.header-select:focus {
outline-color: #9333ea;
}
.header-link-text {
font-size: 1.125rem; /* 18px */
line-height: 1.75rem; /* 28px */
text-decoration: none;
color: #262626;
}
.header-link-text:hover {
color: #9333ea;
}
/* ===== Searchbar Component ===== */
.searchbar-wrapper {
margin-top: 4rem; /* 64px */
}
.searchbar-container {
position: relative;
margin-top: 0.5rem; /* 8px */
border-radius: 0.75rem; /* 12px */
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
.searchbar-input {
display: block;
width: 100%;
border-radius: 0.75rem; /* 12px */
border-width: 0px;
padding: 1.25rem 0 1.25rem 1.5rem; /* 20px 0 20px 24px */
font-size: 1.125rem; /* 18px */
line-height: 1.75rem; /* 28px */
font-weight: 600;
outline-style: solid;
outline: 2px solid transparent;
outline-color: #f5f5f5;
color: #171717;
background-color: #f5f5f5;
}
.searchbar-input:focus {
outline-color: #9333ea;
}
.searchbar-input::placeholder {
color: #737373;
}
.searchbar-magnifying-glass {
position: absolute;
top: 0px;
bottom: 0px;
right: 0px;
display: flex;
align-items: center;
border-top-right-radius: 0.75rem; /* 12px */
border-bottom-right-radius: 0.75rem; /* 12px */
padding-left: 1rem; /* 16px */
padding-right: 1rem; /* 16px */
outline: 2px solid transparent;
outline-offset: 2px;
background-color: transparent;
}
.searchbar-icon {
width: 1.5rem; /* 24px */
height: 1.5rem; /* 24px */
fill: #6b21a8;
}
/* ===== WordHero Component ===== */
.wordhero-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 4rem; /* 64px */
}
.wordhero-word-container {
display: flex;
flex-direction: column;
}
.wordhero-word {
font-size: 3.75rem; /* 60px */
line-height: 1;
font-weight: 600;
color: #262626;
}
.wordhero-word-phonetic {
font-size: 1.5rem; /* 24px */
line-height: 2rem; /* 32px */
margin-top: 0.75rem; /* 12px */
margin-bottom: 0.75rem; /* 12px */
color: #9333ea;
}
.wordhero-play-btn {
display: flex;
align-items: center;
border-radius: 9999px;
padding: 1.25rem; /* 20px */
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
color: white;
background-color: #e9d5ff;
}
.wordhero-play-btn:hover {
background-color: #d8b4fe;
}
.wordhero-play-btn:focus {
outline: 2px solid transparent;
outline-color: #9333ea;
}
.wordhero-play-icon {
height: 2.5rem; /* 40px */
width: 2.5rem; /* 40px */
fill: #9333ea;
}
/* ===== WordDefinitions Component ===== */
.word-definitions-wrapper {
display: flex;
flex-direction: column;
gap: 2rem; /* 32px */
margin-top: 4rem; /* 64px */
}
.word-block-wrapper {
display: flex;
flex-direction: column;
gap: 3.5rem; /* 56px */
}
.word-block-category-container {
display: flex;
align-items: center;
gap: 0.5rem; /* 8px */
}
.word-block-category-name {
padding-right: 0.75rem; /* 12px */
font-size: 1.5rem; /* 24px */
line-height: 2rem; /* 32px */
font-weight: 600;
color: #262626;
}
.word-block-category-divider {
height: 1px;
width: 100%;
background-color: #d4d4d4;
}
.word-block-meaning-container {
display: flex;
flex-direction: column;
row-gap: 1rem; /* 16px */
}
.word-block-meaning-title {
font-size: 1.25rem; /* 20px */
line-height: 1.75rem; /* 28px */
font-weight: 400;
color: #737373;
}
.word-block-meaning-list {
display: flex;
flex-direction: column;
padding-left: 1rem; /* 16px */
font-size: 1.125rem; /* 18px */
line-height: 1.75rem; /* 28px */
row-gap: 2rem; /* 32px */
color: #262626;
}
.word-block-meaning-list-item::marker {
padding-left: 2.5rem; /* 40px */
color: #9333ea;
}
.word-block-meaning-list-item-example {
display: block;
margin-top: 0.5rem; /* 8px */
color: #9333ea;
}
.word-block-synonyms-container {
display: flex;
align-items: baseline;
column-gap: 2rem; /* 32px */
}
.word-block-synonyms-title {
font-size: 1.25rem; /* 20px */
line-height: 1.75rem; /* 28px */
font-weight: 400;
color: #737373;
}
.word-block-synonyms-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem; /* 8px */
}
.word-block-synonyms-list-item {
list-style: none;
font-size: 1.125rem; /* 18px */
line-height: 1.75rem; /* 28px */
font-weight: 600;
color: #9333ea;
}
/* ===== SourceFooter Component ===== */
.footer-divider {
margin-top: 2.5rem; /* 40px */
margin-bottom: 2.5rem; /* 40px */
height: 1px;
background-color: #e5e5e5;
}
.footer-wrapper {
display: flex;
align-items: baseline;
gap: 1rem; /* 16px */
font-size: 0.875rem; /* 14px */
line-height: 1.25rem; /* 20px */
}
.footer-title {
font-weight: 400;
color: #737373;
}
.footer-link-wrapper {
display: flex;
text-decoration: underline;
color: #262626;
}
.footer-link-icon {
margin-left: 0.5rem; /* 8px */
height: 1rem; /* 16px */
width: 1rem; /* 16px */
fill: #262626;
}
/* ===== Favorites Page ===== */
.favorites-list {
margin-top: 1rem; /* 16px */
border-top-width: 2px;
border-bottom-width: 0px;
border-color: rgb(229 229 229);
}
.favorites-text {
margin-top: 4rem; /* 64px */
font-size: 1.125rem; /* 18px */
line-height: 1.75rem; /* 28px */
color: #525252;
}
.favorites-item {
list-style: none;
padding: 0.5rem 0; /* Add some padding to each item */
}
.favorites-item.with-divider {
border-top: 1px solid #e5e5e5; /* Add divider */
}
/* ===== Favorite Word ===== */
.fave-word-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2.5rem 0; /* 40px 0 */
}
.fave-word-link {
background-color: #e9d5ff;
padding: 0.5rem 0.875rem; /* 8px 14px */
border-radius: 0.375rem; /* 6px */
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
.fave-word-link:hover {
background-color: #d8b4fe;
}
.fave-word-link:focus {
outline: 2px solid #9333ea;
}
.fave-word-link p {
font-size: 1.25rem; /* 20px */
line-height: 1.75rem; /* 28px */
font-weight: 500;
color: #262626;
}
.fave-icon-btn {
background-color: transparent;
cursor: pointer;
}
/* ===== Breakpoints ===== */
@media (min-width: 640px) {
.searchbar-input {
line-height: 1.5rem; /* 24px */
}
}
@media (min-width: 768px) {
.word-block-meaning-list {
padding-left: 2.5rem; /* 40px */
}
}
@media (min-width: 1024px) {
.app-wrapper {
padding: 3.5rem 0.5rem; /* 56px 8px */
}
}
@@ -0,0 +1,5 @@
export const SERIF_FONTS =
'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif';
export const SANS_SERIF_FONTS =
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"';
@@ -0,0 +1,22 @@
export interface Phonetic {
text?: string;
audio?: string;
}
export interface Definition {
definition: string;
example?: string;
}
export interface Meaning {
partOfSpeech: string;
definitions: Definition[];
synonyms: string[];
}
export interface WordData {
word: string;
phonetics: Phonetic[];
meanings: Meaning[];
sourceUrls: string[];
}
@@ -0,0 +1,24 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import Root from './pages/root.tsx';
import Favorites from './pages/favorites.tsx';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
},
{
path: '/favorites',
element: <Favorites />,
},
]);
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
@@ -0,0 +1,46 @@
import { useState, useEffect } from 'react';
import Header from '../components/Header';
import FavoriteWord from '../components/FavoriteWord';
import { SERIF_FONTS } from '../lib/constants';
function Favorites() {
const [font, setFont] = useState<string>(SERIF_FONTS);
const [favoriteWords, setFavoriteWords] = useState<string[]>([]);
useEffect(() => {
const loadedFavorites = JSON.parse(
localStorage.getItem('favoriteWords') || '[]'
);
setFavoriteWords(loadedFavorites);
}, []);
return (
<div
className="app-wrapper"
style={{
fontFamily: font,
}}
>
<Header font={font} setFont={setFont} />
{favoriteWords.length > 0 ? (
<ul className="favorites-list">
{favoriteWords.map((word, index) => (
<li
className={`favorites-item ${index > 0 ? 'with-divider' : ''}`}
key={index}
>
<FavoriteWord word={word} />
</li>
))}
</ul>
) : (
<p className="favorites-text">No favorite words added yet.</p>
)}
</div>
);
}
export default Favorites;
@@ -0,0 +1,60 @@
import React, { useState, useEffect } from 'react';
import { WordData } from '../lib/datatypes';
import Header from '../components/Header';
import Searchbar from '../components/Searchbar';
import WordHero from '../components/WordHero';
import WordDefinitions from '../components/WordDefinitions';
import SourceFooter from '../components/SourceFooter';
import { SERIF_FONTS } from '../lib/constants';
function Root() {
const [font, setFont] = useState<string>(SERIF_FONTS);
const [word, setWord] = useState<string>('course');
const [wordData, setWordData] = useState<WordData | null>(null);
const fetchData = async () => {
try {
const url = `https://api.dictionaryapi.dev/api/v2/entries/en/${word}`;
const response = await fetch(url);
const data = await response.json();
setWordData(data[0]);
} catch (error) {
console.error(error);
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
fetchData();
};
useEffect(() => {
fetchData();
}, []);
return (
<div
className="app-wrapper"
style={{
fontFamily: font,
}}
>
<Header font={font} setFont={setFont} />
<Searchbar word={word} setWord={setWord} handleSubmit={handleSubmit} />
{wordData && (
<div>
<WordHero data={wordData} />
<WordDefinitions wordData={wordData} />
<SourceFooter source={wordData.sourceUrls[0]} />
</div>
)}
</div>
);
}
export default Root;
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})