├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── assertions.yml │ └── codeql.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .stylelintrc.js ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app ├── Page.module.css ├── api │ ├── places │ │ └── route.ts │ └── weather │ │ └── route.ts ├── favicon.ico ├── layout.tsx └── page.tsx ├── components ├── Alerts.tsx ├── BackToTop.module.css ├── BackTopTop.tsx ├── CurrentConditions.module.css ├── CurrentConditions.tsx ├── Footer.module.css ├── Footer.tsx ├── Forecast.module.css ├── Forecast.tsx ├── Header.module.css ├── Header.tsx ├── Icon.tsx ├── Meta.tsx ├── Search.module.css ├── Search.tsx ├── Settings.module.css ├── Settings.tsx └── WeatherProvider.tsx ├── lefthook.yml ├── lib ├── config.ts ├── helpers.ts ├── hooks.ts ├── theme.ts └── types.d.ts ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── icons │ ├── 01d.svg │ ├── 01n.svg │ ├── 02d.svg │ ├── 02n.svg │ ├── 03d.svg │ ├── 03n.svg │ ├── 04d.svg │ ├── 04n.svg │ ├── 09d.svg │ ├── 09n.svg │ ├── 10d.svg │ ├── 10n.svg │ ├── 11d.svg │ ├── 11n.svg │ ├── 13d.svg │ ├── 13n.svg │ ├── 50d.svg │ └── 50n.svg └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | end_of_line = lf 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | GOOGLE_MAPS_API_KEY="YOUR-KEY" 2 | OPENWEATHER_API_KEY="YOUR-KEY" 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | !/.*.js 2 | *.min.js 3 | .*cache/ 4 | .next/ 5 | build/ 6 | dist/ 7 | node_modules/ 8 | public/ 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['next/core-web-vitals', 'prettier'], 3 | rules: { 4 | '@next/next/no-img-element': 'off', 5 | 'func-style': ['error', 'declaration'], 6 | 'no-console': ['error', {allow: ['warn', 'error']}] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Code Owners 2 | # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners 3 | * @gregrickaby 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: gregrickaby 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | day: 'monday' 8 | -------------------------------------------------------------------------------- /.github/workflows/assertions.yml: -------------------------------------------------------------------------------- 1 | name: Assertions 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | workflow_dispatch: 8 | 9 | jobs: 10 | assertions: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout Repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Node 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 'lts/*' 21 | cache: 'npm' 22 | 23 | - name: Copy .env 24 | run: cp .env.example .env 25 | 26 | - name: Install Dependencies 27 | run: npm ci --ignore-scripts 28 | 29 | - name: Lint 30 | run: npm run lint 31 | 32 | - name: Build 33 | run: npm run build 34 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: 'CodeQL' 13 | 14 | on: 15 | push: 16 | branches: ['main'] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ['main'] 20 | schedule: 21 | - cron: '28 23 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ['javascript'] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@v2 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 62 | 63 | # If the Autobuild fails above, remove it and uncomment the following three lines. 64 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 65 | 66 | # - run: | 67 | # echo "Run, Build Application using script" 68 | # ./location_of_script_within_repo/buildscript.sh 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v2 72 | with: 73 | category: '/language:${{matrix.language}}' 74 | -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # Cache 38 | *.tsbuildinfo 39 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | useTabs: false, 4 | singleQuote: true, 5 | bracketSpacing: false, 6 | semi: false, 7 | trailingComma: 'none' 8 | } 9 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['stylelint-config-standard'] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "EditorConfig.EditorConfig", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode", 6 | "stylelint.vscode-stylelint" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll": "explicit" 4 | }, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnPaste": true, 7 | "editor.formatOnSave": true, 8 | "eslint.format.enable": false, 9 | "eslint.run": "onSave", 10 | "javascript.suggest.autoImports": true, 11 | "javascript.updateImportsOnFileMove.enabled": "always", 12 | "typescript.suggest.autoImports": true, 13 | "typescript.updateImportsOnFileMove.enabled": "always", 14 | "[typescript]": { 15 | "editor.codeActionsOnSave": { 16 | "source.organizeImports": "explicit" 17 | } 18 | }, 19 | "[typescriptreact]": { 20 | "editor.codeActionsOnSave": { 21 | "source.organizeImports": "explicit" 22 | } 23 | }, 24 | "[typescript][typescriptreact]": { 25 | "editor.codeActionsOnSave": { 26 | "source.organizeImports": "explicit" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Here are the ways to get involved with this project: 4 | 5 | - [Issues & Discussions](#issues--discussions) 6 | - [Contributing Code](#contributing-code) 7 | - [Install Locally](#install-locally) 8 | - [Generate API Keys](#generate-api-keys) 9 | - [OpenWeatherMap API](#openweathermap-api) 10 | - [Google Maps API](#google-maps-api) 11 | - [ENV Variables](#env-variables) 12 | - [Git Workflow](#git-workflow) 13 | - [NPM Scripts](#npm-scripts) 14 | - [Vercel CLI](#vercel-cli) 15 | - [Legal Stuff](#legal-stuff) 16 | 17 | --- 18 | 19 | ## Issues & Discussions 20 | 21 | Before submitting your issue, make sure it has not been mentioned earlier. You can search through the [existing issues](https://github.com/gregrickaby/local-weather/issues) or active [discussions](https://github.com/gregrickaby/local-weather/discussions). 22 | 23 | --- 24 | 25 | ## Contributing Code 26 | 27 | Found a bug you can fix? Fantastic! Patches are always welcome. Here are the steps to get up and running: 28 | 29 | ### Install Locally 30 | 31 | Clone the repo: 32 | 33 | ```bash 34 | git clone git@github.com:gregrickaby/local-weather.git local-weather 35 | ``` 36 | 37 | Install the dependencies: 38 | 39 | ```bash 40 | cd local-weather && npm i 41 | ``` 42 | 43 | --- 44 | 45 | ### Generate API Keys 46 | 47 | #### OpenWeatherMap API 48 | 49 | First, you'll need an [OpenWeatherMap API Key](https://home.openweathermap.org/users/sign_up). If you don't have an account, you can create one for free. 50 | 51 | #### Google Maps API 52 | 53 | Next, you'll need to generate a [Google Maps API Key](https://developers.google.com/maps/documentation/geocoding/get-api-key). 54 | 55 | After you've generated a key, visit the [Credentials page](https://console.cloud.google.com/projectselector2/google/maps-apis/credentials) and follow the instructions below: 56 | 57 | 1. Set application restrictions to "None" 58 | 2. Select "Restrict key" 59 | 3. Choose "Geocoding" and "Places" from the dropdown 60 | 4. Save 61 | 62 | ![screenshot of google api settings](https://dl.dropbox.com/s/2vj1qa2l1602prc/Screen%20Shot%202022-02-12%20at%2008.38.25.png?dl=0) 63 | 64 | #### ENV Variables 65 | 66 | Now add the API keys to `.env.` 67 | 68 | Copy `.env.example` to `.env` in the root of the project: 69 | 70 | ```bash 71 | cp .env.example .env 72 | ``` 73 | 74 | Open `.env` and add your API keys to `.env` 75 | 76 | ```text 77 | // .env 78 | GOOGLE_MAPS_API_KEY="YOUR-KEY-HERE" 79 | OPENWEATHER_API_KEY="YOUR-KEY-HERE" 80 | ``` 81 | 82 | --- 83 | 84 | ### Git Workflow 85 | 86 | 1. Fork the repo and create a feature/patch branch off `main` 87 | 2. Work locally adhering to coding standards 88 | 3. Run `npm run lint` 89 | 4. Make sure the app builds locally with `npm run build && npm run start` 90 | 5. Push your code, open a PR, and fill out the PR template 91 | 6. After peer review, the PR will be merged back into `main` 92 | 7. Repeat ♻️ 93 | 94 | > Your PR must pass automated assertions, deploy to Vercel successfully, and pass a peer review before it can be merged. 95 | 96 | --- 97 | 98 | ### NPM Scripts 99 | 100 | Start local dev server: 101 | 102 | ```bash 103 | npm run dev 104 | ``` 105 | 106 | Lint code: 107 | 108 | ```bash 109 | npm run lint 110 | ``` 111 | 112 | Test a build prior to deployment: 113 | 114 | ```bash 115 | npm run build && npm start 116 | ``` 117 | 118 | --- 119 | 120 | ### Vercel CLI 121 | 122 | I've found that running `vercel` locally is a great way to verify Edge Functions and Middleware are working as expected. 123 | 124 | To install the [Vercel CLI](https://vercel.com/docs/cli), run: 125 | 126 | ```bash 127 | npm i -g vercel 128 | ``` 129 | 130 | Start a Vercel development server locally: 131 | 132 | ```bash 133 | vercel dev 134 | ``` 135 | 136 | --- 137 | 138 | ## Legal Stuff 139 | 140 | This repo is maintained by [Greg Rickaby](https://gregrickaby.com/). By contributing code you grant its use under the [MIT](https://github.com/gregrickaby/local-weather/blob/main/LICENSE). 141 | 142 | --- 143 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2022 Greg Rickaby 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Weather 2 | 3 | A weather app using Next.js, Mantine, Edge API Routes, and the OpenWeatherMap and Google Maps API's. 4 | 5 | ⛈ View your local weather forecast: 6 | 7 | --- 8 | 9 | ## Contributing 10 | 11 | Please see [CONTRIBUTING](./CONTRIBUTING.md) to get started. 12 | 13 | --- 14 | 15 | ## Credits 16 | 17 | - React components by [Mantine](https://mantine.dev/) 18 | - Weather icons by [@basmilius](https://github.com/basmilius/weather-icons) 19 | - Weather data from [OpenWeatherMap API](https://openweathermap.org/api) 20 | - Geocoding data from [Google Maps](https://developers.google.com/maps/documentation/geocoding/overview) 21 | 22 | --- 23 | -------------------------------------------------------------------------------- /app/Page.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 0 auto; 3 | max-width: 1200px; 4 | padding: 0 20px; 5 | 6 | & > * { 7 | margin-bottom: 20px; 8 | margin-top: 20px; 9 | } 10 | } 11 | 12 | .search { 13 | align-items: center; 14 | display: flex; 15 | gap: 10px; 16 | margin-bottom: 20px; 17 | } 18 | 19 | .main { 20 | min-height: 100vh; 21 | } 22 | -------------------------------------------------------------------------------- /app/api/places/route.ts: -------------------------------------------------------------------------------- 1 | export const runtime = 'edge' 2 | 3 | export interface PredictionResponse { 4 | description: string 5 | } 6 | 7 | export interface Place { 8 | predictions: PredictionResponse[] 9 | status: string 10 | } 11 | 12 | /** 13 | * Predict the location via Google's Places Autocomplete API. 14 | * 15 | * @example 16 | * /api/places?location="enterprise, al" 17 | * 18 | * @author Greg Rickaby 19 | * @see https://console.cloud.google.com/apis/credentials 20 | * @see https://developers.google.com/maps/documentation/places/web-service/autocomplete 21 | * @see https://nextjs.org/docs/app/building-your-application/routing/route-handlers 22 | * @see https://nextjs.org/docs/pages/api-reference/edge 23 | */ 24 | export async function GET(request: Request) { 25 | // Get query params from request. 26 | const {searchParams} = new URL(request.url) 27 | 28 | // Parse params. 29 | const unsanitizedLocation = searchParams.get('location') || '' 30 | 31 | // Sanitize the location. 32 | const location = encodeURI(unsanitizedLocation) 33 | 34 | // No location? Bail... 35 | if (!location) { 36 | return new Response(JSON.stringify({error: 'No location provided.'}), { 37 | status: 400, 38 | statusText: 'Bad Request' 39 | }) 40 | } 41 | 42 | try { 43 | // Attempt to fetch the city from Google's Places API. 44 | const places = await fetch( 45 | `https://maps.googleapis.com/maps/api/place/autocomplete/json?input=${location}&types=(cities)&language=en&key=${process.env.GOOGLE_MAPS_API_KEY}` 46 | ) 47 | 48 | // Issue with the places response? Bail... 49 | if (places.status != 200) { 50 | return new Response( 51 | JSON.stringify({ 52 | error: `${places.statusText}` 53 | }), 54 | { 55 | status: places.status, 56 | statusText: places.statusText 57 | } 58 | ) 59 | } 60 | 61 | // Parse the response. 62 | const place = (await places.json()) as Place 63 | 64 | // Issue with the response? Bail... 65 | if (place.status != 'OK' || !place.predictions.length) { 66 | return new Response( 67 | JSON.stringify({ 68 | error: place.status 69 | }), 70 | { 71 | status: 400, 72 | statusText: 'Bad Request' 73 | } 74 | ) 75 | } 76 | 77 | // Build the list of locations. 78 | const locations = place.predictions.map( 79 | (prediction: PredictionResponse) => prediction.description 80 | ) as string[] 81 | 82 | // Return the list of locations. 83 | return new Response(JSON.stringify(locations), { 84 | headers: { 85 | 'Content-Type': 'application/json', 86 | 'Cache-Control': 's-maxage=300, stale-while-revalidate' 87 | }, 88 | status: 200, 89 | statusText: 'OK' 90 | }) 91 | } catch (error) { 92 | console.error(error) 93 | return new Response(JSON.stringify({error: `${error}`}), { 94 | status: 500, 95 | statusText: 'Internal Server Error' 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/api/weather/route.ts: -------------------------------------------------------------------------------- 1 | import {WeatherResponse} from '@/lib/types' 2 | 3 | export const runtime = 'edge' 4 | 5 | export interface GeocodeResponse { 6 | status: string 7 | results: [ 8 | { 9 | geometry: { 10 | location: { 11 | lat: number 12 | lng: number 13 | } 14 | } 15 | } 16 | ] 17 | } 18 | 19 | /** 20 | * Fetch weather data from the OpenWeatherMap API. 21 | * 22 | * @example 23 | * /api/weather?location="enterprise al" 24 | * 25 | * @author Greg Rickaby 26 | * @see https://console.cloud.google.com/apis/credentials 27 | * @see https://developers.google.com/maps/documentation/geocoding/overview 28 | * @see https://openweathermap.org/api/one-call-api 29 | * @see https://nextjs.org/docs/app/building-your-application/routing/route-handlers 30 | * @see https://nextjs.org/docs/pages/api-reference/edge 31 | */ 32 | export async function GET(request: Request) { 33 | // Get query params from request. 34 | const {searchParams} = new URL(request.url) 35 | 36 | // Parse params. 37 | const unsanitizedLocation = searchParams.get('location') || '' 38 | 39 | // Sanitize the location. 40 | const location = encodeURI(unsanitizedLocation) 41 | 42 | // No location? Bail... 43 | if (!location) { 44 | return new Response(JSON.stringify({error: 'No location provided.'}), { 45 | status: 400, 46 | statusText: 'Bad Request' 47 | }) 48 | } 49 | 50 | // Set default coordinates as fallback. 51 | let lat = 28.3886186 52 | let lon = -81.5659069 53 | 54 | try { 55 | // First, try to geocode the address. 56 | const geocode = await fetch( 57 | `https://maps.googleapis.com/maps/api/geocode/json?address=${location}&key=${process.env.GOOGLE_MAPS_API_KEY}` 58 | ) 59 | 60 | // Issue with the geocode request? Bail... 61 | if (geocode.status !== 200) { 62 | return new Response( 63 | JSON.stringify({ 64 | error: `${geocode.statusText}` 65 | }), 66 | { 67 | status: geocode.status, 68 | statusText: geocode.statusText 69 | } 70 | ) 71 | } 72 | 73 | // Parse the response. 74 | const coordinates = (await geocode.json()) as GeocodeResponse 75 | 76 | // Issue with the response? Bail... 77 | if (coordinates.status != 'OK' || !coordinates.results.length) { 78 | return new Response( 79 | JSON.stringify({ 80 | error: `${coordinates.status}` 81 | }), 82 | { 83 | status: 400, 84 | statusText: 'Bad Request' 85 | } 86 | ) 87 | } 88 | 89 | // Pluck out and set the coordinates. 90 | lat = coordinates?.results[0]?.geometry?.location?.lat 91 | lon = coordinates?.results[0]?.geometry?.location?.lng 92 | } catch (error) { 93 | console.error(error) 94 | return new Response(JSON.stringify({error: `${error}`}), { 95 | status: 500, 96 | statusText: 'Internal Server Error' 97 | }) 98 | } 99 | 100 | try { 101 | // Now, fetch the weather data. 102 | const weather = await fetch( 103 | `https://api.openweathermap.org/data/2.5/onecall?lat=${lat}&lon=${lon}&units=metric&exclude=minutely&appid=${process.env.OPENWEATHER_API_KEY}` 104 | ) 105 | 106 | // Issue with the weather response? Bail... 107 | if (weather.status != 200) { 108 | return new Response( 109 | JSON.stringify({ 110 | error: `${weather.statusText}` 111 | }), 112 | { 113 | status: weather.status, 114 | statusText: weather.statusText 115 | } 116 | ) 117 | } 118 | 119 | // Parse the response. 120 | const forecast = (await weather.json()) as WeatherResponse 121 | 122 | // Issue with the forecast? Bail... 123 | if (!forecast.lat || !forecast.lon) { 124 | return new Response( 125 | JSON.stringify({ 126 | error: 'No forecast data.' 127 | }), 128 | { 129 | status: 400, 130 | statusText: 'Bad Request' 131 | } 132 | ) 133 | } 134 | 135 | // Return the weather data. 136 | return new Response(JSON.stringify(forecast), { 137 | headers: { 138 | 'Content-Type': 'application/json', 139 | 'Cache-Control': 's-maxage=300, stale-while-revalidate' 140 | }, 141 | status: 200, 142 | statusText: 'OK' 143 | }) 144 | } catch (error) { 145 | console.error(error) 146 | return new Response(JSON.stringify({error: `${error}`}), { 147 | status: 500, 148 | statusText: 'Internal Server Error' 149 | }) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregrickaby/local-weather/19802609deb4eb708f5613b1a69135742419c2d3/app/favicon.ico -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import Meta from '@/components/Meta' 2 | import WeatherProvider from '@/components/WeatherProvider' 3 | import config from '@/lib/config' 4 | import theme from '@/lib/theme' 5 | import {ColorSchemeScript, MantineProvider} from '@mantine/core' 6 | import '@mantine/core/styles.css' 7 | 8 | export const metadata = { 9 | title: `${config.siteName} - ${config.siteDescription}`, 10 | description: config.siteDescription 11 | } 12 | 13 | /** 14 | * Root layout component. 15 | */ 16 | export default function RootLayout({children}: {children: any}) { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {children} 26 | 27 | 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import classes from '@/app/Page.module.css' 4 | import Alerts from '@/components/Alerts' 5 | import BackToTop from '@/components/BackTopTop' 6 | import CurrentConditions from '@/components/CurrentConditions' 7 | import Footer from '@/components/Footer' 8 | import Forecast from '@/components/Forecast' 9 | import Header from '@/components/Header' 10 | import Search from '@/components/Search' 11 | import {useWeatherContext} from '@/components/WeatherProvider' 12 | import {LoadingOverlay} from '@mantine/core' 13 | 14 | /** 15 | * Home page component. 16 | */ 17 | export default function HomePage() { 18 | const {isLoading} = useWeatherContext() 19 | 20 | return ( 21 | <> 22 |
23 |
24 | 25 | {!isLoading && ( 26 |
27 |
28 | 29 |
30 | 31 | 32 | 33 |
34 | )} 35 |
36 | 37 |
38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/Alerts.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import {useWeatherContext} from '@/components/WeatherProvider' 4 | import {Alert, Text, Title} from '@mantine/core' 5 | import {notifications} from '@mantine/notifications' 6 | import {IconAlertTriangle} from '@tabler/icons-react' 7 | import {useEffect} from 'react' 8 | 9 | /** 10 | * Alerts component. 11 | */ 12 | export default function Alerts() { 13 | const { 14 | weather: {alerts} 15 | } = useWeatherContext() 16 | 17 | /** 18 | * If there are alerts, display a notification. 19 | */ 20 | useEffect(() => { 21 | if (!!alerts && alerts?.length > 0) { 22 | notifications.show({ 23 | autoClose: 5000, 24 | color: 'red', 25 | icon: , 26 | message: 27 | 'Hazardous weather conditions reported for this area. Scroll down for details.', 28 | title: 'Warning' 29 | }) 30 | } 31 | }, [alerts]) 32 | 33 | // No alerts? Bail... 34 | if (!alerts) { 35 | return null 36 | } 37 | 38 | return ( 39 |
40 | 41 | Alerts 42 | 43 | {alerts?.map((alert, index: number) => ( 44 | } 47 | key={index} 48 | mb="xl" 49 | title={alert?.event} 50 | > 51 | {alert?.description} 52 | 53 | ))} 54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /components/BackToTop.module.css: -------------------------------------------------------------------------------- 1 | .backtotop { 2 | bottom: 12px; 3 | position: fixed; 4 | right: 12px; 5 | z-index: 100; 6 | } 7 | -------------------------------------------------------------------------------- /components/BackTopTop.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import classes from '@/components/BackToTop.module.css' 4 | import {Affix, Button, rem, Transition} from '@mantine/core' 5 | import {useWindowScroll} from '@mantine/hooks' 6 | import {IconArrowUp} from '@tabler/icons-react' 7 | 8 | /** 9 | * Back To Top component. 10 | */ 11 | export default function BackToTop() { 12 | const [scroll, scrollTo] = useWindowScroll() 13 | 14 | return ( 15 |
16 | 17 | 0}> 18 | {(transitionStyles) => ( 19 | 27 | )} 28 | 29 | 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /components/CurrentConditions.module.css: -------------------------------------------------------------------------------- 1 | .description { 2 | font-size: 2rem; 3 | font-weight: 700; 4 | line-height: 1; 5 | margin: 0; 6 | text-align: center; 7 | text-transform: capitalize; 8 | } 9 | 10 | .bigtemp { 11 | font-size: 8rem; 12 | font-weight: 700; 13 | line-height: 1; 14 | margin: 0; 15 | text-align: center; 16 | } 17 | 18 | .feelslike { 19 | font-size: 2rem; 20 | font-weight: 700; 21 | line-height: 1; 22 | margin: 0; 23 | text-align: center; 24 | } 25 | -------------------------------------------------------------------------------- /components/CurrentConditions.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import classes from '@/components/CurrentConditions.module.css' 4 | import {useWeatherContext} from '@/components/WeatherProvider' 5 | import {formatTemperature} from '@/lib/helpers' 6 | import {Stack, Text} from '@mantine/core' 7 | 8 | /** 9 | * Current Conditions component. 10 | */ 11 | export default function CurrentConditions() { 12 | const { 13 | weather: { 14 | current: { 15 | weather: [{description}], 16 | temp, 17 | feels_like 18 | } 19 | }, 20 | tempUnit 21 | } = useWeatherContext() 22 | 23 | return ( 24 | 25 | 31 | {description} 32 | 33 | 39 | {formatTemperature(tempUnit, temp)} 40 | 41 | {feels_like > temp && ( 42 | 48 | Feels Like: {formatTemperature(tempUnit, feels_like)} 49 | 50 | )} 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /components/Footer.module.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | display: flex; 3 | flex-direction: column; 4 | font-family: monospace; 5 | font-size: 0.875rem; 6 | text-align: center; 7 | 8 | p { 9 | margin-bottom: 0; 10 | } 11 | 12 | a { 13 | color: var(--mantine-color-anchor); 14 | 15 | &:hover { 16 | text-decoration: none; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import classes from '@/components/Footer.module.css' 2 | import config from '@/lib/config' 3 | import {IconBrandGithub} from '@tabler/icons-react' 4 | 5 | /** 6 | * Footer component. 7 | */ 8 | export default function Footer() { 9 | return ( 10 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /components/Forecast.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | text-align: center; 3 | } 4 | 5 | .card { 6 | text-align: center; 7 | 8 | img { 9 | margin: 0 auto; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /components/Forecast.tsx: -------------------------------------------------------------------------------- 1 | import classes from '@/components/Forecast.module.css' 2 | import Icon from '@/components/Icon' 3 | import {useWeatherContext} from '@/components/WeatherProvider' 4 | import {formatDay, formatTemperature, formatTime} from '@/lib/helpers' 5 | import {Card, SimpleGrid, Space, Text, Title} from '@mantine/core' 6 | 7 | /** 8 | * Forecast component. 9 | */ 10 | export default function Forecast() { 11 | const {weather, tempUnit} = useWeatherContext() 12 | 13 | return ( 14 |
15 | 16 | 17 | The Next 4 Hours 18 | 19 | 20 | {weather?.hourly 21 | ?.map((forecast, index: number) => { 22 | const { 23 | dt, 24 | weather: [{icon, main}], 25 | temp, 26 | feels_like 27 | } = forecast 28 | return ( 29 | 30 | {formatTime(dt)} 31 | {formatTemperature(tempUnit, temp)} 32 | 33 | {main} 34 | {feels_like > temp && ( 35 | 40 | Feels Like: {formatTemperature(tempUnit, feels_like)} 41 | 42 | )} 43 | 44 | ) 45 | }) 46 | .slice(1, 5)} 47 | 48 | 49 | 50 | Extended Forecast 51 | 52 | 53 | 54 | {weather?.daily?.map((forecast, index: number) => { 55 | const { 56 | dt, 57 | pop, 58 | weather: [{icon, main}], 59 | temp: {min, max}, 60 | feels_like: {day} 61 | } = forecast 62 | return ( 63 | 64 | {formatDay(dt, index)} 65 | 66 | {main} {pop ? `${Math.round(pop * 100)}%` : ''} 67 | 68 | 69 | H {formatTemperature(tempUnit, max)} / L{' '} 70 | {formatTemperature(tempUnit, min)} 71 | 72 | 73 | {day > max && ( 74 | 79 | Feels Like: {formatTemperature(tempUnit, day)} 80 | 81 | )} 82 | 83 | ) 84 | })} 85 | 86 |
87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /components/Header.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | margin: var(--mantine-spacing-xl) 0; 3 | } 4 | 5 | .title { 6 | text-align: center; 7 | margin-bottom: var(--mantine-spacing-xl); 8 | } 9 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import classes from '@/components/Header.module.css' 2 | import config from '@/lib/config' 3 | import {Title} from '@mantine/core' 4 | 5 | /** 6 | * Header component. 7 | */ 8 | export default function Header() { 9 | return ( 10 | <> 11 |
12 | 13 | {config.siteName} 14 | 15 |
16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | 3 | /** 4 | * Icon component. 5 | */ 6 | export default function Icon({icon}: {icon: string}) { 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /components/Meta.tsx: -------------------------------------------------------------------------------- 1 | import config from '@/lib/config' 2 | 3 | /** 4 | * Meta component. 5 | */ 6 | export default function Meta() { 7 | return ( 8 | <> 9 | {config.siteName} 10 | 11 | 17 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /components/Search.module.css: -------------------------------------------------------------------------------- 1 | .searchbar { 2 | flex-basis: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /components/Search.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import classes from '@/components/Search.module.css' 4 | import Settings from '@/components/Settings' 5 | import {useWeatherContext} from '@/components/WeatherProvider' 6 | import {usePlaces} from '@/lib/hooks' 7 | import {Autocomplete} from '@mantine/core' 8 | import {useDebouncedValue} from '@mantine/hooks' 9 | import {IconMapPin} from '@tabler/icons-react' 10 | import {useState} from 'react' 11 | 12 | /** 13 | * Search component. 14 | */ 15 | export default function Search() { 16 | const {location, setLocation} = useWeatherContext() 17 | const [searchTerm, setSearchTerm] = useState(location) 18 | const [debounced] = useDebouncedValue(searchTerm, 400) 19 | const {locations} = usePlaces(debounced) 20 | 21 | const places = 22 | !!locations && locations.length > 0 23 | ? locations 24 | : [ 25 | 'New York, NY', 26 | 'Los Angeles, CA', 27 | 'Chicago, IL', 28 | 'Houston, TX', 29 | 'Phoenix, AZ', 30 | 'Philadelphia, PA', 31 | 'San Antonio, TX', 32 | 'San Diego, CA', 33 | 'Dallas, TX', 34 | 'San Jose, CA' 35 | ] 36 | 37 | return ( 38 | <> 39 | } 44 | limit={10} 45 | onChange={setSearchTerm} 46 | onOptionSubmit={(item) => setLocation(item)} 47 | placeholder="Enter the name of your location" 48 | size="lg" 49 | value={searchTerm} 50 | /> 51 | 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /components/Settings.module.css: -------------------------------------------------------------------------------- 1 | .settings { 2 | @media screen and (width <= 768px) { 3 | position: absolute; 4 | right: 10px; 5 | top: 10px; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /components/Settings.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import classes from '@/components/Settings.module.css' 4 | import {useWeatherContext} from '@/components/WeatherProvider' 5 | import config from '@/lib/config' 6 | import { 7 | ActionIcon, 8 | Flex, 9 | Modal, 10 | Stack, 11 | Switch, 12 | useMantineColorScheme 13 | } from '@mantine/core' 14 | import {useDisclosure, useHotkeys} from '@mantine/hooks' 15 | import {IconSettings} from '@tabler/icons-react' 16 | import {useState} from 'react' 17 | 18 | /** 19 | * Settings component. 20 | */ 21 | export default function Settings() { 22 | const [opened, {open, close}] = useDisclosure(false) 23 | const {colorScheme, toggleColorScheme} = useMantineColorScheme() 24 | const {tempUnit, setTempUnit} = useWeatherContext() 25 | const [checked, setChecked] = useState(tempUnit === 'f' ? true : false) 26 | 27 | function toggleTempUnit() { 28 | setChecked(!checked) 29 | setTempUnit(checked ? 'c' : 'f') 30 | } 31 | 32 | useHotkeys([['mod+u', () => toggleTempUnit()]]) 33 | 34 | return ( 35 | <> 36 | 43 | 44 | 45 | 52 | 53 | toggleColorScheme()} 59 | onLabel="ON" 60 | size="lg" 61 | /> 62 | toggleTempUnit()} 68 | onLabel="ON" 69 | size="lg" 70 | /> 71 | 78 | Thank you for using {config.siteName}! Would you consider sponsoring 79 | further development for just $5? 80 |