This commit is contained in:
Philippe Torrel
2026-11-04 13:21:50 +01:00
parent 2cd098e5a2
commit 3ae7bb13b8
33 changed files with 4087 additions and 1 deletions
@@ -1,4 +1,5 @@
import Counter from './modules/Counter.js'; // wichtig mit Dateiendung, da kein Bundler
// import Counter from './modules/Counter.js'; // wichtig mit Dateiendung, wenn kein Bundler
import Counter from './modules/Counter'; // keine Dateiendung, da esbuild Bundler
(() => {
// === DOM & VARS =======
@@ -19,6 +19,7 @@ const Counter = (elOrSelector = null) => {
// ===== INIT =====
const init = () => {
// eslint-disable-next-line
btnEl.addEventListener('click', onClickIncrement);
};
@@ -1,6 +1,13 @@
// Demo für typische Airbnb-Meldungen. Diese Datei wird nicht gebündelt.
// Die ESLint-Erweiterung markiert die fehlerhaften Zeilen direkt im Editor.
// npm run lint zeigt dieselben Meldungen im Terminal.
// npm run dev zeigt sie in der Seite und in der Browser-Konsole.
// npm run lint:fix behebt, was automatisch behebbar ist (z. B. var, ==, let).
// Fehler: no-var
var greeting = 'Hallo';
// Fehler: prefer-const (wird nie neu zugewiesen)
let name = 'Welt';
const airbnbLintExample = {
@@ -9,19 +16,23 @@ const airbnbLintExample = {
return isActive ? 'online' : 'offline';
},
// Fehler: eqeqeq
isZero(value) {
return value == 0;
},
// Fehler: no-nested-ternary
role(isAdmin, isActive) {
return isAdmin ? (isActive ? 'Admin online' : 'Admin offline') : 'Gast';
},
// Fehler: no-unused-vars
sum(a, b) {
const result = a + b;
return a + b;
},
// Fehler: no-plusplus
next(count) {
let value = count;
value++;
@@ -0,0 +1,40 @@
const greeting = 'Hallo';
const name = 'Welt';
const airbnbLintExample = {
// gültig
status(isActive) {
return isActive ? 'online' : 'offline';
},
isZero(value) {
return value === 0;
},
role(isAdmin, isActive) {
let result = 'Gast';
if (isAdmin) {
result = isActive ? 'Admin online' : 'Admin offline';
}
return result;
// return isAdmin ? (isActive ? 'Admin online' : 'Admin offline') : 'Gast';
},
sum(a, b) {
// const result = a + b;
return a + b;
},
next(count) {
const value = count + 1;
// value++;
return value;
},
greet() {
return `${greeting} ${name}`;
},
};
export default airbnbLintExample;
@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/sum-array.js"
}
]
}
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JS Debug</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" />
<script src="sum-array.js" defer></script>
</head>
<body>
<main>
<div class="container py-5">
<h1>JS Debug</h1>
</div>
</main>
</body>
</html>
@@ -0,0 +1,27 @@
{
"name": "02_debug-vscode",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "02_debug-vscode",
"version": "1.0.0",
"dependencies": {
"chalk": "^6.0.1"
}
},
"node_modules/chalk": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.1.tgz",
"integrity": "sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==",
"license": "MIT",
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
}
}
}
@@ -0,0 +1,14 @@
{
"name": "02_debug-vscode",
"version": "1.0.0",
"description": "",
"main": "sum-array.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"type": "module",
"dependencies": {
"chalk": "^6.0.1"
}
}
@@ -0,0 +1,13 @@
import chalk from 'chalk'; // node spezifisches Modul
function sumArray(arr) {
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i];
}
return sum;
}
console.log(chalk.yellow(sumArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])));
@@ -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,15 @@
<!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>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -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,53 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { HeartFilled, HeartIcon } from './Icons';
import { FAVORITE_WORDS } from '../lib/constants';
interface FavoriteWordProps {
word: string;
}
const FavoriteWord: React.FC<FavoriteWordProps> = ({ word }) => {
const [isFavorite, setIsFavorite] = useState(false);
const toggleFavorite = () => {
const favorites = JSON.parse(localStorage.getItem(FAVORITE_WORDS) || '[]');
if (favorites.includes(word)) {
const filteredFavorites = favorites.filter((item: string) => item !== word);
localStorage.setItem(FAVORITE_WORDS, JSON.stringify(filteredFavorites));
} else {
favorites.push(word);
localStorage.setItem(FAVORITE_WORDS, JSON.stringify(favorites));
}
setIsFavorite(!isFavorite);
};
// BUG: Missing useEffect
useEffect(() => {
const favoriteWords = JSON.parse(localStorage.getItem(FAVORITE_WORDS) || '[]');
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,80 @@
import React, { useState, useEffect, useRef } from 'react';
import { PlayIcon, HeartIcon, HeartFilled } from './Icons';
import { Phonetic } from '../lib/datatypes';
import { FAVORITE_WORDS } from '../lib/constants';
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];
console.log(finalPhonetic?.audio);
const audioRef = useRef<HTMLAudioElement | null>(null);
const playAudio = () => {
if (audioRef.current) {
audioRef.current.play();
}
};
const toggleFavorite = () => {
const favorites = JSON.parse(localStorage.getItem(FAVORITE_WORDS) || '[]');
if (favorites.includes(word)) {
const filteredFavorites = favorites.filter((item: string) => item !== word);
localStorage.setItem(FAVORITE_WORDS, JSON.stringify(filteredFavorites));
} else {
favorites.push(word);
localStorage.setItem(FAVORITE_WORDS, JSON.stringify(favorites));
}
setIsFavorite(!isFavorite);
};
useEffect(() => {
const favoriteWords = JSON.parse(
localStorage.getItem(FAVORITE_WORDS) || '[]', // BUG favortieWords instead of (favoriteWords)
);
setIsFavorite(favoriteWords.includes(word)); // BUG
}, [word]); // did update
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,6 @@
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"';
export const FAVORITE_WORDS = 'favoriteWords';
@@ -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,40 @@
import { useState, useEffect } from 'react';
import Header from '../components/Header';
import FavoriteWord from '../components/FavoriteWord';
import { FAVORITE_WORDS, 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(FAVORITE_WORDS) || '[]');
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,62 @@
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';
import { useSearchParams } from 'react-router-dom';
function Root() {
const [params] = useSearchParams();
const [font, setFont] = useState<string>(SERIF_FONTS);
const [word, setWord] = useState<string>(params.get('word') || '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()],
})