This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
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);
|
||||
};
|
||||
|
||||
// [x] 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="/"
|
||||
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,74 @@
|
||||
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('favoriteWords') || '[]');
|
||||
setIsFavorite(favoriteWords.includes(word));
|
||||
}, [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;
|
||||
430
06_js-debug/uebungen/u16_lexifind-exercise/src/index.css
Normal file
430
06_js-debug/uebungen/u16_lexifind-exercise/src/index.css
Normal file
@@ -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[];
|
||||
}
|
||||
24
06_js-debug/uebungen/u16_lexifind-exercise/src/main.tsx
Normal file
24
06_js-debug/uebungen/u16_lexifind-exercise/src/main.tsx
Normal file
@@ -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,66 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
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 [params] = useSearchParams();
|
||||
|
||||
const [font, setFont] = useState<string>(SERIF_FONTS);
|
||||
const [word, setWord] = useState<string>(params.get('word') || 'course'); // /?word=WERT
|
||||
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: unknown) {
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent<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;
|
||||
1
06_js-debug/uebungen/u16_lexifind-exercise/src/vite-env.d.ts
vendored
Normal file
1
06_js-debug/uebungen/u16_lexifind-exercise/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user