This commit is contained in:
@@ -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 =======
|
// === DOM & VARS =======
|
||||||
|
|||||||
+1
@@ -19,6 +19,7 @@ const Counter = (elOrSelector = null) => {
|
|||||||
|
|
||||||
// ===== INIT =====
|
// ===== INIT =====
|
||||||
const init = () => {
|
const init = () => {
|
||||||
|
// eslint-disable-next-line
|
||||||
btnEl.addEventListener('click', onClickIncrement);
|
btnEl.addEventListener('click', onClickIncrement);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+11
@@ -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';
|
var greeting = 'Hallo';
|
||||||
|
|
||||||
|
// Fehler: prefer-const (wird nie neu zugewiesen)
|
||||||
let name = 'Welt';
|
let name = 'Welt';
|
||||||
|
|
||||||
const airbnbLintExample = {
|
const airbnbLintExample = {
|
||||||
@@ -9,19 +16,23 @@ const airbnbLintExample = {
|
|||||||
return isActive ? 'online' : 'offline';
|
return isActive ? 'online' : 'offline';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Fehler: eqeqeq
|
||||||
isZero(value) {
|
isZero(value) {
|
||||||
return value == 0;
|
return value == 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Fehler: no-nested-ternary
|
||||||
role(isAdmin, isActive) {
|
role(isAdmin, isActive) {
|
||||||
return isAdmin ? (isActive ? 'Admin online' : 'Admin offline') : 'Gast';
|
return isAdmin ? (isActive ? 'Admin online' : 'Admin offline') : 'Gast';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Fehler: no-unused-vars
|
||||||
sum(a, b) {
|
sum(a, b) {
|
||||||
const result = a + b;
|
const result = a + b;
|
||||||
return a + b;
|
return a + b;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Fehler: no-plusplus
|
||||||
next(count) {
|
next(count) {
|
||||||
let value = count;
|
let value = count;
|
||||||
value++;
|
value++;
|
||||||
|
|||||||
+40
@@ -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;
|
||||||
+66
@@ -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()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user