├── .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 | 
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 | }
22 | style={transitionStyles}
23 | onClick={() => scrollTo({y: 0})}
24 | >
25 | Scroll to top
26 |
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 |
87 |
88 |
89 |
90 | >
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/components/WeatherProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {useWeather} from '@/lib/hooks'
4 | import {ChildrenProps, WeatherResponse} from '@/lib/types'
5 | import {useLocalStorage} from '@mantine/hooks'
6 | import {createContext, useContext} from 'react'
7 |
8 | export interface WeatherContextProps {
9 | isLoading: boolean
10 | location: string
11 | setLocation: (location: string) => void
12 | setTempUnit: (unit: 'c' | 'f') => void
13 | tempUnit: string
14 | weather: WeatherResponse
15 | }
16 |
17 | // Create the WeatherContext.
18 | const WeatherContext = createContext({} as WeatherContextProps)
19 |
20 | // Create useWeatherContext hook.
21 | export function useWeatherContext() {
22 | return useContext(WeatherContext)
23 | }
24 |
25 | export default function WeatherProvider({children}: ChildrenProps) {
26 | const [location, setLocation] = useLocalStorage({
27 | key: 'location',
28 | defaultValue: 'Enterprise, AL',
29 | getInitialValueInEffect: true
30 | })
31 |
32 | const [tempUnit, setTempUnit] = useLocalStorage({
33 | key: 'tempUnit',
34 | defaultValue: 'f',
35 | getInitialValueInEffect: true
36 | })
37 |
38 | const {weather, isLoading} = useWeather(location as string)
39 |
40 | return (
41 |
53 | {children}
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/lefthook.yml:
--------------------------------------------------------------------------------
1 | # Refer for explanation to following link:
2 | # https://github.com/evilmartians/lefthook/blob/master/docs/usage.md
3 |
4 | pre-commit:
5 | parallel: true
6 | commands:
7 | eslint:
8 | glob: '*.{js,jsx,ts,tsx}'
9 | run: npx eslint {staged_files} --ignore-path .gitignore --fix
10 | stylelint:
11 | glob: '*.css'
12 | run: npx stylelint {staged_files} --ignore-path .gitignore --fix
13 | prettier:
14 | glob: '.'
15 | run: npx prettier {staged_files} --ignore-path .gitignore --write
16 |
--------------------------------------------------------------------------------
/lib/config.ts:
--------------------------------------------------------------------------------
1 | const config = {
2 | siteName: 'Local Weather',
3 | siteDescription: 'View the current weather conditions',
4 | metaDescription:
5 | 'View the current weather conditions, long-range forecast, and get weather alerts for your local area.',
6 | siteUrl: 'https://localwx.vercel.app/',
7 | siteAuthor: 'Greg Rickaby',
8 | authorUrl: 'https://gregrickaby.com',
9 | githubUrl: 'https://github.com/gregrickaby/local-weather'
10 | }
11 |
12 | export default config
13 |
--------------------------------------------------------------------------------
/lib/helpers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Generic fetcher for SWR library.
3 | */
4 | export async function fetcher(url: string) {
5 | return await fetch(url).then((res) => res.json())
6 | }
7 |
8 | /**
9 | * Format the temperature in either Fahrenheit or Celsius.
10 | *
11 | * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat
12 | */
13 | export function formatTemperature(tempUnit: string, temp: number): string {
14 | const temperature = tempUnit === 'c' ? temp : temp * 1.8 + 32
15 |
16 | return new Intl.NumberFormat('en-US', {
17 | style: 'unit',
18 | unit: tempUnit === 'c' ? 'celsius' : 'fahrenheit'
19 | }).format(Math.round(temperature))
20 | }
21 |
22 | export function formatDay(day: number, index: number): string {
23 | // Get the current day.
24 | const now = new Date()
25 |
26 | // Format today's day of the week.
27 | const today = new Intl.DateTimeFormat('en-US', {weekday: 'long'}).format(now)
28 |
29 | // Format tomorrow's day of the week.
30 | const tomorrow = new Intl.DateTimeFormat('en-US', {weekday: 'long'}).format(
31 | now.setDate(now.getDate() + 1)
32 | )
33 |
34 | // Format the day of the week set in OpenWeather API.
35 | let dayOfWeek = new Intl.DateTimeFormat('en-US', {weekday: 'long'}).format(
36 | day * 1000
37 | )
38 |
39 | if (dayOfWeek === today && index === 0) {
40 | dayOfWeek = 'Today'
41 | }
42 |
43 | if (dayOfWeek === tomorrow) {
44 | dayOfWeek = 'Tomorrow'
45 | }
46 |
47 | return dayOfWeek
48 | }
49 |
50 | /**
51 | * Convert UNIX time into human readable format.
52 | *
53 | * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat
54 | */
55 | export function formatTime(time: number): string {
56 | return new Intl.DateTimeFormat('en-US', {
57 | hour: 'numeric'
58 | }).format(time * 1000)
59 | }
60 |
--------------------------------------------------------------------------------
/lib/hooks.ts:
--------------------------------------------------------------------------------
1 | import {fetcher} from '@/lib/helpers'
2 | import {PlacesData, WeatherData} from '@/lib/types'
3 | import useSWR from 'swr'
4 |
5 | /**
6 | * Fetches the list of locations from `/api/places`.
7 | *
8 | * @see https://swr.vercel.app/
9 | */
10 | export function usePlaces(location: string): PlacesData {
11 | const {data, error} = useSWR(`/api/places?location=${location}`, fetcher)
12 |
13 | return {
14 | locations: data,
15 | isLoading: !error && !data,
16 | isError: error
17 | }
18 | }
19 |
20 | /**
21 | * Fetches the weather data from `/api/weather`.
22 | *
23 | * @see https://swr.vercel.app/
24 | */
25 | export function useWeather(location: string): WeatherData {
26 | const {data, error} = useSWR(`/api/weather?location=${location}`, fetcher)
27 |
28 | return {
29 | weather: data,
30 | isLoading: !error && !data,
31 | isError: error
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/lib/theme.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {createTheme} from '@mantine/core'
4 |
5 | /**
6 | * Customize Mantine Theme.
7 | *
8 | * @see https://mantine.dev/theming/mantine-provider/#theme
9 | */
10 | const theme = createTheme({
11 | primaryColor: 'gray'
12 | })
13 |
14 | export default theme
15 |
--------------------------------------------------------------------------------
/lib/types.d.ts:
--------------------------------------------------------------------------------
1 | export interface PlacesData {
2 | locations: string[]
3 | isLoading: boolean
4 | isError: boolean
5 | }
6 |
7 | export interface WeatherData {
8 | weather: WeatherResponse
9 | isLoading: boolean
10 | isError: boolean
11 | }
12 |
13 | export interface ChildrenProps {
14 | children: React.ReactNode
15 | }
16 |
17 | export interface WeatherResponse {
18 | lat: number
19 | lon: number
20 | timezone: string
21 | timezone_offset: number
22 | current: {
23 | st
24 | dt: number
25 | sunrise: number
26 | sunset: number
27 | temp: number
28 | feels_like: number
29 | pressure: number
30 | humidity: number
31 | dew_point: number
32 | uvi: number
33 | clouds: number
34 | visibility: number
35 | wind_speed: number
36 | wind_deg: number
37 | weather: [
38 | {
39 | rain: number
40 | id: number
41 | main: string
42 | description: string
43 | icon: string
44 | }
45 | ]
46 | rain: {
47 | '1h': number
48 | }
49 | }
50 | hourly: [
51 | {
52 | dt: number
53 | temp: number
54 | feels_like: number
55 | pressure: number
56 | humidity: number
57 | dew_point: number
58 | uvi: number
59 | clouds: number
60 | visibility: number
61 | wind_speed: number
62 | wind_deg: number
63 | wind_gust: number
64 | weather: [
65 | {
66 | id: number
67 | main: string
68 | description: string
69 | icon: string
70 | }
71 | ]
72 | pop: number
73 | rain: {
74 | '1h': number
75 | }
76 | }
77 | ]
78 | daily: [
79 | {
80 | dt: number
81 | sunrise: number
82 | sunset: number
83 | moonrise: number
84 | moonset: number
85 | moon_phase: number
86 | temp: {
87 | day: number
88 | min: number
89 | max: number
90 | night: number
91 | eve: number
92 | morn: number
93 | }
94 | feels_like: {
95 | day: number
96 | night: number
97 | eve: number
98 | morn: number
99 | }
100 | pressure: number
101 | humidity: number
102 | dew_point: number
103 | wind_speed: number
104 | wind_deg: number
105 | wind_gust: number
106 | weather: [
107 | {
108 | id: number
109 | main: string
110 | description: string
111 | icon: string
112 | }
113 | ]
114 | clouds: number
115 | pop: number
116 | rain: number
117 | uvi: number
118 | }
119 | ]
120 | alerts: [
121 | {
122 | sender_name: string
123 | event: string
124 | start: number
125 | end: number
126 | description: string
127 | tags: [string]
128 | }
129 | ]
130 | }
131 |
132 | export interface GeocodeResponse {
133 | results: [
134 | {
135 | address_components: [
136 | {
137 | long_name: string
138 | short_name: string
139 | types: [string, string]
140 | },
141 | {
142 | long_name: string
143 | short_name: string
144 | types: [string, string]
145 | },
146 | {
147 | long_name: string
148 | short_name: string
149 | types: [string, string]
150 | },
151 | {
152 | long_name: string
153 | short_name: string
154 | types: [string, string]
155 | },
156 | {
157 | long_name: string
158 | short_name: string
159 | types: [string]
160 | }
161 | ]
162 | formatted_address: string
163 | geometry: {
164 | bounds: {
165 | northeast: {
166 | lat: number
167 | lng: number
168 | }
169 | southwest: {
170 | lat: number
171 | lng: number
172 | }
173 | }
174 | location: {
175 | lat: number
176 | lng: number
177 | }
178 | location_type: string
179 | viewport: {
180 | northeast: {
181 | lat: number
182 | lng: number
183 | }
184 | southwest: {
185 | lat: number
186 | lng: number
187 | }
188 | }
189 | }
190 | place_id: string
191 | types: [string, string]
192 | }
193 | ]
194 | status: string
195 | }
196 |
197 | export interface IpDataResponse {
198 | ip: string
199 | is_eu: boolean
200 | city: string
201 | region: string
202 | region_code: string
203 | country_name: string
204 | country_code: string
205 | continent_name: string
206 | continent_code: string
207 | latitude: number
208 | longitude: number
209 | postal: string
210 | calling_code: string
211 | flag: string
212 | emoji_flag: string
213 | emoji_unicode: string
214 | asn: {
215 | asn: string
216 | name: string
217 | domain: string
218 | route: string
219 | type: string
220 | }
221 | languages: [
222 | {
223 | name: string
224 | native: string
225 | code: string
226 | }
227 | ]
228 | currency: {
229 | name: string
230 | code: string
231 | symbol: string
232 | native: string
233 | plural: string
234 | }
235 | time_zone: {
236 | name: string
237 | abbr: string
238 | offset: string
239 | is_dst: boolean
240 | current_time: string
241 | }
242 | threat: {
243 | is_tor: boolean
244 | is_proxy: boolean
245 | is_anonymous: boolean
246 | is_known_attacker: boolean
247 | is_known_abuser: boolean
248 | is_threat: boolean
249 | is_bogon: boolean
250 | }
251 | count: string
252 | }
253 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | formats: ['image/avif', 'image/webp']
5 | },
6 | logging: {
7 | fetches: {
8 | fullUrl: true
9 | }
10 | }
11 | }
12 |
13 | module.exports = nextConfig
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@gregrickaby/local-weather",
3 | "description": "A weather app using Next.js, Mantine, Edge API Routes, and the OpenWeatherMap and Google Maps API's.",
4 | "private": true,
5 | "version": "1.0",
6 | "homepage": "https://localwx.vercel.app/",
7 | "license": "MIT",
8 | "repository": {
9 | "type": "git",
10 | "url": "git+https://github.com/gregrickaby/local-weather.git"
11 | },
12 | "author": "Greg Rickaby ",
13 | "bugs": {
14 | "url": "https://github.com/gregrickaby/local-weather/issues"
15 | },
16 | "scripts": {
17 | "build": "next build",
18 | "dev": "rimraf .next && next dev",
19 | "format": "prettier . --ignore-path .gitignore --write",
20 | "lint": "npm run lint:css && npm run lint:js && next lint && tsc",
21 | "lint:css": "stylelint '**/*.css' --ignore-path .gitignore --fix",
22 | "lint:js": "eslint . --ignore-path .gitignore --fix",
23 | "start": "next start"
24 | },
25 | "dependencies": {
26 | "@mantine/core": "7.16.2",
27 | "@mantine/hooks": "7.16.2",
28 | "@mantine/notifications": "^7.16.2",
29 | "@tabler/icons-react": "^3.29.0",
30 | "next": "^15.1.6",
31 | "react": "^19.0.0",
32 | "react-dom": "^19.0.0",
33 | "swr": "^2.3.0"
34 | },
35 | "devDependencies": {
36 | "@evilmartians/lefthook": "^1.10.10",
37 | "@types/node": "^22.13.0",
38 | "@types/react": "^19.0.8",
39 | "eslint": "^8.57.0",
40 | "eslint-config-next": "^15.1.6",
41 | "eslint-config-prettier": "^10.0.1",
42 | "markdownlint": "^0.37.4",
43 | "markdownlint-cli": "^0.44.0",
44 | "postcss": "^8.5.1",
45 | "postcss-preset-mantine": "1.17.0",
46 | "postcss-simple-vars": "^7.0.1",
47 | "prettier": "^3.4.2",
48 | "rimraf": "^6.0.1",
49 | "stylelint": "^16.14.1",
50 | "stylelint-config-standard": "^37.0.0",
51 | "typescript": "^5.7.3"
52 | },
53 | "packageManager": "yarn@1.22.22"
54 | }
55 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | 'postcss-preset-mantine': {},
4 | 'postcss-simple-vars': {
5 | variables: {
6 | 'mantine-breakpoint-xs': '36em',
7 | 'mantine-breakpoint-sm': '48em',
8 | 'mantine-breakpoint-md': '62em',
9 | 'mantine-breakpoint-lg': '75em',
10 | 'mantine-breakpoint-xl': '88em'
11 | }
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/public/icons/01d.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/01n.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/02d.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/02n.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/03d.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/03n.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/04d.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/04n.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/09d.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/09n.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/10d.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/10n.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/11d.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/11n.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/13d.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/13n.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/50d.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/50n.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": false,
7 | "noEmit": true,
8 | "incremental": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "node",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "baseUrl": ".",
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------