Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aadf4c29e |
@@ -1,2 +0,0 @@
|
|||||||
.env
|
|
||||||
.git
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
export OPENWEATHERMAP_API_KEY=b553614cb0047997aa62ea65b5f75fbf
|
|
||||||
export WEATHER_CITY=Salem
|
|
||||||
|
|
||||||
export DB_HOST=localhost
|
|
||||||
export DB_PORT=5433
|
|
||||||
export DB_NAME=go_weather
|
|
||||||
export DB_USER=postgres
|
|
||||||
export DB_PASSWORD=postgres
|
|
||||||
|
|
||||||
export JWT_SECRET=helloiamajwtsecretmyguy
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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,4 +1 @@
|
|||||||
.env
|
.env
|
||||||
.dockerenv
|
|
||||||
node_modules
|
|
||||||
tmp
|
|
||||||
|
|||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
docker build \
|
|
||||||
-t gitea.coupxd.com/cody/go-weather:latest \
|
|
||||||
.
|
|
||||||
|
|
||||||
docker build \
|
|
||||||
-t gitea.coupxd.com/cody/go-weather-frontend:latest \
|
|
||||||
./frontend
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/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 \
|
|
||||||
.
|
|
||||||
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
{
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
|
||||||
"": {
|
|
||||||
"dependencies": {
|
|
||||||
"recharts": "^3.7.0",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"eslint": "^10.0.2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages": {
|
|
||||||
"@eslint-community/eslint-utils": ["@eslint-community/[email protected]", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
|
||||||
|
|
||||||
"@eslint-community/regexpp": ["@eslint-community/[email protected]", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
|
||||||
|
|
||||||
"@eslint/config-array": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/object-schema": "^3.0.2", "debug": "^4.3.1", "minimatch": "^10.2.1" } }, "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A=="],
|
|
||||||
|
|
||||||
"@eslint/config-helpers": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/core": "^1.1.0" } }, "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ=="],
|
|
||||||
|
|
||||||
"@eslint/core": ["@eslint/[email protected]", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw=="],
|
|
||||||
|
|
||||||
"@eslint/object-schema": ["@eslint/[email protected]", "", {}, "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw=="],
|
|
||||||
|
|
||||||
"@eslint/plugin-kit": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/core": "^1.1.0", "levn": "^0.4.1" } }, "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ=="],
|
|
||||||
|
|
||||||
"@humanfs/core": ["@humanfs/[email protected]", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
|
||||||
|
|
||||||
"@humanfs/node": ["@humanfs/[email protected]", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
|
||||||
|
|
||||||
"@humanwhocodes/module-importer": ["@humanwhocodes/[email protected]", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
|
||||||
|
|
||||||
"@humanwhocodes/retry": ["@humanwhocodes/[email protected]", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
|
||||||
|
|
||||||
"@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/esrecurse": ["@types/[email protected]", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
|
||||||
|
|
||||||
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
|
||||||
|
|
||||||
"@types/json-schema": ["@types/[email protected]", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
|
||||||
|
|
||||||
"@types/use-sync-external-store": ["@types/[email protected]", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
|
||||||
|
|
||||||
"acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
|
||||||
|
|
||||||
"acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
|
||||||
|
|
||||||
"ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
|
||||||
|
|
||||||
"balanced-match": ["[email protected]", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
|
||||||
|
|
||||||
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="],
|
|
||||||
|
|
||||||
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
|
||||||
|
|
||||||
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
|
||||||
|
|
||||||
"decimal.js-light": ["[email protected]", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
|
||||||
|
|
||||||
"deep-is": ["[email protected]", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
|
||||||
|
|
||||||
"es-toolkit": ["[email protected]", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="],
|
|
||||||
|
|
||||||
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
|
||||||
|
|
||||||
"eslint": ["[email protected]", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.2", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.0", "@eslint/plugin-kit": "^0.6.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.1", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw=="],
|
|
||||||
|
|
||||||
"eslint-scope": ["[email protected]", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw=="],
|
|
||||||
|
|
||||||
"eslint-visitor-keys": ["[email protected]", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
|
||||||
|
|
||||||
"espree": ["[email protected]", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ=="],
|
|
||||||
|
|
||||||
"esquery": ["[email protected]", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
|
||||||
|
|
||||||
"esrecurse": ["[email protected]", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
|
||||||
|
|
||||||
"estraverse": ["[email protected]", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
|
||||||
|
|
||||||
"esutils": ["[email protected]", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
|
||||||
|
|
||||||
"eventemitter3": ["[email protected]", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
|
||||||
|
|
||||||
"fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
|
||||||
|
|
||||||
"fast-json-stable-stringify": ["[email protected]", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
|
||||||
|
|
||||||
"fast-levenshtein": ["[email protected]", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
|
||||||
|
|
||||||
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
|
||||||
|
|
||||||
"find-up": ["[email protected]", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
|
||||||
|
|
||||||
"flat-cache": ["[email protected]", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
|
||||||
|
|
||||||
"flatted": ["[email protected]", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
|
||||||
|
|
||||||
"glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
|
||||||
|
|
||||||
"ignore": ["[email protected]", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
|
||||||
|
|
||||||
"immer": ["[email protected]", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
|
||||||
|
|
||||||
"imurmurhash": ["[email protected]", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
|
||||||
|
|
||||||
"internmap": ["[email protected]", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
|
||||||
|
|
||||||
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
|
||||||
|
|
||||||
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
|
||||||
|
|
||||||
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
|
||||||
|
|
||||||
"json-buffer": ["[email protected]", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
|
||||||
|
|
||||||
"json-schema-traverse": ["[email protected]", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
|
||||||
|
|
||||||
"json-stable-stringify-without-jsonify": ["[email protected]", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
|
||||||
|
|
||||||
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
|
||||||
|
|
||||||
"levn": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
|
||||||
|
|
||||||
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
|
||||||
|
|
||||||
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="],
|
|
||||||
|
|
||||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
|
||||||
|
|
||||||
"natural-compare": ["[email protected]", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
|
||||||
|
|
||||||
"optionator": ["[email protected]", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
|
||||||
|
|
||||||
"p-limit": ["[email protected]", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
|
||||||
|
|
||||||
"p-locate": ["[email protected]", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
|
||||||
|
|
||||||
"path-exists": ["[email protected]", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
|
||||||
|
|
||||||
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
|
||||||
|
|
||||||
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
|
||||||
|
|
||||||
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
|
||||||
|
|
||||||
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
|
||||||
|
|
||||||
"tiny-invariant": ["[email protected]", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
|
||||||
|
|
||||||
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
|
||||||
|
|
||||||
"uri-js": ["[email protected]", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
|
||||||
|
|
||||||
"word-wrap": ["[email protected]", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
|
||||||
|
|
||||||
"yocto-queue": ["[email protected]", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
|
||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
|
||||||
|
|
||||||
"@reduxjs/toolkit/immer": ["[email protected]", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# 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?
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# 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
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"$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": {}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'react-refresh/only-export-components': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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/;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"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.14",
|
|
||||||
"@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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#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;
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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 { AuthProvider } from './contexts/auth-context'
|
|
||||||
import { useAuth } from './hooks/use-auth'
|
|
||||||
import UserMenu from './components/user-menu'
|
|
||||||
import LoginForm from './components/login-form'
|
|
||||||
|
|
||||||
|
|
||||||
function AppContent() {
|
|
||||||
const { isAuthenticated, isLoading } = useAuth()
|
|
||||||
|
|
||||||
if (isLoading) return <div className="p-6">Loading...</div>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-background p-6 w-full">
|
|
||||||
{isAuthenticated ? <UserMenu /> :
|
|
||||||
<div className="flex">
|
|
||||||
<LoginForm />
|
|
||||||
<RegisterForm />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div className="w-full mx-auto space-y-2">
|
|
||||||
<TemperaturePlot />
|
|
||||||
<div className="grid grid-cols-4 gap-6">
|
|
||||||
<LatestTemperature />
|
|
||||||
{isAuthenticated && <TemperatureHistory />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
|
||||||
<AuthProvider>
|
|
||||||
<AppContent />
|
|
||||||
</AuthProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
|
|
||||||
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
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { useAuth } from "@/hooks/use-auth"
|
|
||||||
import { Button } from "./ui/button"
|
|
||||||
|
|
||||||
export default function LoginForm() {
|
|
||||||
const { login, isLoading } = useAuth()
|
|
||||||
const [email, setEmail] = useState("")
|
|
||||||
const [password, setPassword] = useState("")
|
|
||||||
const [formError, setFormError] = useState<string | null>("")
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.SubmitEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setFormError(null)
|
|
||||||
try {
|
|
||||||
await login(email, password)
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
setFormError(err instanceof Error ? err.message : "Login failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="p-4 m-6 flex flex-col gap-3 border rounded-lg bg-card w-80">
|
|
||||||
|
|
||||||
<h2 className="text-lg font-sembibold">Login</h2>
|
|
||||||
{formError && <p className="text-red-500 text-sm">{formError}</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"
|
|
||||||
/>
|
|
||||||
<Button type="submit" className="w-full">
|
|
||||||
{isLoading ? "Logging in..." : "Login"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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.SubmitEvent) {
|
|
||||||
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 gap-3 border rounded-lg bg-card w-80">
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { useAuth } from "@/hooks/use-auth";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
export default function UserMenu() {
|
|
||||||
const { user, logout } = useAuth()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 m-6 flex items-center gap-4 border rounded-lg bg-card">
|
|
||||||
<span className="text-sm">{user?.email}</span>
|
|
||||||
<Button onClick={logout} varient="outline" size="sm">
|
|
||||||
Logout
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import {
|
|
||||||
createContext,
|
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
} from "react"
|
|
||||||
|
|
||||||
import type { ReactNode } from "react"
|
|
||||||
|
|
||||||
type User = {
|
|
||||||
id: number
|
|
||||||
email: string
|
|
||||||
create_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthContextType = {
|
|
||||||
user: User | null
|
|
||||||
isAuthenticated: boolean
|
|
||||||
login: (email: string, passwor: string) => Promise<void>
|
|
||||||
logout: () => void
|
|
||||||
isLoading: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextType | null>(null)
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [user, setUser] = useState<User | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkAuth()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
async function checkAuth() {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/me", {
|
|
||||||
credentials: "include",
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
// api response will be null if user is not authenticated
|
|
||||||
setUser(data.user)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
setUser(null)
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login(email: string, password: string) {
|
|
||||||
const res = await fetch("/api/login", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email, password })
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const msg = await res.text()
|
|
||||||
throw new Error(msg || "Invalid credentials")
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
setUser(data.user)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await fetch("api/logout", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
})
|
|
||||||
setUser(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={{ user, isAuthenticated: !!user, login, logout, isLoading }}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { AuthContext } from "@/contexts/auth-context"
|
|
||||||
import { useContext } from "react"
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
const ctx = useContext(AuthContext)
|
|
||||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider")
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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>,
|
|
||||||
)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": "./tsconfig.app.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "./tsconfig.node.json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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,9 +1,5 @@
|
|||||||
module go-weather
|
module gitea.coupxd.com/cody/go-weather
|
||||||
|
|
||||||
go 1.25.6
|
go 1.25.6
|
||||||
|
|
||||||
require (
|
require github.com/lib/pq v1.11.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
|
||||||
github.com/lib/pq v1.11.1
|
|
||||||
golang.org/x/crypto v0.48.0
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,6 +1,2 @@
|
|||||||
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=
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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"})
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"go-weather/middleware"
|
|
||||||
"go-weather/models"
|
|
||||||
"go-weather/services"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserHandler struct {
|
|
||||||
userService *services.UserService
|
|
||||||
authService *services.AuthService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUserHandler(us *services.UserService, as *services.AuthService) *UserHandler {
|
|
||||||
return &UserHandler{userService: us, authService: as}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *UserHandler) Me(w http.ResponseWriter, r *http.Request) {
|
|
||||||
claims := middleware.GetClaims(r)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
if claims == nil {
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"user": nil})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := h.userService.GetUserByID(claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"user": nil})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"user": user})
|
|
||||||
}
|
|
||||||
|
|
||||||
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) < 4 {
|
|
||||||
http.Error(w, "password must be at least 4 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *UserHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.LoginRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
|
||||||
user, err := h.userService.GetUserByEmail(req.Email)
|
|
||||||
|
|
||||||
if err != nil || user == nil {
|
|
||||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !h.userService.ValidatePassword(user, req.Password) {
|
|
||||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := h.authService.GenerateToken(user.ID, user.Email)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to generate token", "user_id", user.ID, "error", err)
|
|
||||||
http.Error(w, "failed to generate token", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "token",
|
|
||||||
Value: token,
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: true, // HTTPS only
|
|
||||||
SameSite: http.SameSiteStrictMode,
|
|
||||||
MaxAge: 86400, // 24 hours
|
|
||||||
})
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(models.LoginResponse{User: *user})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *UserHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "token",
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: true,
|
|
||||||
MaxAge: -1,
|
|
||||||
})
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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,13 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go-weather/config"
|
|
||||||
"go-weather/handlers"
|
|
||||||
"go-weather/middleware"
|
|
||||||
"go-weather/services"
|
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -17,94 +13,269 @@ import (
|
|||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
func startPoller(db *sql.DB, ws *services.WeatherService, defaultCity string, interval time.Duration) {
|
type WeatherResponse struct {
|
||||||
// Use the service
|
Name string `json:"name"`
|
||||||
weather, err := ws.FetchWeather(defaultCity)
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
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)
|
||||||
} 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 := ws.FetchWeather(defaultCity)
|
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)
|
||||||
continue
|
continue // don't crash
|
||||||
}
|
}
|
||||||
ds := services.NewDatabaseService(db)
|
|
||||||
id, err := ds.SaveReading(weather)
|
id, err := saveReading(db, 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() {
|
||||||
config := config.Load()
|
apiKey := getEnv("OPENWEATHERMAP_API_KEY")
|
||||||
// Setup logger
|
defaultCity := getEnv("WEATHER_CITY")
|
||||||
var handler slog.Handler
|
|
||||||
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
|
|
||||||
slog.SetDefault(slog.New(handler))
|
|
||||||
|
|
||||||
// Connect to DB
|
db := connectDB()
|
||||||
connStr := fmt.Sprintf(
|
defer db.Close()
|
||||||
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
|
||||||
config.DBHost, config.DBPort, config.DBUser, config.DBPassword, config.DBName,
|
|
||||||
)
|
|
||||||
|
|
||||||
database, err := services.ConnectDB(connStr)
|
createTable(db)
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to connect to db: %v", err)
|
|
||||||
}
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
// Create services
|
// Start background polling in a goroutine
|
||||||
databaseService := services.NewDatabaseService(database)
|
go startPoller(db, apiKey, defaultCity, 10*time.Minute)
|
||||||
weatherService := services.NewWeatherService(config.APIKey)
|
|
||||||
userService := services.NewUserService(database)
|
|
||||||
authService := services.NewAuthService(config.JWTSecret)
|
|
||||||
|
|
||||||
// Create route handlers
|
// POST /weather/fetch?city=London - fetch current weather and save it
|
||||||
weatherHandler := handlers.NewWeatherHandler(weatherService, databaseService, config.DefaultCity)
|
http.HandleFunc("/weather/fetch", func(w http.ResponseWriter, r *http.Request) {
|
||||||
userHandler := handlers.NewUserHandler(userService, authService)
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Start background poller
|
city := r.URL.Query().Get("city")
|
||||||
go startPoller(database, weatherService, config.DefaultCity, 10*time.Minute)
|
if city == "" {
|
||||||
|
city = defaultCity
|
||||||
|
}
|
||||||
|
|
||||||
// Register routes
|
weather, err := fetchWeather(apiKey, city)
|
||||||
http.HandleFunc("/login", userHandler.Login)
|
if err != nil {
|
||||||
http.HandleFunc("/logout", userHandler.Logout)
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
http.Handle("/weather/fetch", middleware.AuthMiddleware(authService)(
|
// note closure
|
||||||
http.HandlerFunc(weatherHandler.FetchWeather)),
|
id, err := saveReading(db, weather)
|
||||||
)
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
http.Handle("/me", http.HandlerFunc(userHandler.Me))
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"message": "weather fetched and saved",
|
||||||
|
"id": id,
|
||||||
|
"city": weather.Name,
|
||||||
|
"temp": weather.Main.Temp,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /weather/history?city=London&limit=10
|
||||||
|
http.HandleFunc("/weather/history", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
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...")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"go-weather/services"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
type contextKey string
|
|
||||||
|
|
||||||
const ClaimsKey contextKey = "claims"
|
|
||||||
|
|
||||||
func AuthMiddleware(authService *services.AuthService) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cookie, err := r.Cookie("token")
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, err := authService.ValidateToken(cookie.Value)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), ClaimsKey, claims)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetClaims(r *http.Request) *services.Claims {
|
|
||||||
if claims, ok := r.Context().Value(ClaimsKey).(*services.Claims); ok {
|
|
||||||
return claims
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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()
|
|
||||||
);
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"recharts": "^3.7.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"eslint": "^10.0.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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:
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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.SigningMethodHS256, 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")
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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) GetUserByID(id int) (*models.User, error) {
|
|
||||||
var user models.User
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
`SELECT id, email, created_at FROM users WHERE id = $1`, id,
|
|
||||||
).Scan(&user.ID, &user.Email, &user.CreatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &user, nil
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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