├── .eslintrc.json ├── .gitignore ├── README.md ├── actions ├── getCities.tsx ├── getCreateRandomCity.tsx ├── getCurrentUser.tsx └── getSession.tsx ├── app ├── api │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ ├── cities │ │ └── [cityId] │ │ │ └── route.ts │ ├── city │ │ └── route.ts │ └── register │ │ └── route.ts ├── app │ ├── app.module.css │ ├── cities │ │ ├── [cityId] │ │ │ └── page.tsx │ │ └── page.tsx │ ├── countries │ │ └── page.tsx │ ├── form │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── components ├── AppNav │ ├── AppNav.jsx │ └── AppNav.module.css ├── AuthForm │ ├── AuthForm.module.css │ └── AuthForm.tsx ├── BackButton.tsx ├── Button │ ├── Button.module.css │ └── Button.tsx ├── City │ ├── City.module.css │ └── City.tsx ├── CityItem │ ├── CityItem.module.css │ └── CityItem.tsx ├── CityList │ ├── CityList.module.css │ └── CityList.tsx ├── CountryItem │ ├── CountryItem.module.css │ └── CountryItem.tsx ├── CountryList │ ├── CountryList.module.css │ └── CountryList.tsx ├── Form │ ├── Form.module.css │ └── Form.tsx ├── Map │ ├── Map.module.css │ └── Map.tsx ├── Message │ ├── Message.module.css │ └── Message.tsx ├── Sidebar │ ├── Sidebar.module.css │ └── Sidebar.tsx ├── Spinner │ ├── Spinner.module.css │ └── Spinner.tsx ├── SpinnerFullPage │ ├── SpinnerFullPage.module.css │ └── SpinnerFullPage.tsx └── User │ ├── User.module.css │ └── User.tsx ├── context ├── CitiesContext.tsx └── SessionContextProvider.tsx ├── hooks ├── useGeolocation.tsx └── useUrlPosition.tsx ├── libs ├── prisma.ts └── utils.ts ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prisma └── schema.prisma ├── providers └── FlagIconProviders.tsx ├── public ├── bg.jpg ├── forGithub │ ├── 1.PNG │ ├── 2.PNG │ ├── 3.PNG │ ├── 4.PNG │ ├── 5.PNG │ └── 6.PNG ├── icon.png ├── img-1.jpg ├── img-2.jpg ├── logo.png ├── next.svg └── vercel.svg ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

WorldWise in Next.js, Typescript, Next-Auth and Prisma

5 |
6 |
7 |

8 | Converted/Completed, WorldWise app, previously built only in React, which didn't included real database connection nor it included authentication system. 9 |

10 | 11 | 12 | 13 | 14 | 15 | 16 |
10pic-light10pic13mobile
17 |

18 | 19 | contributors 20 | 21 | 22 | last update 23 | 24 | 25 | forks 26 | 27 | 28 | stars 29 | 30 | 31 | open issues 32 | 33 |

34 | 35 |

36 | Demo not avaliable 37 | · 38 | Documentation 39 | · 40 | Report Bug 41 | · 42 | Request Feature 43 |

44 |
45 | 46 |
47 | 48 | ## Features 49 | 50 | 1. **Interactive Map for Saved Trips:** 51 | - Visualize all your trips on a world map with markers for each saved location. 52 | 53 | 2. **Location detection upon click on map:** 54 | - Uses geolocation APIs to auto-detect the city, country, and region when you select a spot on the map and Provides options for manual adjustments, adding detailed notes, and setting travel dates for each trip. 55 | 56 | 3. **Next-Auth Authentication/Registration:** 57 | - Secure authentication using Next-Auth, supporting credential authentication and registration for users. 58 | 59 | 4. **Database Powered by Neon.tech:** 60 | - Utilizes Neon.tech for a scalable and performant database, with Prisma as the ORM for efficient data management. 61 | 62 |
63 | 64 | ## Installation 65 | 66 | - Clone the repository: 67 | 68 | ```bash 69 | git clone https://github.com/TsotnePharsenadze/worldwise-react-nextjs-typescript 70 | ``` 71 | 72 | - Navigate to the project directory: 73 | 74 | ```bash 75 | cd discord 76 | ``` 77 | 78 | - Install the dependencies: 79 | 80 | ```bash 81 | npm install 82 | ``` 83 | 84 | - Create .env file and setup all the neccessary env variables (Neon.tech database and secret for Next-Auth) 85 | 86 | ``` 87 | DATABASE_URL="" 88 | AUTH_SECRET="" 89 | ``` 90 | 91 | - Set up Neon.tech and generate/push Prisma models: 92 | 93 | 1. Open new terminal and exec `npx prisma generate` 94 | 2. then `npx prisma db push` 95 | 96 |
97 | 98 | ## Usage 99 | 100 | - Start the development server: 101 | 102 | ```bash 103 | npm run dev 104 | ``` 105 | 106 | - Open your browser and visit `http://localhost:3000` to access the application. 107 | 108 |
109 | 110 | ## :camera: Screenshots 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 |
10pic-light10pic13mobile
11pic-light11pic1mobile
123 | 124 |
125 | 126 | ## Contributing 127 | 128 | Contributions are welcome! If you want to contribute to this project, please follow these steps: 129 | 130 | - Fork the repository. 131 | - Create a new branch for your feature or bug fix. 132 | - Commit your changes to the new branch. 133 | - Open a pull request back to the main repository, including a description of your changes. 134 | -------------------------------------------------------------------------------- /actions/getCities.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/libs/prisma"; 4 | 5 | import getSession from "./getSession"; 6 | 7 | export default async function getCities() { 8 | const session = await getSession(); 9 | 10 | if (!session?.user?.email) { 11 | return null; 12 | } 13 | 14 | try { 15 | const currentUser = await prisma.user.findMany({ 16 | where: { 17 | email: session.user.email as string, 18 | }, 19 | }); 20 | 21 | if (!currentUser) { 22 | return null; 23 | } 24 | 25 | const cities = await prisma.city.findMany({ 26 | where: { 27 | userId: currentUser[0].id, 28 | }, 29 | include: { 30 | position: true, 31 | }, 32 | }); 33 | 34 | return cities; 35 | } catch (error: any) { 36 | return null; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /actions/getCreateRandomCity.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export async function getCreateRandomCity() { 4 | await axios.post("/api/city", { 5 | data: { 6 | cityName: "San Francisco", 7 | country: "USA", 8 | emoji: "🌉", 9 | date: "2024-11-01T12:00:00.000Z", 10 | notes: 11 | "Famous for the Golden Gate Bridge, cable cars, and Alcatraz Island.", 12 | lat: 37.7749, 13 | lng: -122.4194, 14 | }, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /actions/getCurrentUser.tsx: -------------------------------------------------------------------------------- 1 | import { prisma } from "@/libs/prisma"; 2 | 3 | import getSession from "./getSession"; 4 | 5 | export default async function getCurrentUser() { 6 | const session = await getSession(); 7 | 8 | if (!session?.user?.email) { 9 | return null; 10 | } 11 | 12 | try { 13 | const currentUser = await prisma.user.findUnique({ 14 | where: { 15 | email: session.user.email as string, 16 | }, 17 | }); 18 | 19 | if (!currentUser) { 20 | return null; 21 | } 22 | 23 | return currentUser; 24 | } catch (error: any) { 25 | return null; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /actions/getSession.tsx: -------------------------------------------------------------------------------- 1 | import { authOptions } from "@/app/api/auth/[...nextauth]/route"; 2 | import { getServerSession } from "next-auth"; 3 | 4 | export default async function getSession() { 5 | return getServerSession(authOptions); 6 | } 7 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcryptjs"; 2 | import NextAuth, { AuthOptions } from "next-auth"; 3 | import CredentialsProvider from "next-auth/providers/credentials"; 4 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 5 | 6 | import { prisma } from "@/libs/prisma"; 7 | 8 | export const authOptions: AuthOptions = { 9 | adapter: PrismaAdapter(prisma), 10 | providers: [ 11 | CredentialsProvider({ 12 | name: "credentials", 13 | credentials: { 14 | email: { label: "email", type: "text" }, 15 | password: { label: "password", type: "password" }, 16 | }, 17 | async authorize(credentials) { 18 | if (!credentials?.email || !credentials?.password) { 19 | throw new Error("Invalid Credentials"); 20 | } 21 | 22 | const user = await prisma.user.findUnique({ 23 | where: { 24 | email: credentials.email, 25 | }, 26 | }); 27 | 28 | if (!user || !user?.hashedPassword) { 29 | throw new Error("Invalid credentials"); 30 | } 31 | 32 | const isCorrectPassword = await bcrypt.compare( 33 | credentials.password, 34 | user.hashedPassword 35 | ); 36 | 37 | if (!isCorrectPassword) { 38 | throw new Error("Invalid credentials"); 39 | } 40 | 41 | return user; 42 | }, 43 | }), 44 | ], 45 | debug: process.env.NODE_ENV === "development", 46 | session: { 47 | strategy: "jwt", 48 | }, 49 | secret: process.env.NEXTAUTH_SECRET, 50 | }; 51 | 52 | const handler = NextAuth(authOptions); 53 | 54 | export { handler as GET, handler as POST }; 55 | -------------------------------------------------------------------------------- /app/api/cities/[cityId]/route.ts: -------------------------------------------------------------------------------- 1 | import getCurrentUser from "@/actions/getCurrentUser"; 2 | import { prisma } from "@/libs/prisma"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function GET( 6 | request: Request, 7 | { params }: { params: { cityId: string } } 8 | ) { 9 | try { 10 | const cityId = parseInt(params.cityId); 11 | 12 | if (!cityId) { 13 | return new NextResponse("Missing cityId", { status: 400 }); 14 | } 15 | 16 | const city = await prisma.city.findUnique({ 17 | where: { 18 | id: cityId, 19 | }, 20 | }); 21 | 22 | if (!city) { 23 | return new NextResponse("City not found", { status: 404 }); 24 | } 25 | 26 | return NextResponse.json(city); 27 | } catch (err: any) { 28 | console.error("api/cities/[cityId]-error&&&get"); 29 | return new NextResponse("Internal Error 500", { status: 500 }); 30 | } 31 | } 32 | 33 | export async function DELETE( 34 | request: Request, 35 | { params }: { params: { cityId: string } } 36 | ) { 37 | try { 38 | const cityId = parseInt(params.cityId); 39 | if (!cityId) { 40 | return new NextResponse("Missing cityId", { status: 400 }); 41 | } 42 | const currentUser = await getCurrentUser(); 43 | if (!currentUser?.id) { 44 | return NextResponse.json( 45 | { error: "Authenticated User is required for this action" }, 46 | { status: 401 } 47 | ); 48 | } 49 | 50 | const city = await prisma.city.delete({ 51 | where: { 52 | id: cityId, 53 | userId: currentUser.id, 54 | }, 55 | }); 56 | 57 | return NextResponse.json(city); 58 | } catch (err: any) { 59 | console.error("api/cities/[cityId]-error&&&delete"); 60 | return new NextResponse("Internal Error 500", { status: 500 }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/api/city/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { prisma } from "@/libs/prisma"; 3 | import getCurrentUser from "@/actions/getCurrentUser"; 4 | 5 | export async function POST(req: Request) { 6 | try { 7 | const user = await getCurrentUser(); 8 | const body = await req.json(); 9 | const { cityName, country, emoji, date, notes, lat, lng } = body; 10 | if (!cityName || !country || !user?.id) { 11 | return NextResponse.json( 12 | { error: "cityName, country, and userId are required fields." }, 13 | { status: 400 } 14 | ); 15 | } 16 | 17 | const newCity = await prisma.city.create({ 18 | data: { 19 | cityName, 20 | country, 21 | emoji, 22 | date: date ? new Date(date) : new Date(), 23 | notes, 24 | user: { 25 | connect: { id: user?.id }, 26 | }, 27 | position: lat && lng ? { create: { lat, lng } } : undefined, 28 | }, 29 | include: { 30 | position: true, 31 | }, 32 | }); 33 | 34 | return NextResponse.json(newCity, { status: 201 }); 35 | } catch (error) { 36 | console.error("Error creating city:", error); 37 | return NextResponse.json( 38 | { error: "An error occurred while creating the city." }, 39 | { status: 500 } 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/api/register/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import bcrypt from "bcryptjs"; 3 | import { prisma } from "@/libs/prisma"; 4 | 5 | export async function POST(req: Request) { 6 | try { 7 | const body = await req.json(); 8 | const { name, email, password } = body.data; 9 | if (!name || !email || !password) 10 | return new NextResponse("Missing Credentials", { status: 400 }); 11 | 12 | const hashedPassword = await bcrypt.hash(password, 10); 13 | 14 | const user = await prisma.user.create({ 15 | data: { 16 | name, 17 | email, 18 | hashedPassword, 19 | }, 20 | }); 21 | 22 | return NextResponse.json(user); 23 | } catch (err) { 24 | console.log("[api/register/route.ts]_error]", err); 25 | return new NextResponse("Internal Error caused by api/register/route.ts", { 26 | status: 500, 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/app/app.module.css: -------------------------------------------------------------------------------- 1 | .app { 2 | height: 100vh; 3 | padding: 2.4rem; 4 | overscroll-behavior-y: none; 5 | display: flex; 6 | position: relative; 7 | } 8 | -------------------------------------------------------------------------------- /app/app/cities/[cityId]/page.tsx: -------------------------------------------------------------------------------- 1 | import City from "@/components/City/City"; 2 | 3 | const CityId = ({ 4 | params, 5 | }: { 6 | params: { 7 | cityId: string; 8 | }; 9 | }) => { 10 | return ; 11 | }; 12 | 13 | export default CityId; 14 | -------------------------------------------------------------------------------- /app/app/cities/page.tsx: -------------------------------------------------------------------------------- 1 | import CityList from "@/components/CityList/CityList"; 2 | 3 | const CitiesPage = () => { 4 | return 5 | }; 6 | 7 | export default CitiesPage; 8 | -------------------------------------------------------------------------------- /app/app/countries/page.tsx: -------------------------------------------------------------------------------- 1 | import CountryList from "@/components/CountryList/CountryList"; 2 | 3 | const CountriesPage = () => { 4 | return ; 5 | }; 6 | 7 | export default CountriesPage; 8 | -------------------------------------------------------------------------------- /app/app/form/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from "@/components/Form/Form"; 2 | 3 | const FormPage = () => { 4 | return
5 | } 6 | 7 | export default FormPage; -------------------------------------------------------------------------------- /app/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import Sidebar from "@/components/Sidebar/Sidebar"; 2 | import dynamic from "next/dynamic"; 3 | import { useMemo } from "react"; 4 | import styles from "./app.module.css"; 5 | import User from "@/components/User/User"; 6 | import getCurrentUser from "@/actions/getCurrentUser"; 7 | 8 | const AppLayout = async ({ children }: { children: React.ReactNode }) => { 9 | const Map = useMemo( 10 | () => 11 | dynamic(() => import("@/components/Map/Map"), { 12 | loading: () =>

A map is loading

, 13 | ssr: false, 14 | }), 15 | [] 16 | ); 17 | 18 | const user = await getCurrentUser(); 19 | 20 | return ( 21 |
22 | {children} 23 | 24 | {user && } 25 |
26 | ); 27 | }; 28 | 29 | export default AppLayout; 30 | -------------------------------------------------------------------------------- /app/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | 5 | const AppPage = () => { 6 | const router = useRouter(); 7 | router.push('/app/cities'); 8 | return null; 9 | }; 10 | 11 | export default AppPage; 12 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TsotnePharsenadze/worldwise-react-nextjs-typescript/81781543513c063e50b50b17a3d9670d8cf641fc/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"; 2 | @import "https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap"; 3 | 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | 8 | :root { 9 | --color-brand--1: #ffb545; 10 | --color-brand--2: #00c46a; 11 | 12 | --color-dark--0: #242a2e; 13 | --color-dark--1: #2d3439; 14 | --color-dark--2: #42484d; 15 | --color-light--1: #aaa; 16 | --color-light--2: #ececec; 17 | --color-light--3: #d6dee0; 18 | } 19 | 20 | * { 21 | margin: 0; 22 | padding: 0; 23 | box-sizing: inherit; 24 | } 25 | 26 | html { 27 | font-size: 62.5%; 28 | box-sizing: border-box; 29 | } 30 | 31 | ::-webkit-scrollbar { 32 | width: 12px; 33 | } 34 | 35 | ::-webkit-scrollbar-track { 36 | background: #42484d; 37 | border-radius: 10px; 38 | } 39 | 40 | ::-webkit-scrollbar-thumb { 41 | background: #2d3439; 42 | border-radius: 10px; 43 | } 44 | 45 | ::-webkit-scrollbar-thumb:hover { 46 | background: rgba(85, 85, 85, 0.516); 47 | } 48 | 49 | html { 50 | scrollbar-width: thin; 51 | scrollbar-color: #2d3439 #42484d; 52 | } 53 | * { 54 | font-family: "Manrope", sans-serif, "Twemoji Country Flags", "Helvetica", 55 | "Comic Sans", serif; 56 | } 57 | body { 58 | font-family: "Manrope", sans-serif, "Twemoji Country Flags", "Helvetica", 59 | "Comic Sans", serif; 60 | color: var(--color-light--2); 61 | font-weight: 400; 62 | line-height: 1.6; 63 | } 64 | 65 | label { 66 | font-size: 1.6rem; 67 | font-weight: 600; 68 | } 69 | 70 | input, 71 | textarea { 72 | width: 100%; 73 | padding: 0.8rem 1.2rem; 74 | font-family: inherit; 75 | font-size: 1.6rem; 76 | color: black; 77 | border: none; 78 | border-radius: 5px; 79 | background-color: var(--color-light--3); 80 | transition: all 0.2s; 81 | } 82 | 83 | input:focus { 84 | outline: none; 85 | background-color: #fff; 86 | } 87 | 88 | .cta:link, 89 | .cta:visited { 90 | display: inline-block; 91 | background-color: var(--color-brand--2); 92 | color: var(--color-dark--1); 93 | text-transform: uppercase; 94 | text-decoration: none; 95 | font-size: 1.6rem; 96 | font-weight: 600; 97 | padding: 1rem 3rem; 98 | border-radius: 5px; 99 | } 100 | 101 | .login { 102 | margin: 2.5rem; 103 | padding: 2.5rem 5rem; 104 | background-color: var(--color-dark--1); 105 | min-height: calc(100vh - 5rem); 106 | display: flex; 107 | flex-direction: column; 108 | justify-content: center; 109 | align-items: center; 110 | } 111 | 112 | .leaflet-popup .leaflet-popup-content-wrapper { 113 | background-color: var(--color-dark--1); 114 | color: var(--color-light--2); 115 | border-radius: 5px; 116 | padding-right: 0.6rem; 117 | } 118 | 119 | .leaflet-popup .leaflet-popup-content { 120 | font-size: 1.5rem; 121 | display: flex; 122 | align-items: center; 123 | gap: 1rem; 124 | } 125 | 126 | .leaflet-popup .leaflet-popup-content span:first-child { 127 | font-size: 2.5rem; 128 | line-height: 1; 129 | } 130 | 131 | .leaflet-popup .leaflet-popup-tip { 132 | background-color: var(--color-dark--1); 133 | } 134 | 135 | .leaflet-popup-content-wrapper { 136 | border-left: 5px solid var(--color-brand--2); 137 | } 138 | 139 | .react-datepicker { 140 | font-family: inherit; 141 | font-size: 1.2rem !important; 142 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import SessionContextProvider from "@/context/SessionContextProvider"; 5 | import { CitiesContextProvider } from "@/context/CitiesContext"; 6 | import Polyfill from "@/providers/FlagIconProviders"; 7 | 8 | const inter = Inter({ subsets: ["latin"] }); 9 | 10 | export const metadata: Metadata = { 11 | title: "Create Next App", 12 | description: "Generated by create next app", 13 | }; 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: Readonly<{ 18 | children: React.ReactNode; 19 | }>) { 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | {children} 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import AuthForm from "@/components/AuthForm/AuthForm"; 3 | 4 | export default function Home() { 5 | return ( 6 |
7 |
8 | WorldWise logo 9 |
10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /components/AppNav/AppNav.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePathname } from "next/navigation"; 4 | import styles from "./AppNav.module.css"; 5 | 6 | function AppNav() { 7 | const pathname = usePathname(); 8 | return ( 9 | 29 | ); 30 | } 31 | 32 | export default AppNav; 33 | -------------------------------------------------------------------------------- /components/AppNav/AppNav.module.css: -------------------------------------------------------------------------------- 1 | .nav { 2 | margin-top: 3rem; 3 | margin-bottom: 2rem; 4 | } 5 | 6 | .nav ul { 7 | list-style: none; 8 | display: flex; 9 | background-color: var(--color-dark--2); 10 | border-radius: 7px; 11 | } 12 | 13 | .nav a:link, 14 | .nav a:visited { 15 | display: block; 16 | color: inherit; 17 | text-decoration: none; 18 | text-transform: uppercase; 19 | font-size: 1.2rem; 20 | font-weight: 700; 21 | padding: 0.5rem 2rem; 22 | border-radius: 5px; 23 | } 24 | 25 | /* CSS Modules feature */ 26 | .nav a:global(.active) { 27 | background-color: var(--color-dark--0); 28 | } 29 | -------------------------------------------------------------------------------- /components/AuthForm/AuthForm.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | background-color: var(--color-dark--2); 3 | border-radius: 7px; 4 | padding: 2rem 3rem; 5 | width: 100%; 6 | display: flex; 7 | flex-direction: column; 8 | gap: 2rem; 9 | width: 48rem; 10 | margin: 4rem; 11 | } 12 | 13 | .row { 14 | display: flex; 15 | flex-direction: column; 16 | gap: 0.5rem; 17 | } 18 | -------------------------------------------------------------------------------- /components/AuthForm/AuthForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import styles from "./AuthForm.module.css"; 4 | import Button from "@/components/Button/Button"; 5 | import { FieldValues, SubmitHandler, useForm } from "react-hook-form"; 6 | import { cn } from "@/libs/utils"; 7 | import { signIn, useSession } from "next-auth/react"; 8 | import { useEffect, useState } from "react"; 9 | import axios from "axios"; 10 | import { useRouter } from "next/navigation"; 11 | 12 | export default function AuthForm() { 13 | const [isLoading, setIsLoading] = useState(false); 14 | const [variant, setVariant] = useState("LOGIN"); 15 | const router = useRouter(); 16 | const session = useSession(); 17 | 18 | useEffect(() => { 19 | if (session?.status === "authenticated") { 20 | router.push("/app"); 21 | } 22 | }, [session?.status, router]); 23 | 24 | const { 25 | register, 26 | handleSubmit, 27 | formState: { errors }, 28 | } = useForm({ 29 | defaultValues: { 30 | name: "", 31 | email: "", 32 | password: "", 33 | }, 34 | }); 35 | 36 | const credentialsAuth: SubmitHandler = async (data) => { 37 | if (variant === "REGISTER") { 38 | setIsLoading(true); 39 | await axios 40 | .post("/api/register", { data }) 41 | .finally(() => setIsLoading(false)); 42 | } else { 43 | await signIn("credentials", { 44 | redirect: false, 45 | email: data.email, 46 | password: data.password, 47 | }).finally(() => setIsLoading(false)); 48 | } 49 | }; 50 | 51 | const toggleVariant = () => { 52 | if (variant === "LOGIN") { 53 | setVariant("REGISTER"); 54 | } else { 55 | setVariant("LOGIN"); 56 | } 57 | }; 58 | 59 | return ( 60 | 61 | {variant === "REGISTER" && ( 62 |
63 | 66 | 74 |
75 | )} 76 | 77 |
78 | 81 | 93 |
94 | 95 |
96 | 99 | 105 |
106 | 107 |
108 | 111 |
112 | 113 |
114 |
115 | 116 | {variant === "LOGIN" 117 | ? "Don't have an account?" 118 | : "Already have an account?"} 119 | toggleVariant()} 122 | > 123 | {" "} 124 | {variant === "LOGIN" ? "Register here" : "Login here"} 125 | 126 | 127 |
128 | 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /components/BackButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import Button from "./Button/Button"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | const BackButton = () => { 8 | const router = useRouter(); 9 | 10 | return ( 11 | 20 | ); 21 | }; 22 | 23 | export default BackButton; 24 | -------------------------------------------------------------------------------- /components/Button/Button.module.css: -------------------------------------------------------------------------------- 1 | .btn { 2 | color: inherit; 3 | text-transform: uppercase; 4 | padding: 0.4rem 0.8rem; 5 | font-family: inherit; 6 | font-size: 1.5rem; 7 | border: none; 8 | border-radius: 5px; 9 | cursor: pointer; 10 | } 11 | 12 | .btn:hover { 13 | @apply opacity-90; 14 | } 15 | 16 | .primary { 17 | font-weight: 700; 18 | background-color: var(--color-brand--2); 19 | color: var(--color-dark--1); 20 | } 21 | 22 | .back { 23 | font-weight: 600; 24 | background: none; 25 | border: 1px solid currentColor; 26 | } 27 | 28 | .position { 29 | font-weight: 700; 30 | position: absolute; 31 | z-index: 1000; 32 | font-size: 1.4rem; 33 | bottom: 4rem; 34 | left: 50%; 35 | transform: translateX(-50%); 36 | background-color: var(--color-brand--2); 37 | color: var(--color-dark--1); 38 | box-shadow: 0 0.4rem 1.2rem rgba(36, 42, 46, 0.16); 39 | } -------------------------------------------------------------------------------- /components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import { FaSpinner } from "react-icons/fa"; 2 | import styles from "./Button.module.css"; 3 | 4 | interface ButtonInterface { 5 | children: React.ReactNode; 6 | onClick?: (e: any) => void; 7 | type: string; 8 | fullWidth?: boolean; 9 | isLoading?: boolean; 10 | } 11 | 12 | const Button = ({ 13 | children, 14 | onClick, 15 | type, 16 | fullWidth, 17 | isLoading, 18 | }: ButtonInterface) => { 19 | return ( 20 | 33 | ); 34 | }; 35 | 36 | export default Button; 37 | -------------------------------------------------------------------------------- /components/City/City.module.css: -------------------------------------------------------------------------------- 1 | .city { 2 | padding: 2rem 3rem; 3 | max-height: 70%; 4 | background-color: var(--color-dark--2); 5 | border-radius: 7px; 6 | overflow: scroll; 7 | 8 | width: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | gap: 2rem; 12 | } 13 | 14 | .row { 15 | display: flex; 16 | flex-direction: column; 17 | gap: 0.5rem; 18 | } 19 | 20 | .city h6 { 21 | text-transform: uppercase; 22 | font-size: 1.1rem; 23 | font-weight: 900; 24 | color: var(--color-light--1); 25 | } 26 | 27 | .city h3 { 28 | font-size: 1.9rem; 29 | display: flex; 30 | align-items: center; 31 | gap: 1rem; 32 | } 33 | 34 | .city h3 span { 35 | font-size: 3.2rem; 36 | line-height: 1; 37 | } 38 | 39 | .city p { 40 | font-size: 1.6rem; 41 | } 42 | 43 | .city a:link, 44 | .city a:visited { 45 | font-size: 1.6rem; 46 | color: var(--color-brand--1); 47 | } 48 | -------------------------------------------------------------------------------- /components/City/City.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import styles from "./City.module.css"; 4 | import Spinner from "../Spinner/Spinner"; 5 | import Message from "../Message/Message"; 6 | import { useCities } from "@/context/CitiesContext"; 7 | import React from "react"; 8 | import BackButton from "../BackButton"; 9 | 10 | type Position = { 11 | lat: number; 12 | lng: number; 13 | }; 14 | 15 | type City = { 16 | id: number; 17 | cityName: string; 18 | emoji: string; 19 | date: string; 20 | position: Position; 21 | notes?: string; 22 | }; 23 | 24 | const formatDate = (date: Date) => 25 | new Intl.DateTimeFormat("en", { 26 | day: "numeric", 27 | month: "long", 28 | year: "numeric", 29 | weekday: "long", 30 | }).format(new Date(date)); 31 | 32 | function City({ id }: { id: string }) { 33 | const { currentCity, getCity, isLoading } = useCities(); 34 | 35 | React.useEffect(() => { 36 | if (id) { 37 | getCity(Number(id)); 38 | } 39 | }, [id, getCity]); 40 | 41 | if (isLoading) return ; 42 | 43 | if (!currentCity) return ; 44 | 45 | const { cityName, emoji, date, notes } = currentCity; 46 | 47 | return ( 48 |
49 |
50 |
City name
51 |

52 | {emoji} {cityName} 53 |

54 |
55 | 56 |
57 |
You went to {cityName} on
58 |

{formatDate(date)}

59 |
60 | 61 | {notes && ( 62 |
63 |
Your notes
64 |

{notes}

65 |
66 | )} 67 | 68 | 78 | 79 |
80 | 81 |
82 |
83 | ); 84 | } 85 | 86 | export default City; 87 | -------------------------------------------------------------------------------- /components/CityItem/CityItem.module.css: -------------------------------------------------------------------------------- 1 | .cityItem, 2 | .cityItem:link, 3 | .cityItem:visited { 4 | display: flex; 5 | gap: 1.6rem; 6 | align-items: center; 7 | 8 | background-color: var(--color-dark--2); 9 | border-radius: 7px; 10 | padding: 1rem 2rem; 11 | border-left: 5px solid var(--color-brand--2); 12 | cursor: pointer; 13 | 14 | color: inherit; 15 | text-decoration: none; 16 | } 17 | 18 | .cityItem--active { 19 | border: 2px solid var(--color-brand--2); 20 | border-left: 5px solid var(--color-brand--2); 21 | } 22 | 23 | .emoji { 24 | font-size: 2.6rem; 25 | line-height: 1; 26 | } 27 | 28 | .name { 29 | font-size: 1.7rem; 30 | font-weight: 600; 31 | margin-right: auto; 32 | } 33 | 34 | .date { 35 | font-size: 1.5rem; 36 | } 37 | 38 | .deleteBtn { 39 | height: 2rem; 40 | aspect-ratio: 1; 41 | border-radius: 50%; 42 | border: none; 43 | background-color: var(--color-dark--1); 44 | color: var(--color-light--2); 45 | font-size: 1.6rem; 46 | font-weight: 400; 47 | cursor: pointer; 48 | transition: all 0.2s; 49 | display: flex; 50 | justify-content: center; 51 | align-items: center; 52 | } 53 | 54 | .deleteBtn:hover { 55 | background-color: var(--color-brand--1); 56 | color: var(--color-dark--1); 57 | } 58 | -------------------------------------------------------------------------------- /components/CityItem/CityItem.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import styles from "./CityItem.module.css"; 4 | import { useCities } from "@/context/CitiesContext"; 5 | import Link from "next/link"; 6 | 7 | type City = { 8 | id: number; 9 | cityName: string; 10 | emoji: string; 11 | date: Date; 12 | position: { 13 | lat: number; 14 | lng: number; 15 | }; 16 | }; 17 | 18 | const formatDate = (date: Date) => 19 | new Intl.DateTimeFormat("en", { 20 | day: "numeric", 21 | month: "long", 22 | year: "numeric", 23 | weekday: "long", 24 | }).format(new Date(date)); 25 | 26 | const CityItem = ({ city }: { city: City }) => { 27 | const { currentCity, deleteCity } = useCities(); 28 | const { id, cityName, emoji, date, position } = city; 29 | 30 | const handleDelete = (e: React.MouseEvent) => { 31 | e.preventDefault(); 32 | deleteCity(id); 33 | }; 34 | 35 | return ( 36 |
  • 37 | 45 | 50 | {emoji} 51 |

    {cityName}

    52 | 53 | 56 |
    57 | 58 |
  • 59 | ); 60 | }; 61 | 62 | export default CityItem; 63 | -------------------------------------------------------------------------------- /components/CityList/CityList.module.css: -------------------------------------------------------------------------------- 1 | .cityList { 2 | width: 100%; 3 | height: 65vh; 4 | list-style: none; 5 | overflow-y: scroll; 6 | overflow-x: hidden; 7 | padding-right: 20px; 8 | display: flex; 9 | flex-direction: column; 10 | gap: 1.4rem; 11 | } 12 | 13 | .cityList::-webkit-scrollbar { 14 | width: 0; 15 | } 16 | -------------------------------------------------------------------------------- /components/CityList/CityList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import styles from "./CityList.module.css"; 4 | import Spinner from "../Spinner/Spinner"; 5 | import CityItem from "../CityItem/CityItem"; 6 | import Message from "../Message/Message"; 7 | import { useCities } from "@/context/CitiesContext"; 8 | 9 | const CityList = () => { 10 | const { isLoading, cities } = useCities(); 11 | if (isLoading) return ; 12 | 13 | if (!cities.length) 14 | return ; 15 | 16 | return ( 17 |
      18 | {cities.map((city) => { 19 | return ; 20 | })} 21 |
    22 | ); 23 | }; 24 | 25 | export default CityList; 26 | -------------------------------------------------------------------------------- /components/CountryItem/CountryItem.module.css: -------------------------------------------------------------------------------- 1 | .countryItem { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | gap: 0.2rem; 6 | 7 | font-size: 1.7rem; 8 | font-weight: 600; 9 | 10 | background-color: var(--color-dark--2); 11 | border-radius: 7px; 12 | padding: 1rem 2rem; 13 | border-left: 5px solid var(--color-brand--1); 14 | } 15 | 16 | .countryItem span:first-child { 17 | font-size: 3rem; 18 | line-height: 1; 19 | } 20 | -------------------------------------------------------------------------------- /components/CountryItem/CountryItem.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import styles from "./CountryItem.module.css"; 4 | type CountryData = { 5 | country: string; 6 | emoji: string; 7 | }; 8 | function CountryItem({ country }: { country: CountryData }) { 9 | return ( 10 |
  • 11 | {country.emoji} 12 | {country.country} 13 |
  • 14 | ); 15 | } 16 | 17 | export default CountryItem; 18 | -------------------------------------------------------------------------------- /components/CountryList/CountryList.module.css: -------------------------------------------------------------------------------- 1 | .countryList { 2 | width: 100%; 3 | height: 65vh; 4 | list-style: none; 5 | overflow-y: scroll; 6 | overflow-x: hidden; 7 | padding-right: 20px; 8 | 9 | display: grid; 10 | grid-template-columns: 1fr 1fr; 11 | align-content: start; 12 | gap: 1.6rem; 13 | } 14 | -------------------------------------------------------------------------------- /components/CountryList/CountryList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import styles from "./CountryList.module.css"; 4 | import Spinner from "../Spinner/Spinner"; 5 | import CountryItem from "../CountryItem/CountryItem"; 6 | import Message from "../Message/Message"; 7 | import { useCities } from "@/context/CitiesContext"; 8 | 9 | type CountryData = { 10 | country: string; 11 | emoji: string; 12 | }; 13 | 14 | const CountryList = () => { 15 | const { isLoading, cities } = useCities(); 16 | 17 | if (isLoading) return ; 18 | 19 | if (!cities.length) 20 | return ; 21 | 22 | const citiesNew: CountryData[] = cities.reduce( 23 | (prev: CountryData[], curr) => { 24 | if (!prev.some((obj) => obj.country === curr.country)) { 25 | prev.push({ country: curr.country, emoji: curr.emoji }); 26 | } 27 | return prev; 28 | }, 29 | [] 30 | ); 31 | 32 | return ( 33 |
      34 | {citiesNew.map((city) => ( 35 | 36 | ))} 37 |
    38 | ); 39 | }; 40 | 41 | export default CountryList; 42 | -------------------------------------------------------------------------------- /components/Form/Form.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | background-color: var(--color-dark--2); 3 | border-radius: 7px; 4 | padding: 2rem 3rem; 5 | width: 100%; 6 | display: flex; 7 | flex-direction: column; 8 | gap: 2rem; 9 | } 10 | 11 | .row { 12 | display: flex; 13 | flex-direction: column; 14 | gap: 0.5rem; 15 | position: relative; 16 | } 17 | 18 | .buttons { 19 | display: flex; 20 | justify-content: space-between; 21 | } 22 | 23 | .flag { 24 | position: absolute; 25 | right: 1rem; 26 | top: 2.7rem; 27 | font-size: 2.8rem; 28 | } 29 | 30 | .form.loading { 31 | opacity: 0.3; 32 | } 33 | 34 | .form.loading button { 35 | pointer-events: none; 36 | background-color: var(--color-light--1); 37 | border: 1px solid var(--color-light--1); 38 | color: var(--color-dark--0); 39 | } 40 | -------------------------------------------------------------------------------- /components/Form/Form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | import styles from "./Form.module.css"; 6 | import Button from "../Button/Button"; 7 | import BackButton from "../BackButton"; 8 | import "react-datepicker/dist/react-datepicker.css"; 9 | import Message from "../Message/Message"; 10 | import Spinner from "../Spinner/Spinner"; 11 | import { useUrlPosition } from "@/hooks/useUrlPosition"; 12 | import DatePicker from "react-datepicker"; 13 | import { useCities } from "@/context//CitiesContext"; 14 | import { useRouter } from "next/navigation"; 15 | 16 | export function convertToEmoji(countryCode: string) { 17 | const codePoints = countryCode 18 | .toUpperCase() 19 | .split("") 20 | .map((char) => 127397 + char.charCodeAt(0)); 21 | return String.fromCodePoint(...codePoints); 22 | } 23 | 24 | function Form() { 25 | const [cityName, setCityName] = useState(""); 26 | const [country, setCountry] = useState(""); 27 | const [date, setDate] = useState(new Date()); 28 | const [notes, setNotes] = useState(""); 29 | const [isLoadingGeoCoding, setIsLoadingGeoCoding] = useState(false); 30 | const [emoji, setEmoji] = useState(""); 31 | const [geocodingError, setGeocodingError] = useState(""); 32 | 33 | const router = useRouter(); 34 | 35 | const { createCitty, isLoading } = useCities(); 36 | 37 | const [lat, lng] = useUrlPosition(); 38 | 39 | useEffect(() => { 40 | async function fetchData() { 41 | try { 42 | setGeocodingError(""); 43 | setIsLoadingGeoCoding(true); 44 | const res = await fetch( 45 | `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lng}` 46 | ); 47 | const data = await res.json(); 48 | 49 | if (!data.countryCode) 50 | throw new Error( 51 | "Neither country nor city has been identified, please click somewhere else 👍" 52 | ); 53 | 54 | setCityName(data.city || data.locality); 55 | setCountry(data.countryName); 56 | setEmoji(convertToEmoji(data.countryCode)); 57 | } catch (err: any) { 58 | setGeocodingError(err.message); 59 | } finally { 60 | setIsLoadingGeoCoding(false); 61 | } 62 | } 63 | 64 | fetchData(); 65 | }, [lat, lng]); 66 | 67 | if (isLoadingGeoCoding) return ; 68 | 69 | if (!lat && !lng) 70 | return ; 71 | 72 | if (geocodingError) return ; 73 | 74 | function handleSubmit(event: React.FormEvent) { 75 | event.preventDefault(); 76 | 77 | const newCity = { 78 | cityName, 79 | country, 80 | emoji, 81 | date: date as Date, 82 | notes, 83 | lat, 84 | lng, 85 | }; 86 | 87 | createCitty(newCity); 88 | router.push("/app/cities/"); 89 | } 90 | 91 | return ( 92 |
    96 |
    97 | 98 | setCityName(e.target.value)} 101 | value={cityName} 102 | /> 103 | {emoji} 104 |
    105 | 106 |
    107 | 108 | setDate(date)} selected={date} className="p-4 rounded-lg bg-gray-200 text-xl" /> 109 |
    110 | 111 |
    112 | 113 |