Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af9c23c521 | ||
|
|
715c122eb7 | ||
|
|
09804829bd | ||
|
|
f83a8b0697 | ||
|
|
6c2379976d | ||
|
|
402ae9bf9b | ||
|
|
35b0c2396f | ||
|
|
e7d8d09fbc | ||
|
|
9150a069fe | ||
|
|
b49349eb26 | ||
|
|
be7e8a3c33 | ||
|
|
4b05aa7bf3 | ||
|
|
ef183ab1ce | ||
|
|
d2e57f0af4 | ||
|
|
40a9bb4724 | ||
|
|
b4f819eba9 | ||
|
|
f649e91d57 | ||
|
|
d2c0600359 |
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
.git
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Build
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: gitea.coupxd.com
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
# Backend
|
||||||
|
- name: Build and Push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: gitea.coupxd.com/cody/go-weather:latest
|
||||||
|
|
||||||
|
#Frontend
|
||||||
|
- name: Build and Push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./frontend
|
||||||
|
platforms: linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: gitea.coupxd.com/cody/go-weather-frontend:latest
|
||||||
@@ -1 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
|
.dockerenv
|
||||||
|
node_modules
|
||||||
|
tmp
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
FROM golang:1.25-bookworm AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
# Buildx automatically injects TARGETARCH, TARGETOS, and TARGETPLATFORM as build args into the Dockerfile
|
||||||
|
ARG TARGETARCH
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -o go-weather .
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
COPY --from=builder /app/go-weather /go-weather
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/go-weather"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
env "local" {
|
||||||
|
src = "file://migrations/schema.sql"
|
||||||
|
url = "postgres://postgres:postgres@localhost:5433/go_weather?sslmode=disable"
|
||||||
|
dev = "docker://postgres/16/alpine/dev" # for schema validation
|
||||||
|
}
|
||||||
|
|
||||||
|
env "production" {
|
||||||
|
src = "file://migrations/schema.sql"
|
||||||
|
url = "postgres://${env("DB_USER")}:${env("DB_PASSWORD")}@${env("DB_HOST")}:${env("DB_PORT")}/${env("DB_NAME")}?sslmode=require"
|
||||||
|
dev = "docker://postgres/16/alpine/dev"
|
||||||
|
}
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
docker build \
|
||||||
|
-t gitea.coupxd.com/cody/go-weather:latest \
|
||||||
|
.
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
-t gitea.coupxd.com/cody/go-weather-frontend:latest \
|
||||||
|
./frontend
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Creating build environment"
|
||||||
|
docker buildx create --name pibuilder --use
|
||||||
|
|
||||||
|
echo "Printing build environment info"
|
||||||
|
docker buildx inspect --bootstrap
|
||||||
|
|
||||||
|
echo "Doing build"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--push \
|
||||||
|
-t gitea.coupxd.com/cody/go-weather:latest \
|
||||||
|
.
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"recharts": "^3.7.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@reduxjs/toolkit": ["@reduxjs/[email protected]", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@standard-schema/utils": ["@standard-schema/[email protected]", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||||
|
|
||||||
|
"@types/d3-array": ["@types/[email protected]", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||||
|
|
||||||
|
"@types/d3-color": ["@types/[email protected]", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||||
|
|
||||||
|
"@types/d3-ease": ["@types/[email protected]", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||||
|
|
||||||
|
"@types/d3-interpolate": ["@types/[email protected]", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||||
|
|
||||||
|
"@types/d3-path": ["@types/[email protected]", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||||
|
|
||||||
|
"@types/d3-scale": ["@types/[email protected]", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||||
|
|
||||||
|
"@types/d3-shape": ["@types/[email protected]", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
|
||||||
|
|
||||||
|
"@types/d3-time": ["@types/[email protected]", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||||
|
|
||||||
|
"@types/d3-timer": ["@types/[email protected]", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||||
|
|
||||||
|
"@types/use-sync-external-store": ["@types/[email protected]", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||||
|
|
||||||
|
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"d3-array": ["[email protected]", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||||
|
|
||||||
|
"d3-color": ["[email protected]", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||||
|
|
||||||
|
"d3-ease": ["[email protected]", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||||
|
|
||||||
|
"d3-format": ["[email protected]", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
||||||
|
|
||||||
|
"d3-interpolate": ["[email protected]", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||||
|
|
||||||
|
"d3-path": ["[email protected]", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||||
|
|
||||||
|
"d3-scale": ["[email protected]", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||||
|
|
||||||
|
"d3-shape": ["[email protected]", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||||
|
|
||||||
|
"d3-time": ["[email protected]", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||||
|
|
||||||
|
"d3-time-format": ["[email protected]", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||||
|
|
||||||
|
"d3-timer": ["[email protected]", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||||
|
|
||||||
|
"decimal.js-light": ["[email protected]", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||||
|
|
||||||
|
"es-toolkit": ["[email protected]", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="],
|
||||||
|
|
||||||
|
"eventemitter3": ["[email protected]", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||||
|
|
||||||
|
"immer": ["[email protected]", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||||
|
|
||||||
|
"internmap": ["[email protected]", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||||
|
|
||||||
|
"react": ["[email protected]", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
|
|
||||||
|
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||||
|
|
||||||
|
"react-is": ["[email protected]", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="],
|
||||||
|
|
||||||
|
"react-redux": ["[email protected]", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
|
||||||
|
|
||||||
|
"recharts": ["[email protected]", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew=="],
|
||||||
|
|
||||||
|
"redux": ["[email protected]", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
|
||||||
|
|
||||||
|
"redux-thunk": ["[email protected]", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||||
|
|
||||||
|
"reselect": ["[email protected]", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||||
|
|
||||||
|
"scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"tiny-invariant": ["[email protected]", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||||
|
|
||||||
|
"use-sync-external-store": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||||
|
|
||||||
|
"victory-vendor": ["[email protected]", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||||
|
|
||||||
|
"@reduxjs/toolkit/immer": ["[email protected]", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
APIKey string
|
||||||
|
DefaultCity string
|
||||||
|
DBHost string
|
||||||
|
DBPort string
|
||||||
|
DBUser string
|
||||||
|
DBPassword string
|
||||||
|
DBName string
|
||||||
|
JWTSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetEnv(key string) string {
|
||||||
|
val := os.Getenv(key)
|
||||||
|
if val == "" {
|
||||||
|
log.Fatalf("missing required env var: %s", key)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
return &Config{
|
||||||
|
APIKey: GetEnv("OPENWEATHERMAP_API_KEY"),
|
||||||
|
DefaultCity: GetEnv("WEATHER_CITY"),
|
||||||
|
DBHost: GetEnv("DB_HOST"),
|
||||||
|
DBPort: GetEnv("DB_PORT"),
|
||||||
|
DBUser: GetEnv("DB_USER"),
|
||||||
|
DBPassword: GetEnv("DB_PASSWORD"),
|
||||||
|
DBName: GetEnv("DB_NAME"),
|
||||||
|
JWTSecret: GetEnv("JWT_SECRET"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
go-weather:
|
||||||
|
image: gitea.coupxd.com/cody/go-weather:latest
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: gitea.coupxd.com/cody/go-weather-frontend:latest
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- go-weather
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM oven/bun:latest AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
RUN bun install
|
||||||
|
COPY . .
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Serve stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
+1381
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "gray",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://go-weather:8080/;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.574.0",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
|
"tailwind-merge": "^3.4.1",
|
||||||
|
"tailwindcss": "^4.1.18"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"shadcn": "^3.8.5",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import './App.css'
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider"
|
||||||
|
import LatestTemperature from "@/components/latest-temperature"
|
||||||
|
import TemperatureHistory from "@/components/temperature-history"
|
||||||
|
import TemperaturePlot from "@/components/temperature-plot"
|
||||||
|
import RegisterForm from './components/register-form'
|
||||||
|
// import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||||
|
<main className="min-h-screen bg-background p-6 w-full">
|
||||||
|
<div>
|
||||||
|
<RegisterForm />
|
||||||
|
</div>
|
||||||
|
<div className="w-full mx-auto space-y-2">
|
||||||
|
<TemperaturePlot />
|
||||||
|
<div className="grid grid-cols-4 gap-6">
|
||||||
|
<LatestTemperature />
|
||||||
|
<TemperatureHistory />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
interface LatestTemperature {
|
||||||
|
temp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLatestTemperature(): Promise<LatestTemperature>{
|
||||||
|
const res = await fetch("/api/latest-temperature");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch latest temperature");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function LatestTemperature(){
|
||||||
|
const {data, isLoading, error} = useQuery({
|
||||||
|
queryKey: ["latest-temperature"],
|
||||||
|
queryFn: fetchLatestTemperature,
|
||||||
|
|
||||||
|
});
|
||||||
|
if (isLoading) return <p>Loading...</p>
|
||||||
|
if (error) return <p>Error: {error.message}</p>
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>{data?.temp ?? 'N/A'}</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LatestTemperature
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
export default function RegisterForm() {
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("")
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setSuccess(false)
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("Passwords do not match")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError("Passwords must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = await res.text()
|
||||||
|
setError(msg || "Registration failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess(true)
|
||||||
|
setEmail("")
|
||||||
|
setPassword("")
|
||||||
|
setConfirmPassword("")
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
setError("Network error")
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 m-6 flex flex-col border rounded-lg bg-card">
|
||||||
|
<h2>Register</h2>
|
||||||
|
{error && <p>{error}</p>}
|
||||||
|
{success && <p>Account created</p>}
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="p-2 border rounded bg-background"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="p-2 border rounded bg-background"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="p-2 border rounded bg-background"
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
{loading ? "Creating ..." : "Register"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import WeatherHistory from '@/hooks/weather-history'
|
||||||
|
|
||||||
|
|
||||||
|
function TemperatureHistory(){
|
||||||
|
const {data, isLoading, error} = WeatherHistory()
|
||||||
|
|
||||||
|
if (isLoading) return <p>Loading...</p>
|
||||||
|
if (error) return <p>Error: {error.message}</p>
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{data?.map((record) => (
|
||||||
|
<div key={record.id}>{record.temp ?? 'N/A'}({record.city})</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TemperatureHistory
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ScatterChart, Scatter, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
|
||||||
|
import WeatherHistory from '@/hooks/weather-history'
|
||||||
|
|
||||||
|
function TemperaturePlotChart({data} : {data: WeatherHistory[] | undefined}){
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <div className="w-full h-96">No data available</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<ScatterChart data={data}>
|
||||||
|
<XAxis dataKey="id" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Scatter dataKey="temp" fill="#6366f1" />
|
||||||
|
</ScatterChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TemperaturePlotChart
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import {lazy, Suspense } from "react"
|
||||||
|
import WeatherHistory from '@/hooks/weather-history'
|
||||||
|
|
||||||
|
const Chart = lazy(() => import("./temperature-plot-chart"))
|
||||||
|
|
||||||
|
function TemperaturePlot() {
|
||||||
|
const {data, isLoading, error} = WeatherHistory()
|
||||||
|
|
||||||
|
if (isLoading) return <p>Loading...</p>
|
||||||
|
if (error) return <p>Error: {error.message}</p>
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="h-[400-px]" />}>
|
||||||
|
<Chart data={data} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TemperaturePlot
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
type Theme = "dark" | "light" | "system"
|
||||||
|
|
||||||
|
type ThemeProviderProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
defaultTheme?: Theme
|
||||||
|
storageKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThemeProviderState = {
|
||||||
|
theme: Theme
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ThemeProviderState = {
|
||||||
|
theme: "system",
|
||||||
|
setTheme: () => null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
defaultTheme = "system",
|
||||||
|
storageKey = "vite-ui-theme",
|
||||||
|
...props
|
||||||
|
}: ThemeProviderProps) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(
|
||||||
|
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement
|
||||||
|
|
||||||
|
root.classList.remove("light", "dark")
|
||||||
|
|
||||||
|
if (theme === "system") {
|
||||||
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
.matches
|
||||||
|
? "dark"
|
||||||
|
: "light"
|
||||||
|
|
||||||
|
root.classList.add(systemTheme)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root.classList.add(theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
theme,
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
localStorage.setItem(storageKey, theme)
|
||||||
|
setTheme(theme)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProviderContext.Provider {...props} value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeProviderContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeProviderContext)
|
||||||
|
|
||||||
|
if (context === undefined)
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider")
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
// {"id":1001,"city":"Salem","temp":66.7,"feels_like":67.17,"humidity":87,"conditions":"scattered clouds","fetched_at":"2026-02-18T22:16:38.067097Z"}
|
||||||
|
interface WeatherHistory {
|
||||||
|
id: number;
|
||||||
|
city: string;
|
||||||
|
temp: number;
|
||||||
|
feels_like: number;
|
||||||
|
humidity: number;
|
||||||
|
conditions: string;
|
||||||
|
fetched_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWeatherHistory(): Promise<WeatherHistory[]>{
|
||||||
|
const res = await fetch("/api/weather/history");
|
||||||
|
if(!res.ok) throw new Error("Failed to fetch weather history");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function WeatherHistory() {
|
||||||
|
const {data, isLoading, error} = useQuery({
|
||||||
|
queryKey: ["weather-history"],
|
||||||
|
queryFn: fetchWeatherHistory,
|
||||||
|
|
||||||
|
});
|
||||||
|
return {data, isLoading, error}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WeatherHistory
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.13 0.028 261.692);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.13 0.028 261.692);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.13 0.028 261.692);
|
||||||
|
--primary: oklch(0.21 0.034 264.665);
|
||||||
|
--primary-foreground: oklch(0.985 0.002 247.839);
|
||||||
|
--secondary: oklch(0.967 0.003 264.542);
|
||||||
|
--secondary-foreground: oklch(0.21 0.034 264.665);
|
||||||
|
--muted: oklch(0.967 0.003 264.542);
|
||||||
|
--muted-foreground: oklch(0.551 0.027 264.364);
|
||||||
|
--accent: oklch(0.967 0.003 264.542);
|
||||||
|
--accent-foreground: oklch(0.21 0.034 264.665);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.928 0.006 264.531);
|
||||||
|
--input: oklch(0.928 0.006 264.531);
|
||||||
|
--ring: oklch(0.707 0.022 261.325);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0.002 247.839);
|
||||||
|
--sidebar-foreground: oklch(0.13 0.028 261.692);
|
||||||
|
--sidebar-primary: oklch(0.21 0.034 264.665);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
|
||||||
|
--sidebar-accent: oklch(0.967 0.003 264.542);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.034 264.665);
|
||||||
|
--sidebar-border: oklch(0.928 0.006 264.531);
|
||||||
|
--sidebar-ring: oklch(0.707 0.022 261.325);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.13 0.028 261.692);
|
||||||
|
--foreground: oklch(0.985 0.002 247.839);
|
||||||
|
--card: oklch(0.21 0.034 264.665);
|
||||||
|
--card-foreground: oklch(0.985 0.002 247.839);
|
||||||
|
--popover: oklch(0.21 0.034 264.665);
|
||||||
|
--popover-foreground: oklch(0.985 0.002 247.839);
|
||||||
|
--primary: oklch(0.928 0.006 264.531);
|
||||||
|
--primary-foreground: oklch(0.21 0.034 264.665);
|
||||||
|
--secondary: oklch(0.278 0.033 256.848);
|
||||||
|
--secondary-foreground: oklch(0.985 0.002 247.839);
|
||||||
|
--muted: oklch(0.278 0.033 256.848);
|
||||||
|
--muted-foreground: oklch(0.707 0.022 261.325);
|
||||||
|
--accent: oklch(0.278 0.033 256.848);
|
||||||
|
--accent-foreground: oklch(0.985 0.002 247.839);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.551 0.027 264.364);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.21 0.034 264.665);
|
||||||
|
--sidebar-foreground: oklch(0.985 0.002 247.839);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
|
||||||
|
--sidebar-accent: oklch(0.278 0.033 256.848);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0.002 247.839);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
/* For Shad CN */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import path from "path"
|
||||||
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
|
import react from "@vitejs/plugin-react"
|
||||||
|
import { defineConfig } from "vite"
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
dedupe: ['react', 'react-dom'],
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8080",
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
recharts: ["recharts"],
|
||||||
|
vendor: ["react", "react-dom"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
module gitea.coupxd.com/cody/go-weather
|
module go-weather
|
||||||
|
|
||||||
go 1.25.6
|
go 1.25.6
|
||||||
|
|
||||||
require github.com/lib/pq v1.11.1
|
require github.com/lib/pq v1.11.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
|
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
|
||||||
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"go-weather/models"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.HealthResponse{Status: "ok"})
|
||||||
|
}
|
||||||
|
func LatestTemperature(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.LatestTemperatureResponse{Temp: 69})
|
||||||
|
}
|
||||||
|
func Test(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.TestResponse{Status: "hello from go"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"go-weather/models"
|
||||||
|
"go-weather/services"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserHandler struct {
|
||||||
|
userService *services.UserService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserHandler(us *services.UserService) *UserHandler {
|
||||||
|
return &UserHandler{userService: us}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.RegisterRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||||
|
if req.Email == "" || req.Password == "" {
|
||||||
|
http.Error(w, "email and password are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Password) < 8 {
|
||||||
|
http.Error(w, "password must be at least 8 characters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := h.userService.EmailExists(req.Email)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
http.Error(w, "email already registered", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userService.CreateUser(req.Email, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to create user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(user)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"go-weather/services"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WeatherHandler struct {
|
||||||
|
weatherService *services.WeatherService
|
||||||
|
dbService *services.DatabaseService
|
||||||
|
config struct {
|
||||||
|
DefaultCity string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWeatherHandler(ws *services.WeatherService, ds *services.DatabaseService, defaultCity string) *WeatherHandler {
|
||||||
|
return &WeatherHandler{
|
||||||
|
weatherService: ws,
|
||||||
|
dbService: ds,
|
||||||
|
config: struct{ DefaultCity string }{DefaultCity: defaultCity},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WeatherHandler) FetchWeather(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
city := r.URL.Query().Get("city")
|
||||||
|
if city == "" {
|
||||||
|
city = h.config.DefaultCity
|
||||||
|
}
|
||||||
|
weather, err := h.weatherService.FetchWeather(city)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.dbService.SaveReading(weather)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"message": "weather fetched and saved",
|
||||||
|
"id": id,
|
||||||
|
"city": weather.Name,
|
||||||
|
"temp": weather.Main.Temp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WeatherHandler) GetHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
city := r.URL.Query().Get("city")
|
||||||
|
if city == "" {
|
||||||
|
city = h.config.DefaultCity
|
||||||
|
}
|
||||||
|
readings, err := h.dbService.GetReadings(city, 25)
|
||||||
|
fmt.Printf("Length of readings: %v", len(readings))
|
||||||
|
fmt.Printf("HELLO")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(readings)
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-weather/config"
|
||||||
|
"go-weather/handlers"
|
||||||
|
"go-weather/services"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,269 +15,80 @@ import (
|
|||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WeatherResponse struct {
|
func startPoller(db *sql.DB, ws *services.WeatherService, defaultCity string, interval time.Duration) {
|
||||||
Name string `json:"name"`
|
// Use the service
|
||||||
Main struct {
|
weather, err := ws.FetchWeather(defaultCity)
|
||||||
Temp float64 `json:"temp"`
|
|
||||||
FeelsLike float64 `json:"feels_like"`
|
|
||||||
Humidity int `json:"humidity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
Weather []struct {
|
|
||||||
Description string `json:"description"`
|
|
||||||
} `json:"weather"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Reading struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
City string `json:"city"`
|
|
||||||
Temp float64 `json:"temp"`
|
|
||||||
FeelsLike float64 `json:"feels_like"`
|
|
||||||
Humidity int `json:"humidity"`
|
|
||||||
Conditions string `json:"conditions"`
|
|
||||||
FetchedAt time.Time `json:"fetched_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnv(key string) string {
|
|
||||||
val := os.Getenv(key)
|
|
||||||
if val == "" {
|
|
||||||
log.Fatalf("missing required env var: %s", key)
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
func connectDB() *sql.DB {
|
|
||||||
connStr := fmt.Sprintf(
|
|
||||||
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
|
||||||
getEnv("DB_HOST"),
|
|
||||||
getEnv("DB_PORT"),
|
|
||||||
getEnv("DB_USER"),
|
|
||||||
getEnv("DB_PASSWORD"),
|
|
||||||
getEnv("DB_NAME"),
|
|
||||||
)
|
|
||||||
|
|
||||||
db, err := sql.Open("postgres", connStr)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to connect to db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.Ping()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to connect to db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("connected to PostgreSQL")
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTable(db *sql.DB) {
|
|
||||||
_, err := db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS weather_readings (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
city TEXT NOT NULL,
|
|
||||||
temp DOUBLE PRECISION NOT NULL,
|
|
||||||
feels_like DOUBLE PRECISION NOT NULL,
|
|
||||||
humidity INTEGER NOT NULL,
|
|
||||||
conditions TEXT NOT NULL,
|
|
||||||
fetched_at TIMESTAMP WITH TIME ZONE NOT NULL
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to create table: %v", err)
|
|
||||||
}
|
|
||||||
fmt.Println("weather_readings table ready")
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveReading(db *sql.DB, weather WeatherResponse) (int, error) {
|
|
||||||
conditions := ""
|
|
||||||
if len(weather.Weather) > 0 {
|
|
||||||
conditions = weather.Weather[0].Description
|
|
||||||
}
|
|
||||||
|
|
||||||
var id int
|
|
||||||
err := db.QueryRow(
|
|
||||||
`INSERT INTO weather_readings (city, temp, feels_like, humidity, conditions, fetched_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING id`,
|
|
||||||
weather.Name,
|
|
||||||
weather.Main.Temp,
|
|
||||||
weather.Main.FeelsLike,
|
|
||||||
weather.Main.Humidity,
|
|
||||||
conditions,
|
|
||||||
time.Now(),
|
|
||||||
).Scan(&id)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to save reading: %w", err)
|
|
||||||
}
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getReadings(db *sql.DB, city string, limit int) ([]Reading, error) {
|
|
||||||
rows, err := db.Query(
|
|
||||||
`SELECT id, city, temp, feels_like, humidity, conditions, fetched_at
|
|
||||||
FROM weather_readings
|
|
||||||
WHERE city ILIKE $1
|
|
||||||
ORDER BY fetched_at DESC
|
|
||||||
LIMIT $2`,
|
|
||||||
city, limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Query failed: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var readings []Reading
|
|
||||||
for rows.Next() {
|
|
||||||
var r Reading
|
|
||||||
err := rows.Scan(&r.ID, &r.City, &r.Temp, &r.FeelsLike, &r.Humidity, &r.Conditions, &r.FetchedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scan failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
readings = append(readings, r)
|
|
||||||
}
|
|
||||||
return readings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchWeather(apiKey, city string) (WeatherResponse, error) {
|
|
||||||
url := fmt.Sprintf(
|
|
||||||
"https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=imperial",
|
|
||||||
city, apiKey,
|
|
||||||
)
|
|
||||||
resp, err := http.Get(url)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return WeatherResponse{}, fmt.Errorf("request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return WeatherResponse{}, fmt.Errorf("API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var weather WeatherResponse
|
|
||||||
err = json.NewDecoder(resp.Body).Decode(&weather)
|
|
||||||
if err != nil {
|
|
||||||
return WeatherResponse{}, fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
return weather, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func startPoller(db *sql.DB, apiKey, city string, interval time.Duration) {
|
|
||||||
weather, err := fetchWeather(apiKey, city)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("poller: fetch failed: %v", err)
|
log.Printf("poller: fetch failed: %v", err)
|
||||||
} else {
|
} else {
|
||||||
id, err := saveReading(db, weather)
|
ds := services.NewDatabaseService(db)
|
||||||
|
id, err := ds.SaveReading(weather)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("poller: save failed %v", err)
|
log.Printf("poller: save failed %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("poller: saved reading #%d for %s (%.1f F)", id, weather.Name, weather.Main.Temp)
|
log.Printf("poller: saved reading #%d for %s (%.1f F)", id, weather.Name, weather.Main.Temp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
weather, err := fetchWeather(apiKey, city)
|
weather, err := ws.FetchWeather(defaultCity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("poller fetch failed: %v", err)
|
log.Printf("poller fetch failed: %v", err)
|
||||||
continue // don't crash
|
continue
|
||||||
}
|
}
|
||||||
|
ds := services.NewDatabaseService(db)
|
||||||
id, err := saveReading(db, weather)
|
id, err := ds.SaveReading(weather)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("poller: save failed: %v", err)
|
log.Printf("poller: save failed: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Printf("poller: saved reading #%d for %s (%.1f F)", id, weather.Name, weather.Main.Temp)
|
log.Printf("poller: saved reading #%d for %s (%.1f F)", id, weather.Name, weather.Main.Temp)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
apiKey := getEnv("OPENWEATHERMAP_API_KEY")
|
config := config.Load()
|
||||||
defaultCity := getEnv("WEATHER_CITY")
|
// Connect to DB
|
||||||
|
connStr := fmt.Sprintf(
|
||||||
|
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
config.DBHost, config.DBPort, config.DBUser, config.DBPassword, config.DBName,
|
||||||
|
)
|
||||||
|
|
||||||
db := connectDB()
|
database, err := services.ConnectDB(connStr)
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
createTable(db)
|
|
||||||
|
|
||||||
// Start background polling in a goroutine
|
|
||||||
go startPoller(db, apiKey, defaultCity, 10*time.Minute)
|
|
||||||
|
|
||||||
// POST /weather/fetch?city=London - fetch current weather and save it
|
|
||||||
http.HandleFunc("/weather/fetch", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
city := r.URL.Query().Get("city")
|
|
||||||
if city == "" {
|
|
||||||
city = defaultCity
|
|
||||||
}
|
|
||||||
|
|
||||||
weather, err := fetchWeather(apiKey, city)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
log.Fatalf("failed to connect to db: %v", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
// note closure
|
// Create services
|
||||||
id, err := saveReading(db, weather)
|
databaseService := services.NewDatabaseService(database)
|
||||||
if err != nil {
|
weatherService := services.NewWeatherService(config.APIKey)
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
userService := services.NewUserService(database)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
// Create route handlers
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
weatherHandler := handlers.NewWeatherHandler(weatherService, databaseService, config.DefaultCity)
|
||||||
"message": "weather fetched and saved",
|
userHandler := handlers.NewUserHandler(userService)
|
||||||
"id": id,
|
|
||||||
"city": weather.Name,
|
|
||||||
"temp": weather.Main.Temp,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /weather/history?city=London&limit=10
|
// Start background poller
|
||||||
http.HandleFunc("/weather/history", func(w http.ResponseWriter, r *http.Request) {
|
go startPoller(database, weatherService, config.DefaultCity, 10*time.Minute)
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
city := r.URL.Query().Get("city")
|
|
||||||
if city == "" {
|
|
||||||
city = defaultCity
|
|
||||||
}
|
|
||||||
|
|
||||||
// note closure
|
|
||||||
readings, err := getReadings(db, city, 25)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(readings)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
http.HandleFunc("/weather/fetch", weatherHandler.FetchWeather)
|
||||||
|
http.HandleFunc("/weather/history", weatherHandler.GetHistory)
|
||||||
|
http.HandleFunc("/health", handlers.Health)
|
||||||
|
http.HandleFunc("/latest-temperature", handlers.LatestTemperature)
|
||||||
|
http.HandleFunc("/test", handlers.Test)
|
||||||
|
http.HandleFunc("/register", userHandler.Register)
|
||||||
|
// Start server
|
||||||
go func() {
|
go func() {
|
||||||
fmt.Println("server listening on :8080")
|
fmt.Println("server listening on :8080")
|
||||||
if err := http.ListenAndServe(":8080", nil); err != nil {
|
if err := http.ListenAndServe(":8080", nil); err != nil {
|
||||||
log.Fatalf("server failed: %v", err)
|
log.Fatalf("server failed: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
// Wait for shutdown
|
||||||
// wait for Ctrl+C
|
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
signal.Notify(quit, os.Interrupt)
|
signal.Notify(quit, os.Interrupt)
|
||||||
<-quit
|
<-quit
|
||||||
fmt.Println("\nshutting down...")
|
fmt.Println("\nshutting down...")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS weather_readings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
city TEXT NOT NULL,
|
||||||
|
temp DOUBLE PRECISION NOT NULL,
|
||||||
|
feels_like DOUBLE PRECISION NOT NULL,
|
||||||
|
humidity INTEGER NOT NULL,
|
||||||
|
conditions TEXT NOT NULL,
|
||||||
|
fetched_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponse struct {
|
||||||
|
User User `json:"user"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WeatherResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Main struct {
|
||||||
|
Temp float64 `json:"temp"`
|
||||||
|
FeelsLike float64 `json:"feels_like"`
|
||||||
|
Humidity int `json:"humidity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
Weather []struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"weather"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Reading struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Temp float64 `json:"temp"`
|
||||||
|
FeelsLike float64 `json:"feels_like"`
|
||||||
|
Humidity int `json:"humidity"`
|
||||||
|
Conditions string `json:"conditions"`
|
||||||
|
FetchedAt time.Time `json:"fetched_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LatestTemperatureResponse struct {
|
||||||
|
Temp int `json:"temp"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"recharts": "^3.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: postgres-dev
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: go_weather
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
cloudbeaver:
|
||||||
|
image: dbeaver/cloudbeaver:latest
|
||||||
|
container_name: cloudbeaver-dev
|
||||||
|
ports:
|
||||||
|
- "8978:8978"
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthService struct {
|
||||||
|
secretKey []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthService(secret string) *AuthService {
|
||||||
|
return &AuthService{secretKey: []byte(secret)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) GenerateToken(userID int, email string) (string, error) {
|
||||||
|
claims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||||
|
return token.SignedString(s.secretKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("unexpected signing method")
|
||||||
|
}
|
||||||
|
return s.secretKey, nil
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
"go-weather/models"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ConnectDB(connStr string) (*sql.DB, error) {
|
||||||
|
db, err := sql.Open("postgres", connStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Ping()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("connected to PostgreSQL")
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseService struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDatabaseService(db *sql.DB) *DatabaseService {
|
||||||
|
return &DatabaseService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DatabaseService) SaveReading(weather models.WeatherResponse) (int, error) {
|
||||||
|
conditions := ""
|
||||||
|
if len(weather.Weather) > 0 {
|
||||||
|
conditions = weather.Weather[0].Description
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`INSERT INTO weather_readings (city, temp, feels_like, humidity, conditions, fetched_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id`,
|
||||||
|
weather.Name,
|
||||||
|
weather.Main.Temp,
|
||||||
|
weather.Main.FeelsLike,
|
||||||
|
weather.Main.Humidity,
|
||||||
|
conditions,
|
||||||
|
time.Now(),
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to save reading: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DatabaseService) GetReadings(city string, limit int) ([]models.Reading, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, city, temp, feels_like, humidity, conditions, fetched_at
|
||||||
|
FROM weather_readings
|
||||||
|
WHERE city ILIKE $1
|
||||||
|
ORDER BY fetched_at DESC
|
||||||
|
LIMIT $2`,
|
||||||
|
city, limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Query failed: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var readings []models.Reading
|
||||||
|
for rows.Next() {
|
||||||
|
var r models.Reading
|
||||||
|
err := rows.Scan(&r.ID, &r.City, &r.Temp, &r.FeelsLike, &r.Humidity, &r.Conditions, &r.FetchedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
readings = append(readings, r)
|
||||||
|
}
|
||||||
|
return readings, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"go-weather/models"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserService struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserService(db *sql.DB) *UserService {
|
||||||
|
return &UserService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) CreateUser(email string, password string) (*models.User, error) {
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
err = s.db.QueryRow(
|
||||||
|
`INSERT INTO users (email, password_hash, created_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, email, created_at`,
|
||||||
|
email, string(hashedPassword), time.Now(),
|
||||||
|
).Scan(&user.ID, &user.Email, &user.CreatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) GetUserByEmail(email string) (*models.User, error) {
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT id, email, password_hash, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE email = $1`, email,
|
||||||
|
).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) EmailExists(email string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
func (s *UserService) ValidatePassword(user *models.User, password string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"go-weather/models"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WeatherService struct {
|
||||||
|
apiKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWeatherService(apiKey string) *WeatherService {
|
||||||
|
return &WeatherService{apiKey: apiKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeatherService) FetchWeather(city string) (models.WeatherResponse, error) {
|
||||||
|
url := fmt.Sprintf(
|
||||||
|
"https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=imperial",
|
||||||
|
city, s.apiKey,
|
||||||
|
)
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return models.WeatherResponse{}, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return models.WeatherResponse{}, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var weather models.WeatherResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&weather)
|
||||||
|
if err != nil {
|
||||||
|
return models.WeatherResponse{}, fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
return weather, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user