├── .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 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
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 |
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 |
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 |
10 |
28 |
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 |
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 | {
13 | e.preventDefault();
14 | router.back();
15 | }}
16 | type="back"
17 | >
18 | ← Back
19 |
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 |
26 | {isLoading && (
27 |
28 |
29 |
30 | )}{" "}
31 | {children}
32 |
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 | {formatDate(date)}
53 |
54 | ×
55 |
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 |
125 | );
126 | }
127 |
128 | export default Form;
129 |
--------------------------------------------------------------------------------
/components/Map/Map.module.css:
--------------------------------------------------------------------------------
1 | .mapContainer {
2 | flex: 1;
3 | height: 100%;
4 | background-color: var(--color-dark--2);
5 | position: relative;
6 | }
7 |
8 | .map {
9 | height: 100%;
10 | }
--------------------------------------------------------------------------------
/components/Map/Map.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import L from "leaflet";
3 | import { useRouter } from "next/navigation";
4 | import {
5 | MapContainer,
6 | TileLayer,
7 | Marker,
8 | Popup,
9 | useMap,
10 | useMapEvent,
11 | } from "react-leaflet";
12 | import { useState, useEffect } from "react";
13 | import { useCities } from "@/context/CitiesContext";
14 | import Button from "../Button/Button";
15 | import { useGeolocation } from "@/hooks/useGeolocation";
16 | import { useUrlPosition } from "@/hooks/useUrlPosition";
17 | import styles from "./Map.module.css";
18 | import "leaflet/dist/leaflet.css";
19 | import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
20 | import markerIcon from "leaflet/dist/images/marker-icon.png";
21 | import markerShadow from "leaflet/dist/images/marker-shadow.png";
22 |
23 | // @ts-ignore
24 | delete L.Icon.Default.prototype._getIconUrl;
25 | L.Icon.Default.mergeOptions({
26 | iconUrl: markerIcon.src,
27 | iconRetinaUrl: markerIcon2x.src,
28 | shadowUrl: markerShadow.src,
29 | });
30 |
31 | interface ChangeCenterProps {
32 | position: [number, number];
33 | }
34 |
35 | const Map = () => {
36 | const [position, setPosition] = useState<[number, number]>([50, 40]);
37 |
38 | const {
39 | isLoading: isLoadingPosition,
40 | position: geolocationPosition,
41 | getPosition,
42 | } = useGeolocation({ defaultPosition: { lat: 50, lng: 40 } });
43 |
44 | const [lat, lng] = useUrlPosition();
45 |
46 | const { cities } = useCities();
47 | useEffect(() => {
48 | if (lat !== null && lng !== null && !isNaN(lat) && !isNaN(lng)) {
49 | setPosition([lat, lng]);
50 | }
51 | }, [lat, lng]);
52 |
53 | useEffect(() => {
54 | if (geolocationPosition) {
55 | setPosition([geolocationPosition.lat, geolocationPosition.lng]);
56 | }
57 | }, [geolocationPosition]);
58 |
59 | return (
60 |
61 |
62 | {isLoadingPosition ? "Loading..." : "Use your location"}
63 |
64 |
70 |
74 | {cities.map((city) => {
75 | return (
76 |
80 |
81 | {city.emoji}
82 | {city.cityName}
83 |
84 |
85 | );
86 | })}
87 |
88 |
89 |
90 |
91 |
92 | );
93 | };
94 |
95 | function ChangeCenter({ position }: ChangeCenterProps) {
96 | const map = useMap();
97 | useEffect(() => {
98 | map.setView(position);
99 | }, [position, map]);
100 | return null;
101 | }
102 |
103 | function DetectClick() {
104 | const router = useRouter();
105 |
106 | useMapEvent("click", (e) => {
107 | const lat = e.latlng.lat;
108 | const lng = e.latlng.lng;
109 | router.push(`/app/form?lat=${lat}&lng=${lng}`);
110 | });
111 |
112 | return null;
113 | }
114 |
115 | export default Map;
116 |
--------------------------------------------------------------------------------
/components/Message/Message.module.css:
--------------------------------------------------------------------------------
1 | .message {
2 | text-align: center;
3 | font-size: 1.8rem;
4 | width: 80%;
5 | margin: 2rem auto;
6 | font-weight: 600;
7 | }
8 |
--------------------------------------------------------------------------------
/components/Message/Message.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./Message.module.css";
2 |
3 | function Message({ message }: { message: string }) {
4 | return (
5 |
6 | 👋 {message}
7 |
8 | );
9 | }
10 |
11 | export default Message;
12 |
--------------------------------------------------------------------------------
/components/Sidebar/Sidebar.module.css:
--------------------------------------------------------------------------------
1 | .sidebar {
2 | flex-basis: 56rem;
3 | background-color: var(--color-dark--1);
4 | padding: 3rem 5rem 3.5rem 5rem;
5 |
6 | display: flex;
7 | flex-direction: column;
8 | align-items: center;
9 | height: calc(100vh - 4.8rem);
10 | }
11 |
12 | .footer {
13 | margin-top: auto;
14 | }
15 |
16 | .copyright {
17 | font-size: 1.2rem;
18 | color: var(--color-light--1);
19 | }
20 |
--------------------------------------------------------------------------------
/components/Sidebar/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./Sidebar.module.css";
2 | import AppNav from "@/components/AppNav/AppNav";
3 |
4 | const Sidebar = ({ children }: { children: React.ReactNode }) => {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | {children}
14 |
15 |
18 |
19 | );
20 | };
21 |
22 | export default Sidebar;
23 |
--------------------------------------------------------------------------------
/components/Spinner/Spinner.module.css:
--------------------------------------------------------------------------------
1 | .spinnerContainer {
2 | height: 100%;
3 | display: flex;
4 | align-items: center;
5 | justify-content: center;
6 | }
7 |
8 | .spinner {
9 | width: 6rem;
10 | height: 6rem;
11 | border-radius: 50%;
12 | background: conic-gradient(#0000 10%, var(--color-light--2));
13 | -webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 8px), #000 0);
14 | animation: rotate 1.5s infinite linear;
15 | }
16 |
17 | @keyframes rotate {
18 | to {
19 | transform: rotate(1turn);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/components/Spinner/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./Spinner.module.css";
2 |
3 | function Spinner() {
4 | return (
5 |
8 | );
9 | }
10 |
11 | export default Spinner;
12 |
--------------------------------------------------------------------------------
/components/SpinnerFullPage/SpinnerFullPage.module.css:
--------------------------------------------------------------------------------
1 | .spinnerFullpage {
2 | margin: 2.5rem;
3 | height: calc(100vh - 5rem);
4 | background-color: var(--color-dark--1);
5 | }
6 |
--------------------------------------------------------------------------------
/components/SpinnerFullPage/SpinnerFullPage.tsx:
--------------------------------------------------------------------------------
1 | import Spinner from "../Spinner/Spinner";
2 | import styles from "./SpinnerFullPage.module.css";
3 |
4 | function SpinnerFullPage() {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
12 | export default SpinnerFullPage;
13 |
--------------------------------------------------------------------------------
/components/User/User.module.css:
--------------------------------------------------------------------------------
1 | .user {
2 | position: absolute;
3 | top: 4.2rem;
4 | right: 4.2rem;
5 | background-color: var(--color-dark--1);
6 | padding: 1rem 1.4rem;
7 | border-radius: 7px;
8 | z-index: 999;
9 | box-shadow: 0 0.8rem 2.4rem rgba(36, 42, 46, 0.5);
10 | font-size: 1.6rem;
11 | font-weight: 600;
12 |
13 | display: flex;
14 | align-items: center;
15 | gap: 1.6rem;
16 | }
17 |
18 | .user img {
19 | border-radius: 100px;
20 | height: 4rem;
21 | }
22 |
23 | .user button {
24 | background-color: var(--color-dark--2);
25 | border-radius: 7px;
26 | border: none;
27 | padding: 0.6rem 1.2rem;
28 | color: inherit;
29 | font-family: inherit;
30 | font-size: 1.2rem;
31 | font-weight: 700;
32 | text-transform: uppercase;
33 | cursor: pointer;
34 | }
35 |
--------------------------------------------------------------------------------
/components/User/User.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { signOut } from "next-auth/react";
4 | import styles from "./User.module.css";
5 | import { type User } from "@prisma/client";
6 |
7 | async function User({ user }: { user: User }) {
8 | return (
9 |
10 | {/*
*/}
11 |
12 |
Welcome, {user?.email}
13 |
signOut()} className="hover:opacity-80">Logout
14 |
15 | );
16 | }
17 |
18 | export default User;
19 |
--------------------------------------------------------------------------------
/context/CitiesContext.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, {
4 | createContext,
5 | useContext,
6 | useReducer,
7 | useCallback,
8 | ReactNode,
9 | } from "react";
10 | import { City, Position } from "@prisma/client";
11 | import getCities from "@/actions/getCities";
12 | import { useSession } from "next-auth/react";
13 | import { useRouter } from "next/navigation";
14 |
15 | type CityWithPosition = City & {
16 | position: Position;
17 | };
18 |
19 | interface State {
20 | cities: CityWithPosition[];
21 | isLoading: boolean;
22 | currentCity: City | null;
23 | error: string;
24 | }
25 |
26 | interface Action {
27 | type:
28 | | "loading"
29 | | "cities/loaded"
30 | | "city/loaded"
31 | | "city/created"
32 | | "city/deleted"
33 | | "rejected";
34 | payload?: any;
35 | }
36 |
37 | const initialState: State = {
38 | cities: [],
39 | isLoading: false,
40 | currentCity: null,
41 | error: "",
42 | };
43 |
44 | function reducer(state: State, action: Action): State {
45 | switch (action.type) {
46 | case "loading":
47 | return { ...state, isLoading: true };
48 | case "cities/loaded":
49 | return { ...state, isLoading: false, cities: action.payload };
50 | case "city/loaded":
51 | return { ...state, isLoading: false, currentCity: action.payload };
52 | case "city/created":
53 | return { ...state, isLoading: false, cities: action.payload };
54 | case "city/deleted":
55 | return { ...state, isLoading: false, cities: action.payload };
56 | case "rejected":
57 | return { ...state, isLoading: false, error: action.payload };
58 | default:
59 | throw new Error("Unknown action type");
60 | }
61 | }
62 |
63 | interface CitiesContextType extends State {
64 | getCity: (id: number) => Promise;
65 | createCitty: (cityData: Partial) => Promise;
66 | deleteCity: (id: number) => Promise;
67 | }
68 |
69 | const CitiesContext = createContext(undefined);
70 |
71 | interface CitiesContextProviderProps {
72 | children: ReactNode;
73 | }
74 |
75 | export function CitiesContextProvider({
76 | children,
77 | }: CitiesContextProviderProps) {
78 | const router = useRouter();
79 | const session = useSession();
80 | const [{ cities, isLoading, currentCity, error }, dispatch] = useReducer(
81 | reducer,
82 | initialState
83 | );
84 |
85 | const getCity = useCallback(async (id: number) => {
86 | try {
87 | dispatch({ type: "loading" });
88 | const res = await fetch(`/api/cities/${id}`);
89 | const data = await res.json();
90 | dispatch({ type: "city/loaded", payload: data });
91 | } catch {
92 | dispatch({
93 | type: "rejected",
94 | payload: "There was an error while fetching the data",
95 | });
96 | }
97 | }, []);
98 |
99 | const createCitty = async (cityData: Partial) => {
100 | try {
101 | dispatch({ type: "loading" });
102 | const res = await fetch("/api/city", {
103 | method: "POST",
104 | body: JSON.stringify(cityData),
105 | headers: { "Content-Type": "application/json" },
106 | });
107 | const data: City = await res.json();
108 | const updatedCities = [...cities, data].sort((a, b) => b.id - a.id);
109 | dispatch({ type: "city/created", payload: updatedCities });
110 | } catch {
111 | dispatch({
112 | type: "rejected",
113 | payload: "There was an error while creating city",
114 | });
115 | }
116 | };
117 |
118 | const deleteCity = async (id: number) => {
119 | try {
120 | dispatch({ type: "loading" });
121 | await fetch(`/api/cities/${id}`, {
122 | method: "DELETE",
123 | });
124 | const updatedCities = cities
125 | .filter((city) => city.id !== id)
126 | .sort((a, b) => b.id - a.id);
127 | dispatch({ type: "city/deleted", payload: updatedCities });
128 | } catch {
129 | dispatch({
130 | type: "rejected",
131 | payload: "There was an error while deleting the city",
132 | });
133 | }
134 | };
135 |
136 | React.useEffect(() => {
137 | async function fetchData() {
138 | try {
139 | dispatch({ type: "loading" });
140 | const data = await getCities();
141 | if (data) {
142 | const sortedData = data.sort((a, b) => b.id - a.id);
143 | dispatch({ type: "cities/loaded", payload: sortedData });
144 | } else {
145 | dispatch({
146 | type: "rejected",
147 | payload: "No data found",
148 | });
149 | }
150 | } catch (error) {
151 | dispatch({
152 | type: "rejected",
153 | payload: "There was an error while fetching the data",
154 | });
155 | }
156 | }
157 |
158 | if (session?.status === "authenticated") {
159 | fetchData();
160 | }
161 | }, [session]);
162 |
163 | return (
164 |
175 | {children}
176 |
177 | );
178 | }
179 |
180 | export function useCities() {
181 | const context = useContext(CitiesContext);
182 | if (context === undefined) {
183 | throw new Error(
184 | "CitiesContext must be used within a CitiesContextProvider"
185 | );
186 | }
187 | return context;
188 | }
189 |
--------------------------------------------------------------------------------
/context/SessionContextProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { SessionProvider } from "next-auth/react";
4 |
5 | const SessionContextProvider = ({ children }: { children: React.ReactNode }) => {
6 | return {children}
7 | };
8 |
9 | export default SessionContextProvider;
10 |
--------------------------------------------------------------------------------
/hooks/useGeolocation.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | export function useGeolocation({
4 | defaultPosition = null,
5 | }: {
6 | defaultPosition: { lat: number; lng: number } | null | undefined;
7 | }) {
8 | const [isLoading, setIsLoading] = useState(false);
9 | const [error, setError] = useState(null);
10 | const [position, setPosition] = useState(defaultPosition);
11 |
12 | function getPosition() {
13 | if (!navigator.geolocation)
14 | return setError("Your browser does not support geolocation");
15 |
16 | setIsLoading(true);
17 | navigator.geolocation.getCurrentPosition(
18 | (pos) => {
19 | setPosition({
20 | lat: pos.coords.latitude,
21 | lng: pos.coords.longitude,
22 | });
23 | setIsLoading(false);
24 | },
25 | (error) => {
26 | setError(error.message);
27 | setIsLoading(false);
28 | }
29 | );
30 | }
31 | return { position, getPosition, isLoading, error };
32 | }
33 |
--------------------------------------------------------------------------------
/hooks/useUrlPosition.tsx:
--------------------------------------------------------------------------------
1 | import { useSearchParams } from "next/navigation";
2 |
3 | export function useUrlPosition(): [number | null, number | null] {
4 | const searchParams = useSearchParams();
5 | const lat = searchParams.get("lat");
6 | const lng = searchParams.get("lng");
7 |
8 | const latitude = lat ? parseFloat(lat) : null;
9 | const longitude = lng ? parseFloat(lng) : null;
10 |
11 | return [latitude, longitude];
12 | }
13 |
--------------------------------------------------------------------------------
/libs/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
4 |
5 | export const prisma = globalForPrisma.prisma || new PrismaClient();
6 |
7 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
8 |
--------------------------------------------------------------------------------
/libs/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import withAuth from "next-auth/middleware";
2 |
3 | export default withAuth({
4 | pages: {
5 | signIn: "/",
6 | },
7 | });
8 |
9 | export const config = {
10 | matcher: ["/app/:path*"],
11 | };
12 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "airbnb",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@next-auth/prisma-adapter": "^1.0.7",
13 | "@prisma/client": "^5.21.1",
14 | "@tailwindcss/forms": "^0.5.9",
15 | "axios": "^1.7.7",
16 | "clsx": "^2.1.1",
17 | "country-flag-emoji-polyfill": "^0.1.8",
18 | "leaflet": "^1.9.4",
19 | "leaflet-defaulticon-compatibility": "^0.1.2",
20 | "next": "14.2.7",
21 | "next-auth": "^4.24.10",
22 | "react": "^18",
23 | "react-datepicker": "^7.5.0",
24 | "react-dom": "^18",
25 | "react-hook-form": "^7.53.1",
26 | "react-icons": "^5.3.0",
27 | "react-leaflet": "^4.2.1",
28 | "tailwind-merge": "^2.5.4"
29 | },
30 | "devDependencies": {
31 | "@types/bcryptjs": "^2.4.6",
32 | "@types/leaflet": "^1.9.14",
33 | "@types/node": "^20",
34 | "@types/react": "^18",
35 | "@types/react-dom": "^18",
36 | "bcryptjs": "^2.4.3",
37 | "eslint": "^8",
38 | "eslint-config-next": "14.2.7",
39 | "postcss": "^8",
40 | "prisma": "^5.21.1",
41 | "tailwindcss": "^3.4.1",
42 | "typescript": "^5"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "postgresql"
3 | url = env("DATABASE_URL")
4 | }
5 |
6 | generator client {
7 | provider = "prisma-client-js"
8 | }
9 |
10 | model User {
11 | id String @id @default(cuid())
12 | name String?
13 | email String @unique
14 | emailVerified DateTime?
15 | image String?
16 | accounts Account[]
17 | sessions Session[]
18 | hashedPassword String?
19 |
20 | citiesId City[]
21 |
22 | createdAt DateTime @default(now())
23 | updatedAt DateTime @updatedAt
24 | }
25 |
26 | model Account {
27 | userId String
28 | type String
29 | provider String
30 | providerAccountId String
31 | refresh_token String?
32 | access_token String?
33 | expires_at Int?
34 | token_type String?
35 | scope String?
36 | id_token String?
37 | session_state String?
38 |
39 | createdAt DateTime @default(now())
40 | updatedAt DateTime @updatedAt
41 |
42 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
43 |
44 | @@id([provider, providerAccountId])
45 | }
46 |
47 | model Session {
48 | sessionToken String @unique
49 | userId String
50 | expires DateTime
51 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
52 |
53 | createdAt DateTime @default(now())
54 | updatedAt DateTime @updatedAt
55 | }
56 |
57 | model VerificationToken {
58 | identifier String
59 | token String
60 | expires DateTime
61 |
62 | @@id([identifier, token])
63 | }
64 |
65 | model City {
66 | id Int @id @default(autoincrement())
67 | cityName String
68 | country String
69 | emoji String @map("emoji")
70 | date DateTime
71 | notes String?
72 |
73 | positionId Int? @unique
74 | position Position? @relation(fields: [positionId], references: [id], onDelete: Cascade)
75 | userId String
76 | user User @relation(fields: [userId], references: [id])
77 |
78 | @@map("cities")
79 | }
80 |
81 | model Position {
82 | id Int @id @default(autoincrement())
83 | lat Float
84 | lng Float
85 | city City?
86 |
87 | @@map("positions")
88 | }
89 |
--------------------------------------------------------------------------------
/providers/FlagIconProviders.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
5 |
6 | const Polyfill = () => {
7 | useEffect(() => {
8 | polyfillCountryFlagEmojis();
9 | }, []);
10 |
11 | return null;
12 | };
13 |
14 | export default Polyfill;
15 |
--------------------------------------------------------------------------------
/public/bg.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TsotnePharsenadze/worldwise-react-nextjs-typescript/81781543513c063e50b50b17a3d9670d8cf641fc/public/bg.jpg
--------------------------------------------------------------------------------
/public/forGithub/1.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TsotnePharsenadze/worldwise-react-nextjs-typescript/81781543513c063e50b50b17a3d9670d8cf641fc/public/forGithub/1.PNG
--------------------------------------------------------------------------------
/public/forGithub/2.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TsotnePharsenadze/worldwise-react-nextjs-typescript/81781543513c063e50b50b17a3d9670d8cf641fc/public/forGithub/2.PNG
--------------------------------------------------------------------------------
/public/forGithub/3.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TsotnePharsenadze/worldwise-react-nextjs-typescript/81781543513c063e50b50b17a3d9670d8cf641fc/public/forGithub/3.PNG
--------------------------------------------------------------------------------
/public/forGithub/4.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TsotnePharsenadze/worldwise-react-nextjs-typescript/81781543513c063e50b50b17a3d9670d8cf641fc/public/forGithub/4.PNG
--------------------------------------------------------------------------------
/public/forGithub/5.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TsotnePharsenadze/worldwise-react-nextjs-typescript/81781543513c063e50b50b17a3d9670d8cf641fc/public/forGithub/5.PNG
--------------------------------------------------------------------------------
/public/forGithub/6.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TsotnePharsenadze/worldwise-react-nextjs-typescript/81781543513c063e50b50b17a3d9670d8cf641fc/public/forGithub/6.PNG
--------------------------------------------------------------------------------
/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TsotnePharsenadze/worldwise-react-nextjs-typescript/81781543513c063e50b50b17a3d9670d8cf641fc/public/icon.png
--------------------------------------------------------------------------------
/public/img-1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TsotnePharsenadze/worldwise-react-nextjs-typescript/81781543513c063e50b50b17a3d9670d8cf641fc/public/img-1.jpg
--------------------------------------------------------------------------------
/public/img-2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TsotnePharsenadze/worldwise-react-nextjs-typescript/81781543513c063e50b50b17a3d9670d8cf641fc/public/img-2.jpg
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TsotnePharsenadze/worldwise-react-nextjs-typescript/81781543513c063e50b50b17a3d9670d8cf641fc/public/logo.png
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
13 | "gradient-conic":
14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
15 | },
16 | },
17 | },
18 | plugins: [require("@tailwindcss/forms")],
19 | };
20 | export default config;
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------