diff --git a/t20-project-2/README.md b/t20-project-2/README.md index 653fa86..4ba7a40 100644 --- a/t20-project-2/README.md +++ b/t20-project-2/README.md @@ -1,5 +1,20 @@ # React + TypeScript + Vite +## GraphQL connection + +The city explorer uses the Apollo API at `http://it2810-20.idi.ntnu.no:3001/` through a same-origin `/api/graphql` route. The Vite development server proxies that route to Apollo, so the browser does not need direct cross-origin access. + +Install and start this project from its folder with: + +```sh +pnpm --ignore-workspace install --frozen-lockfile +pnpm --ignore-workspace dev +``` + +The `--ignore-workspace` option is needed when this project is opened underneath a parent directory that has its own `pnpm-workspace.yaml`. + +For production, configure the web server to proxy `/api/graphql` to the Apollo server and serve the site over HTTPS. Alternatively, set `VITE_GRAPHQL_URL` to an HTTPS GraphQL endpoint and configure CORS on Apollo to allow the site's origin. Do not expose the MongoDB connection string to the frontend. City search, filters, pagination, and reviews use the API; saved cities remain in the current browser because the API does not provide account or bookmark operations. + This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Currently, two official plugins are available: diff --git a/t20-project-2/src/App.tsx b/t20-project-2/src/App.tsx index b9a73a1..ca18298 100644 --- a/t20-project-2/src/App.tsx +++ b/t20-project-2/src/App.tsx @@ -1,92 +1,95 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, type FormEvent } from 'react' +import { graphqlRequest } from './graphql' + +type Review = { + id: string + cityId: string + rating: number + comment: string | null + author: string + createdAt: string +} type City = { id: string name: string - country: string - continent: string - description: string - rating: string - reviews: number - population: string - elevation: string - image: string - imageAlt: string + country: string | null + latitude: number | null + longitude: number | null + population: number | null + timezone: string | null + reviewCount: number + averageRating: number | null + reviews: Review[] } -const cities: City[] = [ - { - id: 'copenhagen', - name: 'Copenhagen', - country: 'Denmark', - continent: 'Europe', - description: 'Bike-friendly streets, Nordic design, and long summer evenings by the water.', - rating: '4.8', - reviews: 248, - population: '653,000 residents', - elevation: '14 m above sea level', - image: - 'https://images.unsplash.com/photo-1513622470522-26c3c8a854bc?auto=format&fit=crop&w=420&q=78', - imageAlt: 'Colorful houses along Nyhavn in Copenhagen', - }, - { - id: 'lisbon', - name: 'Lisbon', - country: 'Portugal', - continent: 'Europe', - description: 'Pastel facades, steep hills, and the salty Atlantic breeze.', - rating: '4.9', - reviews: 391, - population: '545,000 residents', - elevation: '2 m above sea level', - image: - 'https://images.unsplash.com/photo-1555881400-74d7acaacd8b?auto=format&fit=crop&w=420&q=78', - imageAlt: 'Yellow tram on a street in Lisbon', - }, - { - id: 'kyoto', - name: 'Kyoto', - country: 'Japan', - continent: 'Asia', - description: 'Timber houses, peaceful temple gardens, and a city in constant motion.', - rating: '4.7', - reviews: 184, - population: '1.46 million residents', - elevation: '50 m above sea level', - image: - 'https://images.unsplash.com/photo-1493976040374-85c8e12f0c0e?auto=format&fit=crop&w=420&q=78', - imageAlt: 'Traditional Kyoto street with a pagoda in the background', - }, -] +type CityPage = { items: City[]; totalCount: number; hasMore: boolean } +type CityFilter = { country?: string; minPopulation?: number; maxPopulation?: number } +type CitySort = { field: 'NAME' | 'POPULATION' | 'COUNTRY'; direction: 'ASC' | 'DESC' } -const storageKey = 'country-boys:saved-cities' +const pageSize = 12 +const savedCitiesStorageKey = 'country-boys:saved-cities' +const citiesQuery = ` + query SearchCities($search: String, $filter: CityFilter, $sort: CitySort, $limit: Int, $offset: Int) { + searchCities(search: $search, filter: $filter, sort: $sort, limit: $limit, offset: $offset) { + items { + id name country latitude longitude population timezone reviewCount averageRating + reviews { id cityId rating comment author createdAt } + } + totalCount + hasMore + } + } +` +const countriesQuery = 'query { countries }' +const addReviewMutation = ` + mutation AddReview($input: AddReviewInput!) { + addReview(input: $input) { id cityId rating comment author createdAt } + } +` -function readSavedCities(): string[] { +function readSavedCities(): City[] { try { - const stored = window.localStorage.getItem(storageKey) - const parsed: unknown = stored ? JSON.parse(stored) : [] - - if (!Array.isArray(parsed)) return [] - - return parsed.filter( - (id): id is string => - typeof id === 'string' && cities.some((city) => city.id === id), + const stored: unknown = JSON.parse(window.localStorage.getItem(savedCitiesStorageKey) ?? '[]') + if (!Array.isArray(stored)) return [] + return stored.filter( + (city): city is City => + typeof city === 'object' && city !== null && + 'id' in city && typeof city.id === 'string' && + 'name' in city && typeof city.name === 'string', ) } catch { return [] } } +function formatPopulation(population: number | null) { + return population === null + ? 'Population unavailable' + : `${new Intl.NumberFormat('en-US').format(population)} residents` +} + +function getCityFilter(country: string, populationRange: string): CityFilter | undefined { + const populationBounds: Record> = { + any: {}, + small: { maxPopulation: 100000 }, + medium: { minPopulation: 100000, maxPopulation: 1000000 }, + large: { minPopulation: 1000000 }, + } + const filter = { ...(country ? { country } : {}), ...(populationBounds[populationRange] ?? {}) } + return Object.keys(filter).length ? filter : undefined +} + +function getCitySort(sort: string): CitySort { + if (sort === 'name') return { field: 'NAME', direction: 'ASC' } + if (sort === 'country') return { field: 'COUNTRY', direction: 'ASC' } + return { field: 'POPULATION', direction: 'DESC' } +} + function BookmarkIcon({ filled }: { filled: boolean }) { return ( ) } @@ -94,149 +97,244 @@ function BookmarkIcon({ filled }: { filled: boolean }) { function CityCard({ city, saved, + detailsOpen, + reviewMessage, onToggleSaved, + onToggleDetails, + onAddReview, }: { city: City saved: boolean - onToggleSaved: (cityId: string) => void + detailsOpen: boolean + reviewMessage: string + onToggleSaved: (city: City) => void + onToggleDetails: (cityId: string) => void + onAddReview: (event: FormEvent, cityId: string) => void }) { return ( -
  • - {city.imageAlt} +
  • -

    - {city.country} · {city.continent} -

    +

    {city.country ?? 'Country unavailable'}

    {city.name}

    -
    -

    {city.description}

    - - {city.rating}{' '} - ({city.reviews}) - - - {city.population} + {city.averageRating === null ? 'No ratings' : city.averageRating.toFixed(1)} ({city.reviewCount} reviews) - {city.elevation} + {formatPopulation(city.population)} + {city.timezone && <>{city.timezone}}
    - - View city details - + + {detailsOpen && ( +
    + {city.latitude !== null && city.longitude !== null &&

    Coordinates: {city.latitude.toFixed(4)}, {city.longitude.toFixed(4)}

    } +

    Reviews

    + {city.reviews.length > 0 ? ( +
      + {city.reviews.map((review) => ( +
    • + {review.author}{'★'.repeat(review.rating)} + {review.comment &&

      {review.comment}

      } +
    • + ))} +
    + ) :

    No reviews yet. Add the first one.

    } +
    onAddReview(event, city.id)}> + + +