finished project

This commit is contained in:
nameishappy
2023-08-03 21:20:53 +05:30
commit 73651d6058
65 changed files with 15611 additions and 0 deletions

21
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,21 @@
/* eslint-env node */
module.exports = {
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
"no-unused-vars": ["off"]
},
}

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends":"react-app"
}

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

100
data/cities.json Normal file
View File

@@ -0,0 +1,100 @@
{
"cities": [
{
"cityName": "Lisbon",
"country": "Portugal",
"emoji": "🇵🇹",
"date": "2027-10-31T15:59:59.138Z",
"notes": "My favorite city so far!",
"position": {
"lat": 38.727881642324164,
"lng": -9.140900099907554
},
"id": 73930385
},
{
"cityName": "Muneville-le-Bingard",
"country": "France",
"emoji": "🇫🇷",
"date": "2023-07-24T12:43:14.903Z",
"notes": "",
"position": {
"lat": "49.1549385508535",
"lng": "-1.450192918262432"
},
"id": 98443210
},
{
"cityName": "Tachilek",
"country": "Myanmar",
"emoji": "🇲🇲",
"date": "2023-07-25T05:36:52.300Z",
"notes": "",
"position": {
"lat": "20.47168563901091",
"lng": "99.70512653573469"
},
"id": 98443211
},
{
"cityName": "Amborompotsy",
"country": "Madagascar",
"emoji": "🇲🇬",
"date": "2023-07-25T05:44:10.656Z",
"notes": "",
"position": {
"lat": "-20.868672459730085",
"lng": "46.142629530132155"
},
"id": 98443215
},
{
"cityName": "Gibeon",
"country": "Namibia",
"emoji": "🇳🇦",
"date": "2023-07-25T05:44:17.206Z",
"notes": "",
"position": {
"lat": "-24.279471155061238",
"lng": "16.96304591111141"
},
"id": 98443216
},
{
"cityName": "Luni Junction",
"country": "India",
"emoji": "🇮🇳",
"date": "2023-08-03T05:20:38.419Z",
"notes": "such a nice place would love to go there again",
"position": {
"lat": "26.15565523194215",
"lng": "73.1250312372971"
},
"id": 98443218
},
{
"cityName": "Khan Sahib",
"country": "India",
"emoji": "🇮🇳",
"date": "2023-08-03T05:21:37.486Z",
"notes": "",
"position": {
"lat": "33.90726362193601",
"lng": "74.70705467324112"
},
"id": 98443219
},
{
"cityName": "Yavatmal",
"country": "India",
"emoji": "🇮🇳",
"date": "2023-08-03T15:45:19.272Z",
"notes": "nice",
"position": {
"lat": "20.307043551027597",
"lng": "78.04687500000001"
},
"id": 98443220
}
]
}

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WorldWise : Track ypur journey...</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

13731
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "worldwise",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"server": "json-server --watch data/cities.json --port 8000 "
},
"dependencies": {
"json-server": "^0.17.3",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-datepicker": "^4.16.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.14.2"
},
"devDependencies": {
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@vitejs/plugin-react": "^4.0.1",
"eslint": "^8.45.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1",
"vite": "^4.4.0",
"vite-plugin-eslint": "^1.8.1"
}
}

BIN
public/bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
public/img-1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
public/img-2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

50
src/App.jsx Normal file
View File

@@ -0,0 +1,50 @@
import React, { useContext } from "react";
import { useState } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import Product from "./pages/Product";
import Homepage from "./pages/Homepage";
import Pricing from "./pages/Pricing";
import PageNotFound from "./pages/PageNotFound";
import Login from "./pages/Login";
import AppLayout from "./pages/AppLayout";
import CityList from "./components/CityList";
import CountryList from "./components/CountryList";
import City from "./components/City";
import Form from "./components/Form";
import { CitiesProvider } from "./contexts/CitiesContext";
import { AuthProvider } from "./contexts/FakeAuthcontext";
import ProtectedRoute from "./pages/ProtectedRoute";
const App = () => {
return (
<AuthProvider>
<CitiesProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Homepage />} />
<Route path="Product" element={<Product />} />
<Route path="Pricing" element={<Pricing />} />
<Route path="login" element={<Login />} />
<Route
path="app"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route index element={<Navigate replace to="cities" />} />
<Route path="cities" element={<CityList />} />
<Route path="cities/:id" element={<City />} />
<Route path="countries" element={<CountryList />} />
<Route path="form" element={<Form />} />
</Route>
<Route path="*" element={<PageNotFound />} />
</Routes>
</BrowserRouter>
</CitiesProvider>
</AuthProvider>
);
};
export default App;

20
src/components/AppNav.jsx Normal file
View File

@@ -0,0 +1,20 @@
import React from 'react'
import styles from './AppNav.module.css'
import { Link, NavLink } from 'react-router-dom'
const AppNav = () => {
return (
<nav className={styles.nav}>
<ul>
<li>
<NavLink to="cities" >cities</NavLink>
</li>
<li>
<NavLink to="countries" >Countries</NavLink>
</li>
</ul>
</nav>
)
}
export default AppNav

View File

@@ -0,0 +1,28 @@
.nav {
margin-top: 3rem;
margin-bottom: 2rem;
}
.nav ul {
list-style: none;
display: flex;
background-color: var(--color-dark--2);
border-radius: 7px;
}
.nav a:link,
.nav a:visited {
display: block;
color: inherit;
text-decoration: none;
text-transform: uppercase;
font-size: 1.2rem;
font-weight: 700;
padding: 0.5rem 2rem;
border-radius: 5px;
}
/* CSS Modules feature */
.nav a:global(.active) {
background-color: var(--color-dark--0);
}

View File

@@ -0,0 +1,21 @@
import React from "react";
import Button from "./Button";
import { useNavigate } from "react-router-dom";
const BackButton = () => {
const navigate=useNavigate();
return (
<Button
type="back"
onClick={(e) => {
e.preventDefault();
navigate(-1);
}}
>
&larr; Back
</Button>
);
};
export default BackButton;

12
src/components/Button.jsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react'
import styles from './Button.module.css'
const Button = ({children,onClick,type}) => {
return (
<button onClick={onClick} className={`${styles.btn} ${styles[type]}`}>
{children}
</button>
)
}
export default Button

View File

@@ -0,0 +1,35 @@
.btn {
color: inherit;
text-transform: uppercase;
padding: 0.8rem 1.6rem;
font-family: inherit;
font-size: 1.5rem;
border: none;
border-radius: 5px;
cursor: pointer;
}
.primary {
font-weight: 700;
background-color: var(--color-brand--2);
color: var(--color-dark--1);
}
.back {
font-weight: 600;
background: none;
border: 1px solid currentColor;
}
.position {
font-weight: 700;
position: absolute;
z-index: 1000;
font-size: 1.4rem;
bottom: 4rem;
left: 50%;
transform: translateX(-50%);
background-color: var(--color-brand--2);
color: var(--color-dark--1);
box-shadow: 0 0.4rem 1.2rem rgba(36, 42, 46, 0.16);
}

86
src/components/City.jsx Normal file
View File

@@ -0,0 +1,86 @@
import { useParams } from "react-router-dom";
import styles from "./City.module.css";
import { useSearchParams } from "react-router-dom";
import { useEffect } from "react";
import { useCities } from "../contexts/CitiesContext";
import ButtonBack from "./Button";
import Spinner from "./Spinner";
import BackButton from "./BackButton";
const formatDate = (date) =>
new Intl.DateTimeFormat("en", {
day: "numeric",
month: "long",
year: "numeric",
weekday: "long",
}).format(new Date(date));
function City() {
const { id } = useParams();
const { currentCity, getCity ,isLoading} = useCities();
useEffect(
function () {
getCity(id);
console.log(id);
console.log(currentCity)
},
[id]
);
const {cityName, emoji, date, notes}=currentCity; // Declare variables in the outer scope
const [search, setSearchParams] = useSearchParams();
const lat = search.get("lat");
const lng = search.get("lng");
if(isLoading){
return <Spinner/>
}
else{
return (
<div className={styles.city}>
<div className={styles.row}>
<h6>City name</h6>
<h3>
{/* <span>{emoji}</span> */}
{cityName}
</h3>
</div>
<div className={styles.row}>
<h6>You went to {cityName} on</h6>
<p>{formatDate(date || null)}</p>
</div>
{notes && (
<div className={styles.row}>
<h6>Your notes</h6>
<p>{notes}</p>
</div>
)}
<div className={styles.row}>
<h6>Learn more</h6>
<a
href={`https://en.wikipedia.org/wiki/${cityName}`}
target="_blank"
rel="noreferrer"
>
Check out {cityName} on Wikipedia &rarr;
</a>
</div>
<div>
<BackButton/>
</div>
</div>
);
}
}
export default City;

View File

@@ -0,0 +1,47 @@
.city {
padding: 2rem 3rem;
max-height: 70%;
background-color: var(--color-dark--2);
border-radius: 7px;
overflow: scroll;
width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
}
.row {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.city h6 {
text-transform: uppercase;
font-size: 1.1rem;
font-weight: 900;
color: var(--color-light--1);
}
.city h3 {
font-size: 1.9rem;
display: flex;
align-items: center;
gap: 1rem;
}
.city h3 span {
font-size: 3.2rem;
line-height: 1;
}
.city p {
font-size: 1.6rem;
}
.city a:link,
.city a:visited {
font-size: 1.6rem;
color: var(--color-brand--1);
}

View File

@@ -0,0 +1,38 @@
import React from "react";
import styles from "./CityItem.module.css";
import { Link } from "react-router-dom";
import { useCities } from "../contexts/CitiesContext";
import Spinner from "./Spinner";
const formatDate = (date) =>
new Intl.DateTimeFormat("en", {
day: "numeric",
month: "long",
year: "numeric",
weekday: "long",
}).format(new Date(date));
export default function CityItem({ city }) {
const {currentCity,isLoading,deleteCity}=useCities();
const { cityName, emoji, date,position,id } = city;
function handleClick(e){
e.preventDefault();
deleteCity(id);
}
return (
<div >
<ul>
<li>
<Link className={`${styles.cityItem} ${(id===currentCity.id)?styles["cityItem--active"]:''}`} to={`${city.id}?lat=${position.lat}&lng=${position.lng}`}>
<span className={styles.emoji}>{emoji}</span>
<h3 className={styles.name}>{cityName}</h3>
<time className={styles.date}>{formatDate(date)}</time>
<button className={styles.deleteBtn} onClick={handleClick}>&times;</button>
</Link>
</li>
</ul>
</div>
);
}

View File

@@ -0,0 +1,54 @@
.cityItem,
.cityItem:link,
.cityItem:visited {
display: flex;
gap: 1.6rem;
align-items: center;
background-color: var(--color-dark--2);
border-radius: 7px;
padding: 1rem 2rem;
border-left: 5px solid var(--color-brand--2);
cursor: pointer;
color: inherit;
text-decoration: none;
}
.cityItem--active {
border: 2px solid var(--color-brand--2);
border-left: 5px solid var(--color-brand--2);
}
.emoji {
font-size: 2.6rem;
line-height: 1;
}
.name {
font-size: 1.7rem;
font-weight: 600;
margin-right: auto;
}
.date {
font-size: 1.5rem;
}
.deleteBtn {
height: 2rem;
aspect-ratio: 1;
border-radius: 50%;
border: none;
background-color: var(--color-dark--1);
color: var(--color-light--2);
font-size: 1.6rem;
font-weight: 400;
cursor: pointer;
transition: all 0.2s;
}
.deleteBtn:hover {
background-color: var(--color-brand--1);
color: var(--color-dark--1);
}

View File

@@ -0,0 +1,27 @@
import React from "react";
import styles from "./CityList.module.css";
import Spinner from "./Spinner";
import CityItem from "./CityItem";
import Message from "./Message";
import { useCities } from "../contexts/CitiesContext";
const CityList = () => {
const {cities,isLoading}=useCities();
if (isLoading) {
return <Spinner />;
}
if(!cities.length){
return <Message message={"Add your first city by clicking on the map"}/>;
}
return (
<ul className={styles.cityList}>
{cities.map((city) => (
<CityItem city={city} key={city.id} />
))}
</ul>
);
};
export default CityList;

View File

@@ -0,0 +1,15 @@
.cityList {
width: 100%;
height: 65vh;
list-style: none;
overflow-y: scroll;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 1.4rem;
}
.cityList::-webkit-scrollbar {
width: 0;
}

View File

@@ -0,0 +1,12 @@
import styles from "./CountryItem.module.css";
function CountryItem({ country }) {
return (
<li className={styles.countryItem}>
{/* <span>{country.emoji}</span> */}
<span>{country}</span>
</li>
);
}
export default CountryItem;

View File

@@ -0,0 +1,19 @@
.countryItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
font-size: 1.7rem;
font-weight: 600;
background-color: var(--color-dark--2);
border-radius: 7px;
padding: 1rem 2rem;
border-left: 5px solid var(--color-brand--1);
}
.countryItem span:first-child {
font-size: 3rem;
line-height: 1;
}

View File

@@ -0,0 +1,35 @@
import React from "react";
import styles from "./CountryList.module.css";
import Spinner from "./Spinner";
import CountryItem from "./CountryItem";
import Message from "./Message";
import { useCities } from "../contexts/CitiesContext";
const CountryList = () => {
const {cities,isLoading}=useCities();
if (isLoading) {
return <Spinner />;
}
if(!cities.length){
return <Message message={"Add your first city by clicking on the map"}/>;
}
const countries = cities.reduce((countries, city) => {
if (!countries.includes(city.country)) {
countries.push(city.country);
}
return countries;
}, []);;
console.log(cities);
return (
<ul className={styles.countryList}>
{countries.map((country) => (
<CountryItem country={country} key={country} />
))}
</ul>
);
};
export default CountryList;

View File

@@ -0,0 +1,12 @@
.countryList {
width: 100%;
height: 65vh;
list-style: none;
overflow-y: scroll;
overflow-x: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
align-content: start;
gap: 1.6rem;
}

132
src/components/Form.jsx Normal file
View File

@@ -0,0 +1,132 @@
// "https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=0&longitude=0"
import { useEffect, useState } from "react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import styles from "./Form.module.css";
import Button from "./Button";
import BackButton from "./BackButton";
import { useUrlPosition } from "../hooks/useUrlPosition";
import Message from "./Message";
import Spinner from "./Spinner";
import { useCities } from "../contexts/CitiesContext";
import { useNavigate } from "react-router-dom";
export function convertToEmoji(countryCode) {
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt());
return String.fromCodePoint(...codePoints);
}
function Form() {
const [cityName, setCityName] = useState("");
const {createCity,isLoading}=useCities();
const [country, setCountry] = useState("");
const [date, setDate] = useState(new Date());
const [notes, setNotes] = useState("");
const [isGeocodingLoading, setIsGeocodingLoading] = useState(false);
const [lat, lng] = useUrlPosition();
const [emoji, setEmoji] = useState("");
const [geocodingError, setGeocodingError] = useState(null);
const navigate=useNavigate();
useEffect(
function () {
if (!lat || !lng) {
return;
}
async function fetchData() {
setGeocodingError(null);
try {
setIsGeocodingLoading(true);
const data = await fetch(
`https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lng}`
);
const res = await data.json();
if (res.countryCode === "") {
throw new Error(
"That doesn't seems to be a country. Click somewhere else"
);
}
setCityName(res.city || res.locality || "");
setCountry(res.countryName || "");
setEmoji(convertToEmoji(res.countryCode));
} catch (err) {
setGeocodingError(err.message);
} finally {
setIsGeocodingLoading(false);
}
}
fetchData();
},
[lat, lng]
);
async function handleSubmit(e) {
e.preventDefault();
if(!cityName || !date) return;
const newCity = {
cityName,
country,
emoji,
date,
notes,
position: { lat, lng }
};
await createCity(newCity);
navigate("/app/cities")
}
if (!lat || !lng) {
return <Message message="Click on the map to select a location" />;
}
if (isGeocodingLoading) {
return <Spinner />;
}
if (geocodingError) {
return <Message message={geocodingError} />;
}
return (
<form className={`${styles.form} ${isLoading?styles.loading : ""} `} onSubmit={handleSubmit}>
<div className={styles.row}>
<label htmlFor="cityName">City name</label>
<input
id="cityName"
onChange={(e) => setCityName(e.target.value)}
value={cityName}
/>
<span className={styles.flag}>{emoji}</span>
</div>
<div className={styles.row}>
<label htmlFor="date">When did you go to {cityName}?</label>
<DatePicker
id="date"
selected={date}
onChange={(date) => setDate(date)}
dateFormat="dd/MM/yyyy"
/>
</div>
<div className={styles.row}>
<label htmlFor="notes">Notes about your trip to {cityName}</label>
<textarea
id="notes"
onChange={(e) => setNotes(e.target.value)}
value={notes}
/>
</div>
<div className={styles.buttons}>
<Button type="primary">Add</Button>
<BackButton />
</div>
</form>
);
}
export default Form;

View File

@@ -0,0 +1,44 @@
.form {
background-color: var(--color-dark--2);
border-radius: 7px;
padding: 2rem 3rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
}
.row {
display: flex;
flex-direction: column;
gap: 0.5rem;
position: relative;
}
.buttons {
display: flex;
justify-content: space-between;
}
.flag {
position: absolute;
right: 1rem;
top: 2.7rem;
font-size: 2.8rem;
}
.form.loading {
opacity: 0.3;
}
.form.loading button {
pointer-events: none;
background-color: var(--color-light--1);
border: 1px solid var(--color-light--1);
color: var(--color-dark--0);
}
:global(.react-datepicker) {
font-family: inherit;
font-size: 1.2rem;
}

12
src/components/Logo.jsx Normal file
View File

@@ -0,0 +1,12 @@
import { Link } from "react-router-dom";
import styles from "./Logo.module.css";
function Logo() {
return (
<Link to="/">
<img src="/logo.png" alt="WorldWise logo" className={styles.logo} />
</Link>
);
}
export default Logo;

View File

@@ -0,0 +1,3 @@
.logo {
height: 5.2rem;
}

80
src/components/Map.jsx Normal file
View File

@@ -0,0 +1,80 @@
import { React, useEffect, useState } from "react";
import styles from "./Map.module.css";
import { useNavigate, useSearchParams } from "react-router-dom";
import { MapContainer, Marker, Popup, TileLayer, useMap, useMapEvent } from "react-leaflet";
import { useCities } from "../contexts/CitiesContext";
import {useGeolocation} from "../hooks/useGeolocation";
import Button from "./Button";
import { useUrlPosition } from "../hooks/useUrlPosition";
const Map = () => {
const { cities } = useCities();
const [mapPosition, setMapPosition] = useState([20.5937, 78.9629]);
const [mapLat,mapLng]=useUrlPosition();
const {isLoading:isLoadingGeolocation,position:geoLocationPosition,getPosition}=useGeolocation();
useEffect(() => {
if (mapLat && mapLng) {
setMapPosition([mapLat, mapLng]);
}
}, [mapLat, mapLng]);
useEffect(() => {
if (geoLocationPosition) {
setMapPosition([geoLocationPosition.lat, geoLocationPosition.lng]);
}
},[geoLocationPosition])
return (
<div className={styles.mapContainer}>
{<Button type="position" onClick={getPosition} disabled={isLoadingGeolocation}>
{ !isLoadingGeolocation?"Use your position":"Loading..."}
</Button>}
<div className={styles.map}>
<MapContainer
// center={[mapLat,mapLng]}
center={mapPosition}
zoom={6}
scrollWheelZoom={true}
className={styles.map}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png"
/>
{cities.map((city) => {
return (
<Marker position={[city.position.lat, city.position.lng]} key={city.id}>
<Popup>
{city.cityName}
<br /> {city.country}
</Popup>
</Marker>
);
})}
<DetectClick/>
<ChangeCenter position={mapPosition} />
</MapContainer>
</div>
</div>
);
};
function ChangeCenter({position}){
const map=useMap();
map.setView(position);
return null;
}
function DetectClick(){
const navigate=useNavigate();
useMapEvent(
{click:(e)=>navigate(`form?lat=${e.latlng.lat}&lng=${e.latlng.lng}`)}
)
}
export default Map;

View File

@@ -0,0 +1,38 @@
.mapContainer {
flex: 1;
height: 100%;
background-color: var(--color-dark--2);
position: relative;
}
.map {
height: 100%;
}
/* Here we want to style classes that are coming from leaflet. So we want CSS Modules to give us the ACTUAL classnames, not to add some random ID to them, because then they won't match the classnames defined inside the map. The solution is to define these classes as GLOBAL */
:global(.leaflet-popup .leaflet-popup-content-wrapper) {
background-color: var(--color-dark--1);
color: var(--color-light--2);
border-radius: 5px;
padding-right: 0.6rem;
}
:global(.leaflet-popup .leaflet-popup-content) {
font-size: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
:global(.leaflet-popup .leaflet-popup-content span:first-child) {
font-size: 2.5rem;
line-height: 1;
}
:global(.leaflet-popup .leaflet-popup-tip) {
background-color: var(--color-dark--1);
}
:global(.leaflet-popup-content-wrapper) {
border-left: 5px solid var(--color-brand--2);
}

View File

@@ -0,0 +1,11 @@
import styles from "./Message.module.css";
function Message({ message }) {
return (
<p className={styles.message}>
<span role="img">👋</span> {message}
</p>
);
}
export default Message;

View File

@@ -0,0 +1,7 @@
.message {
text-align: center;
font-size: 1.8rem;
width: 80%;
margin: 2rem auto;
font-weight: 600;
}

View File

@@ -0,0 +1,24 @@
import React from 'react'
import Logo from './Logo'
import styles from "./PageNav.module.css"
import { NavLink } from 'react-router-dom'
const PageNav = () => {
return (
<nav className={styles.nav}>
<Logo/>
<ul>
<li>
<NavLink to="/pricing" >Pricing</NavLink>
</li>
<li>
<NavLink to="/product" >Product</NavLink>
</li>
<li>
<NavLink to="/login" className={styles.ctaLink} >Login</NavLink>
</li>
</ul>
</nav>
)
}
export default PageNav

View File

@@ -0,0 +1,34 @@
.nav {
display: flex;
align-items: center;
justify-content: space-between;
}
.nav ul {
list-style: none;
display: flex;
align-items: center;
gap: 4rem;
}
.nav a:link,
.nav a:visited {
text-decoration: none;
color: var(--color-light--2);
text-transform: uppercase;
font-size: 1.5rem;
font-weight: 600;
}
/* CSS Modules feature */
.nav a:global(.active) {
color: var(--color-brand--2);
}
a.ctaLink:link,
a.ctaLink:visited {
background-color: var(--color-brand--2);
color: var(--color-dark--0);
padding: 0.8rem 2rem;
border-radius: 7px;
}

View File

@@ -0,0 +1,25 @@
import React from 'react'
import AppNav from "./AppNav"
import Logo from './Logo'
import styles from "./Sidebar.module.css"
import { Outlet } from 'react-router-dom'
const Sidebar = () => {
return (
<div className={styles.sidebar}>
<Logo/>
<AppNav/>
<Outlet/>
<footer className={styles.footer}>
<p className={styles.copyright}>
&copy; Copyright {new Date().getFullYear()} by WorldWise corp
</p>
</footer>
</div>
)
}
export default Sidebar

View File

@@ -0,0 +1,19 @@
.sidebar {
flex-basis: 56rem;
background-color: var(--color-dark--1);
padding: 3rem 5rem 3.5rem 5rem;
display: flex;
flex-direction: column;
align-items: center;
height: calc(100vh - 4.8rem);
}
.footer {
margin-top: auto;
}
.copyright {
font-size: 1.2rem;
color: var(--color-light--1);
}

View File

@@ -0,0 +1,11 @@
import styles from "./Spinner.module.css";
function Spinner() {
return (
<div className={styles.spinnerContainer}>
<div className={styles.spinner}></div>
</div>
);
}
export default Spinner;

View File

@@ -0,0 +1,21 @@
.spinnerContainer {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
width: 6rem;
height: 6rem;
border-radius: 50%;
background: conic-gradient(#0000 10%, var(--color-light--2));
-webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 8px), #000 0);
animation: rotate 1.5s infinite linear;
}
@keyframes rotate {
to {
transform: rotate(1turn);
}
}

View File

@@ -0,0 +1,12 @@
import Spinner from "./Spinner";
import styles from "./SpinnerFullPage.module.css";
function SpinnerFullPage() {
return (
<div className={styles.spinnerFullpage}>
<Spinner />
</div>
);
}
export default SpinnerFullPage;

View File

@@ -0,0 +1,5 @@
.spinnerFullpage {
margin: 2.5rem;
height: calc(100vh - 5rem);
background-color: var(--color-dark--1);
}

42
src/components/User.jsx Normal file
View File

@@ -0,0 +1,42 @@
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/FakeAuthcontext";
import styles from "./User.module.css";
const FAKE_USER = {
name: "Jack",
email: "jack@example.com",
password: "qwerty",
avatar: "https://i.pravatar.cc/100?u=zz",
};
function User() {
const navigate=useNavigate();
const {user,logout} = useAuth();
function handleClick() {
logout();
navigate("/");
}
return (
<div className={styles.user}>
<img src={user.avatar} alt={user.name} />
<span>Welcome, {user.name}</span>
<button onClick={handleClick}>Logout</button>
</div>
);
}
export default User;
/*
CHALLENGE
1) Add `AuthProvider` to `App.jsx`
2) In the `Login.jsx` page, call `login()` from context
3) Inside an effect, check whether `isAuthenticated === true`. If so, programatically navigate to `/app`
4) In `User.js`, read and display logged in user from context (`user` object). Then include this component in `AppLayout.js`
5) Handle logout button by calling `logout()` and navigating back to `/`
*/

View File

@@ -0,0 +1,34 @@
.user {
position: absolute;
top: 4.2rem;
right: 4.2rem;
background-color: var(--color-dark--1);
padding: 1rem 1.4rem;
border-radius: 7px;
z-index: 999;
box-shadow: 0 0.8rem 2.4rem rgba(36, 42, 46, 0.5);
font-size: 1.6rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 1.6rem;
}
.user img {
border-radius: 100px;
height: 4rem;
}
.user button {
background-color: var(--color-dark--2);
border-radius: 7px;
border: none;
padding: 0.6rem 1.2rem;
color: inherit;
font-family: inherit;
font-size: 1.2rem;
font-weight: 700;
text-transform: uppercase;
cursor: pointer;
}

View File

@@ -0,0 +1,145 @@
import {
useEffect,
createContext,
useState,
useContext,
useReducer,
} from "react";
const CitiesContext = createContext();
const initialState = {
cities: [],
isLoading: false,
currentCity: {},
error: "",
};
function reducer(state, action) {
switch (action.type) {
case "loading":
return { ...state, isLoading: true };
case "cities/loaded":
return { ...state, isLoading: false,cities: action.payload };
case "city/loaded":
return { ...state, currentCity: action.payload, isLoading: false };
case "cities/created":
return {
...state,
cities: [...state.cities, action.payload],
isLoading: false,
currentCity:action.payload
};
case "cities/deleted":
return {
...state,
isLoading: false,
cities: state.cities.filter((city) => city.id !== action.payload),
currentCity:{}
};
case "rejected":
return { ...state, isLoading: false, error: action.payload };
default:
throw new Error(`Action not supported`);
}
}
function CitiesProvider({ children }) {
// const [cities, setCities] = useState([]);
// const [isLoading, setIsLoading] = useState(false);
// const [currentCity,setCurrentCity]=useState({});
const [state, dispatch] = useReducer(reducer,initialState);
const { cities, isLoading, currentCity } = state;
useEffect(function () {
async function fetchData() {
dispatch({ type: "loading" });
try {
const data = await fetch("http://localhost:8000/cities");
const res = await data.json();
dispatch({ type:"cities/loaded", payload: res });
} catch {
dispatch({
type: "rejected",
payload: "There was an error loading the cities",
});
}
}
fetchData();
}, []);
async function getCity(id) {
if(Number(id)===currentCity.id) return;
dispatch({ type: "loading" });
try {
const data = await fetch(`http://localhost:8000/cities/${id}`);
const res = await data.json();
dispatch({ type: "city/loaded", payload: res });
} catch {
dispatch({
type: "rejected",
payload: "There was an error loading the city",
});
}
}
async function createCity(newCity) {
dispatch({ type: "loading" });
try {
const data = await fetch(`http://localhost:8000/cities`, {
method: "POST",
body: JSON.stringify(newCity),
headers: {
"Content-Type": "application/json",
},
});
const res = await data.json();
dispatch({ type: "cities/created", payload: res });
} catch {
dispatch({
type: "rejected",
payload: "There was an error loading the city",
});
}
}
async function deleteCity(id) {
dispatch({ type: "loading" });
try {
const data = await fetch(`http://localhost:8000/cities/${id}`, {
method: "DELETE",
});
dispatch({ type: "cities/deleted", payload: id });
} catch {
dispatch({
type: "rejected",
payload: "There was an error deleting the city",
});
}
}
return (
<CitiesContext.Provider
value={{
cities,
isLoading,
currentCity,
getCity,
createCity,
deleteCity,
}}
>
{children}
</CitiesContext.Provider>
);
}
function useCities() {
const context = useContext(CitiesContext);
return context;
}
export { CitiesProvider, useCities };

View File

@@ -0,0 +1,57 @@
import { createContext, useContext, useReducer } from "react";
const AuthContext = createContext();
const initialState = {
user: null,
isAuthenticated: false,
};
function reducer(state, action) {
switch (action.type) {
case "login":
return { ...state, user: action.payload, isAuthenticated: true };
case "logout":
return { ...state, user: null, isAuthenticated: false };
default:
throw new Error("Unknown action");
}
}
const FAKE_USER = {
name: "Jack",
email: "jack@example.com",
password: "qwerty",
avatar: "https://i.pravatar.cc/100?u=zz",
};
function AuthProvider({ children }) {
const [{ user, isAuthenticated }, dispatch] = useReducer(
reducer,
initialState
);
function login(email, password) {
if (email === FAKE_USER.email && password === FAKE_USER.password)
dispatch({ type: "login", payload: FAKE_USER });
}
function logout() {
dispatch({ type: "logout" });
}
return (
<AuthContext.Provider value={{ user, isAuthenticated, login, logout }}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const context = useContext(AuthContext);
if (context === undefined)
throw new Error("AuthContext was used outside AuthProvider");
return context;
}
export { AuthProvider, useAuth };

View File

@@ -0,0 +1,29 @@
import { useState } from "react";
export function useGeolocation(defaultPosition=null) {
const [isLoading, setIsLoading] = useState(false);
const [position, setPosition] = useState(defaultPosition);
const [error, setError] = useState(null);
function getPosition() {
if (!navigator.geolocation)
return setError("Your browser does not support geolocation");
setIsLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setPosition({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
});
setIsLoading(false);
},
(error) => {
setError(error.message);
setIsLoading(false);
}
);
}
return { isLoading, position, error, getPosition };
}

View File

@@ -0,0 +1,8 @@
import { useSearchParams } from "react-router-dom";
export function useUrlPosition() {
const [search] = useSearchParams();
const lat = search.get("lat");
const lng = search.get("lng");
return [lat,lng];
}

78
src/index.css Normal file
View File

@@ -0,0 +1,78 @@
/* Taken from getting started guide at: https://leafletjs.com/examples/quick-start/ */
@import "https://unpkg.com/leaflet@1.7.1/dist/leaflet.css";
@import "https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap";
/* These CSS variables are global, so they are available in all CSS modules */
:root {
--color-brand--1: #ffb545;
--color-brand--2: #00c46a;
--color-dark--0: #242a2e;
--color-dark--1: #2d3439;
--color-dark--2: #42484d;
--color-light--1: #aaa;
--color-light--2: #ececec;
--color-light--3: #d6dee0;
}
* {
margin: 0;
padding: 0;
box-sizing: inherit;
}
html {
font-size: 62.5%;
box-sizing: border-box;
}
body {
font-family: "Manrope", sans-serif;
color: var(--color-light--2);
font-weight: 400;
line-height: 1.6;
}
label {
font-size: 1.6rem;
font-weight: 600;
}
input,
textarea {
width: 100%;
padding: 0.8rem 1.2rem;
font-family: inherit;
font-size: 1.6rem;
border: none;
border-radius: 5px;
background-color: var(--color-light--3);
transition: all 0.2s;
}
input:focus {
outline: none;
background-color: #fff;
}
.cta:link,
.cta:visited {
display: inline-block;
background-color: var(--color-brand--2);
color: var(--color-dark--1);
text-transform: uppercase;
text-decoration: none;
font-size: 1.6rem;
font-weight: 600;
padding: 1rem 3rem;
border-radius: 5px;
}
/*
"importCSSModule": {
"prefix": "csm",
"scope": "javascript,typescript,javascriptreact",
"body": ["import styles from './${TM_FILENAME_BASE}.module.css'"],
"description": "Import CSS Module as `styles`"
}, */

11
src/main.jsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

22
src/pages/AppLayout.jsx Normal file
View File

@@ -0,0 +1,22 @@
import React from 'react'
import Sidebar from '../components/Sidebar'
import styles from './AppLayout.module.css'
import Map from '../components/Map'
import User from '../components/User'
import { useAuth } from '../contexts/FakeAuthcontext'
const AppLayout = () => {
const {isAuthenticated}=useAuth();
return (
<div className={styles.app}>
<Sidebar/>
<Map/>
{isAuthenticated && <User/>}
</div>
)
}
export default AppLayout

View File

@@ -0,0 +1,7 @@
.app {
height: 100vh;
padding: 2.4rem;
overscroll-behavior-y: none;
display: flex;
position: relative;
}

24
src/pages/Homepage.jsx Normal file
View File

@@ -0,0 +1,24 @@
import { Link } from "react-router-dom";
import styles from "./Homepage.module.css";
import PageNav from "../components/PageNav";
export default function Homepage() {
return (
<main className={styles.homepage}>
<PageNav/>
<section>
<h1>
You travel the world.
<br />
WorldWise keeps track of your adventures.
</h1>
<h2>
A world map that tracks your footsteps into every city you can think
of. Never forget your wonderful experiences, and show your friends how
you have wandered the world.
</h2>
<Link to='/login' className="cta">Start Tracking Now</Link>
</section>
</main>
);
}

View File

@@ -0,0 +1,34 @@
.homepage {
height: calc(100vh - 5rem);
margin: 2.5rem;
background-image: linear-gradient(
rgba(36, 42, 46, 0.8),
rgba(36, 42, 46, 0.8)
),
url("../bg.jpg");
background-size: cover;
background-position: center;
padding: 2.5rem 5rem;
}
.homepage section {
display: flex;
flex-direction: column;
height: 85%;
align-items: center;
justify-content: center;
gap: 2.5rem;
text-align: center;
}
.homepage h1 {
font-size: 4.5rem;
line-height: 1.3;
}
.homepage h2 {
width: 90%;
font-size: 1.9rem;
color: var(--color-light--1);
margin-bottom: 2.5rem;
}

58
src/pages/Login.jsx Normal file
View File

@@ -0,0 +1,58 @@
import PageNav from "../components/PageNav";
import React, { useEffect, useState } from 'react';
import styles from "./Login.module.css";
import {useAuth} from "../contexts/FakeAuthcontext";
import { useNavigate } from "react-router-dom";
import Button from "../components/Button";
export default function Login() {
// PRE-FILL FOR DEV PURPOSES
const [email, setEmail] = useState("jack@example.com");
const [password, setPassword] = useState("qwerty");
const {login,isAuthenticated}=useAuth();
const navigate=useNavigate();
function handleSubmit(e){
e.preventDefault();
if(email && password) login(email,password);
}
useEffect(()=>{
if(isAuthenticated){
console.log("isAuthenticated",isAuthenticated)
navigate("/app");
}
},[isAuthenticated])
return (
<main className={styles.login}>
<PageNav/>
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.row}>
<label htmlFor="email">Email address</label>
<input
type="email"
id="email"
onChange={(e) => setEmail(e.target.value)}
value={email}
/>
</div>
<div className={styles.row}>
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
onChange={(e) => setPassword(e.target.value)}
value={password}
/>
</div>
<div>
<Button type='primary'>Login</Button>
</div>
</form>
</main>
);
}

View File

@@ -0,0 +1,26 @@
.login {
margin: 2.5rem;
padding: 2.5rem 5rem;
background-color: var(--color-dark--1);
min-height: calc(100vh - 5rem);
}
.form {
background-color: var(--color-dark--2);
border-radius: 7px;
padding: 2rem 3rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
/* Different from other form */
width: 48rem;
margin: 8rem auto;
}
.row {
display: flex;
flex-direction: column;
gap: 0.5rem;
}

View File

@@ -0,0 +1,7 @@
export default function PageNotFound() {
return (
<div>
<h1>Page not found 😢</h1>
</div>
);
}

26
src/pages/Pricing.jsx Normal file
View File

@@ -0,0 +1,26 @@
// Uses the same styles as Product
import PageNav from "../components/PageNav";
import styles from "./Product.module.css";
export default function Product() {
return (
<main className={styles.product}>
<PageNav/>
<section>
<div>
<h2>
Simple pricing.
<br />
Just $9/month.
</h2>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Vitae vel
labore mollitia iusto. Recusandae quos provident, laboriosam fugit
voluptatem iste.
</p>
</div>
<img src="img-2.jpg" alt="overview of a large city with skyscrapers" />
</section>
</main>
);
}

30
src/pages/Product.jsx Normal file
View File

@@ -0,0 +1,30 @@
import PageNav from "../components/PageNav";
import styles from "./Product.module.css";
export default function Product() {
return (
<main className={styles.product}>
<PageNav/>
<section>
<img
src="img-1.jpg"
alt="person with dog overlooking mountain with sunset"
/>
<div>
<h2>About WorldWide.</h2>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Illo est
dicta illum vero culpa cum quaerat architecto sapiente eius non
soluta, molestiae nihil laborum, placeat debitis, laboriosam at fuga
perspiciatis?
</p>
<p>
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Corporis
doloribus libero sunt expedita ratione iusto, magni, id sapiente
sequi officiis et.
</p>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,34 @@
.product {
margin: 2.5rem;
padding: 2.5rem 5rem;
background-color: var(--color-dark--1);
min-height: calc(100vh - 5rem);
}
.product section {
width: clamp(80rem, 80%, 90rem);
margin: 6rem auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7rem;
align-items: center;
}
.product img {
width: 100%;
}
.product h2 {
font-size: 4rem;
line-height: 1.2;
margin-bottom: 3rem;
}
.product p {
font-size: 1.6rem;
margin-bottom: 2rem;
}
.product section a {
margin-top: 2rem;
}

View File

@@ -0,0 +1,17 @@
import { useEffect } from "react";
import { useAuth } from "../contexts/FakeAuthcontext";
import { useNavigate } from "react-router-dom";
function ProtectedRoute({children}){
const navigate=useNavigate();
const {isAuthenticated}=useAuth();
useEffect(function(){
if(!isAuthenticated) navigate("/");
},[isAuthenticated,navigate])
return isAuthenticated? children:null;
}
export default ProtectedRoute;

7
vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// import eslint from "vite-plugin-eslint"
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})