fix ui and add pwa
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 5s
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 5s
This commit is contained in:
42
CI_README.md
Normal file
42
CI_README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Gitea Actions CI/CD (build -> push -> deploy)
|
||||||
|
|
||||||
|
This project includes a Gitea Actions workflow at `.gitea/workflows/deploy.yml` which:
|
||||||
|
|
||||||
|
- Builds a Docker image and tags it `latest` as `${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${REGISTRY_REPO}:latest`.
|
||||||
|
- Pushes the image to your container registry (supports `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` if needed).
|
||||||
|
- SSHes to the deployment server and writes `docker-compose.yml` into `/home/services/myapp`, then runs `docker-compose up -d`.
|
||||||
|
|
||||||
|
Required repository secrets (add in Gitea repo settings -> Secrets):
|
||||||
|
|
||||||
|
- DEPLOY_HOST: IP or hostname of the server
|
||||||
|
- DEPLOY_USER: SSH user
|
||||||
|
- DEPLOY_KEY: Private SSH key for DEPLOY_USER (no passphrase or use agent)
|
||||||
|
- REGISTRY_HOST: Registry host (e.g. docker.io or registry.example.com)
|
||||||
|
- REGISTRY_NAMESPACE: Namespace/org or username
|
||||||
|
- REGISTRY_REPO: Image/repo name
|
||||||
|
- (optional) REGISTRY_USERNAME and REGISTRY_PASSWORD for private registries
|
||||||
|
|
||||||
|
How to trigger:
|
||||||
|
- The workflow triggers on push to `main` and can be triggered manually via `workflow_dispatch`.
|
||||||
|
|
||||||
|
Manual deploy (example):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Build and push locally
|
||||||
|
$env:REGISTRY_HOST='registry.example.com'
|
||||||
|
$env:REGISTRY_NAMESPACE='myuser'
|
||||||
|
$env:REGISTRY_REPO='greenhomeui'
|
||||||
|
docker build -t $env:REGISTRY_HOST/$env:REGISTRY_NAMESPACE/$env:REGISTRY_REPO:latest .
|
||||||
|
docker push $env:REGISTRY_HOST/$env:REGISTRY_NAMESPACE/$env:REGISTRY_REPO:latest
|
||||||
|
|
||||||
|
# Copy docker-compose and run on server
|
||||||
|
scp docker-compose.yml user@yourserver:/home/services/myapp/docker-compose.yml
|
||||||
|
ssh user@yourserver "cd /home/services/myapp; docker pull $env:REGISTRY_HOST/$env:REGISTRY_NAMESPACE/$env:REGISTRY_REPO:latest; docker-compose up -d --remove-orphans"
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual server helper:
|
||||||
|
- `scripts/remote-deploy.sh` can be copied to the server and used to pull+run the image. It respects env vars `REGISTRY_HOST`, `REGISTRY_NAMESPACE`, `REGISTRY_REPO` when present.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The workflow uses `appleboy/ssh-action` to SSH into the server. That action needs the private key provided in `DEPLOY_KEY`.
|
||||||
|
- The workflow writes a `docker-compose.yml` based on the repo's compose config and uses the `latest` tag. If you prefer not to overwrite server-side compose files, modify the workflow to only run `docker pull` and `docker-compose up -d`.
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
import type { NextConfig } from 'next'
|
import type { NextConfig } from 'next'
|
||||||
import withPWA from 'next-pwa'
|
|
||||||
|
|
||||||
const isProd = process.env.NODE_ENV === 'production'
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
@@ -9,9 +6,4 @@ const nextConfig: NextConfig = {
|
|||||||
turbopack: { root: __dirname }
|
turbopack: { root: __dirname }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withPWA({
|
export default nextConfig
|
||||||
dest: 'public',
|
|
||||||
disable: !isProd,
|
|
||||||
register: true,
|
|
||||||
skipWaiting: true
|
|
||||||
})(nextConfig)
|
|
||||||
|
|||||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"jalaali-js": "^1.2.8",
|
"jalaali-js": "^1.2.8",
|
||||||
|
"lucide-react": "^0.553.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"next-pwa": "^5.6.0",
|
"next-pwa": "^5.6.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -7115,6 +7116,14 @@
|
|||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.553.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.553.0.tgz",
|
||||||
|
"integrity": "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.19",
|
"version": "0.30.19",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"jalaali-js": "^1.2.8",
|
"jalaali-js": "^1.2.8",
|
||||||
|
"lucide-react": "^0.553.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"next-pwa": "^5.6.0",
|
"next-pwa": "^5.6.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
|||||||
BIN
public/fonts/vazirmatn/Vazirmatn-Black.woff2
Normal file
BIN
public/fonts/vazirmatn/Vazirmatn-Black.woff2
Normal file
Binary file not shown.
BIN
public/fonts/vazirmatn/Vazirmatn-Bold.woff2
Normal file
BIN
public/fonts/vazirmatn/Vazirmatn-Bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/vazirmatn/Vazirmatn-ExtraBold.woff2
Normal file
BIN
public/fonts/vazirmatn/Vazirmatn-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/vazirmatn/Vazirmatn-ExtraLight.woff2
Normal file
BIN
public/fonts/vazirmatn/Vazirmatn-ExtraLight.woff2
Normal file
Binary file not shown.
BIN
public/fonts/vazirmatn/Vazirmatn-Light.woff2
Normal file
BIN
public/fonts/vazirmatn/Vazirmatn-Light.woff2
Normal file
Binary file not shown.
BIN
public/fonts/vazirmatn/Vazirmatn-Medium.woff2
Normal file
BIN
public/fonts/vazirmatn/Vazirmatn-Medium.woff2
Normal file
Binary file not shown.
BIN
public/fonts/vazirmatn/Vazirmatn-Regular.woff2
Normal file
BIN
public/fonts/vazirmatn/Vazirmatn-Regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/vazirmatn/Vazirmatn-SemiBold.woff2
Normal file
BIN
public/fonts/vazirmatn/Vazirmatn-SemiBold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/vazirmatn/Vazirmatn-Thin.woff2
Normal file
BIN
public/fonts/vazirmatn/Vazirmatn-Thin.woff2
Normal file
Binary file not shown.
BIN
public/fonts/vazirmatn/Vazirmatn[wght].woff2
Normal file
BIN
public/fonts/vazirmatn/Vazirmatn[wght].woff2
Normal file
Binary file not shown.
72
public/fonts/vazirmatn/style.css
Normal file
72
public/fonts/vazirmatn/style.css
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/* Generated by script */
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-Thin.woff2') format('woff2');
|
||||||
|
font-weight: 100;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-ExtraLight.woff2') format('woff2');
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-Light.woff2') format('woff2');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-Medium.woff2') format('woff2');
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-SemiBold.woff2') format('woff2');
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-Bold.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-ExtraBold.woff2') format('woff2');
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-Black.woff2') format('woff2');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "GreenHome",
|
|
||||||
"short_name": "GreenHome",
|
|
||||||
"start_url": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"theme_color": "#16a34a",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/icon-192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icon-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
179
public/sw.js
179
public/sw.js
File diff suppressed because one or more lines are too long
1
public/workbox-00a24876.js
Normal file
1
public/workbox-00a24876.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/persian-date'
|
import { getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/persian-date'
|
||||||
|
import Loading from '@/components/Loading'
|
||||||
|
|
||||||
function useQueryParam(name: string) {
|
function useQueryParam(name: string) {
|
||||||
if (typeof window === 'undefined') return null as string | null
|
if (typeof window === 'undefined') return null as string | null
|
||||||
@@ -14,11 +15,14 @@ export default function MonthPage() {
|
|||||||
const month = Number(useQueryParam('month') ?? getCurrentPersianMonth().toString())
|
const month = Number(useQueryParam('month') ?? getCurrentPersianMonth().toString())
|
||||||
|
|
||||||
const [daysWithCounts, setDaysWithCounts] = useState<{ day: number; count: number }[]>([])
|
const [daysWithCounts, setDaysWithCounts] = useState<{ day: number; count: number }[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
api.monthDays(deviceId, year, month)
|
api.monthDays(deviceId, year, month)
|
||||||
.then(list => setDaysWithCounts(list.map(x => ({ day: Number(x.persianDate.split('/')[2]), count: x.count }))))
|
.then(list => setDaysWithCounts(list.map(x => ({ day: Number(x.persianDate.split('/')[2]), count: x.count }))))
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
}, [deviceId, year, month])
|
}, [deviceId, year, month])
|
||||||
|
|
||||||
const maxDay = useMemo(() => {
|
const maxDay = useMemo(() => {
|
||||||
@@ -32,6 +36,10 @@ export default function MonthPage() {
|
|||||||
const days = useMemo(() => Array.from({ length: maxDay }, (_, i) => i + 1), [maxDay])
|
const days = useMemo(() => Array.from({ length: maxDay }, (_, i) => i + 1), [maxDay])
|
||||||
const countByDay = useMemo(() => new Map(daysWithCounts.map(d => [d.day, d.count])), [daysWithCounts])
|
const countByDay = useMemo(() => new Map(daysWithCounts.map(d => [d.day, d.count])), [daysWithCounts])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loading message="در حال بارگذاری تقویم ماه..." />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
@@ -51,7 +59,7 @@ export default function MonthPage() {
|
|||||||
const c = countByDay.get(d) ?? 0
|
const c = countByDay.get(d) ?? 0
|
||||||
const hasData = c > 0
|
const hasData = c > 0
|
||||||
return (
|
return (
|
||||||
<a key={d} href={`/day_details?deviceId=${deviceId}&year=${year}&month=${month}&day=${d}`} className={`min-h-[84px] bg-white p-2 flex flex-col border-l-0 ${hasData ? 'bg-green-50 hover:bg-green-100' : ''}`}>
|
<a key={d} href={`/day-details?deviceId=${deviceId}&year=${year}&month=${month}&day=${d}`} className={`min-h-[84px] bg-white p-2 flex flex-col border-l-0 ${hasData ? 'bg-green-50 hover:bg-green-100' : ''}`}>
|
||||||
<div className="text-xs text-gray-500 mb-1">{d}</div>
|
<div className="text-xs text-gray-500 mb-1">{d}</div>
|
||||||
{hasData && (
|
{hasData && (
|
||||||
<span className="self-start text-[10px] bg-green-600 text-white rounded-full px-1.5 py-0.5">{c}</span>
|
<span className="self-start text-[10px] bg-green-600 text-white rounded-full px-1.5 py-0.5">{c}</span>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getCurrentPersianYear } from '@/lib/persian-date'
|
import { getCurrentPersianYear } from '@/lib/persian-date'
|
||||||
|
import { Calendar as CalendarIcon, ChevronRight, Database, TrendingUp } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Loading from '@/components/Loading'
|
||||||
|
|
||||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||||
|
|
||||||
@@ -24,6 +27,7 @@ export default function CalendarPage() {
|
|||||||
const [year, setYear] = useState<number>(getCurrentPersianYear())
|
const [year, setYear] = useState<number>(getCurrentPersianYear())
|
||||||
const [activeMonths, setActiveMonths] = useState<number[]>([])
|
const [activeMonths, setActiveMonths] = useState<number[]>([])
|
||||||
const [monthDays, setMonthDays] = useState<Record<number, { days: number; records: number }>>({})
|
const [monthDays, setMonthDays] = useState<Record<number, { days: number; records: number }>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
const years = useMemo(() => Array.from({ length: 10 }, (_, i) => getCurrentPersianYear() - 2 + i), [])
|
const years = useMemo(() => Array.from({ length: 10 }, (_, i) => getCurrentPersianYear() - 2 + i), [])
|
||||||
|
|
||||||
// وقتی deviceIdParam تغییر کرد، deviceId را به روز کن
|
// وقتی deviceIdParam تغییر کرد، deviceId را به روز کن
|
||||||
@@ -37,6 +41,7 @@ export default function CalendarPage() {
|
|||||||
let mounted = true
|
let mounted = true
|
||||||
setMonthDays({})
|
setMonthDays({})
|
||||||
setActiveMonths([])
|
setActiveMonths([])
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
api.activeMonths(deviceId, year)
|
api.activeMonths(deviceId, year)
|
||||||
.then(async (months) => {
|
.then(async (months) => {
|
||||||
@@ -51,44 +56,113 @@ export default function CalendarPage() {
|
|||||||
setMonthDays(Object.fromEntries(entries))
|
setMonthDays(Object.fromEntries(entries))
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
|
.finally(() => {
|
||||||
|
if (mounted) setLoading(false)
|
||||||
|
})
|
||||||
return () => { mounted = false }
|
return () => { mounted = false }
|
||||||
}, [deviceId, year])
|
}, [deviceId, year])
|
||||||
|
|
||||||
const totalDays = useMemo(() => Object.values(monthDays).reduce((s, v) => s + (v?.days ?? 0), 0), [monthDays])
|
const totalDays = useMemo(() => Object.values(monthDays).reduce((s, v) => s + (v?.days ?? 0), 0), [monthDays])
|
||||||
const totalRecords = useMemo(() => Object.values(monthDays).reduce((s, v) => s + (v?.records ?? 0), 0), [monthDays])
|
const totalRecords = useMemo(() => Object.values(monthDays).reduce((s, v) => s + (v?.records ?? 0), 0), [monthDays])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loading message="در حال بارگذاری تقویم..." />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="min-h-screen p-4 md:p-6">
|
||||||
<div className="rounded-2xl border bg-white shadow-sm p-6">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="text-center text-xl font-semibold mb-4">انتخاب سال و ماه 📅</div>
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3 mb-4">
|
<Link
|
||||||
<label className="text-sm text-gray-600">انتخاب سال:</label>
|
href="/"
|
||||||
<select className="border rounded-md px-2 py-1" value={year} onChange={e => setYear(Number(e.target.value))}>
|
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
|
||||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
>
|
||||||
</select>
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
بازگشت به صفحه اصلی
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl shadow-md">
|
||||||
|
<CalendarIcon className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">انتخاب سال و ماه</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center text-sm text-gray-600 mb-5">
|
{/* Main Card */}
|
||||||
خلاصه {year}: {totalDays} روز دارای دیتا · {totalRecords} رکورد
|
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 md:p-8">
|
||||||
</div>
|
{/* Year Selector */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-4 mb-6">
|
||||||
|
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<CalendarIcon className="w-4 h-4 text-gray-500" />
|
||||||
|
انتخاب سال:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="px-4 py-2 border-2 border-gray-200 rounded-xl focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all bg-white font-medium"
|
||||||
|
value={year}
|
||||||
|
onChange={e => setYear(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
|
{/* Summary Stats */}
|
||||||
{monthNames.map((name, idx) => {
|
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 mb-6">
|
||||||
const m = idx + 1
|
<div className="flex items-center justify-center gap-6 flex-wrap">
|
||||||
const isActive = activeMonths.includes(m)
|
<div className="flex items-center gap-2">
|
||||||
const stats = monthDays[m]
|
<Database className="w-5 h-5 text-green-600" />
|
||||||
return (
|
<span className="text-sm text-gray-700">
|
||||||
<a key={m} href={`/day_details?deviceId=${deviceId}&year=${year}&month=${m}`} className={`rounded-xl border p-4 text-center transition ${isActive ? 'bg-white hover:bg-gray-50' : 'opacity-50'}`}>
|
<span className="font-semibold text-green-700">{totalDays}</span> روز دارای داده
|
||||||
<div className="text-sm">{name}</div>
|
</span>
|
||||||
{isActive && stats && (
|
</div>
|
||||||
<div className="inline-block mt-3 text-xs bg-green-600 text-white rounded-full px-2 py-1">
|
<div className="flex items-center gap-2">
|
||||||
{stats.days} روز · {stats.records} رکورد
|
<TrendingUp className="w-5 h-5 text-green-600" />
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
<span className="font-semibold text-green-700">{totalRecords}</span> رکورد
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Months Grid */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{monthNames.map((name, idx) => {
|
||||||
|
const m = idx + 1
|
||||||
|
const isActive = activeMonths.includes(m)
|
||||||
|
const stats = monthDays[m]
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={m}
|
||||||
|
href={`/day-details?deviceId=${deviceId}&year=${year}&month=${m}`}
|
||||||
|
className={`group relative rounded-xl border-2 p-5 text-center transition-all duration-300 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-white border-green-200 hover:border-green-400 hover:shadow-lg hover:-translate-y-1'
|
||||||
|
: 'bg-gray-50 border-gray-200 opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`text-lg font-semibold mb-2 ${isActive ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||||
|
{name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{isActive && stats ? (
|
||||||
</a>
|
<div className="space-y-1">
|
||||||
)
|
<div className="inline-flex items-center gap-1 bg-green-600 text-white text-xs rounded-full px-3 py-1.5 font-medium">
|
||||||
})}
|
<Database className="w-3 h-3" />
|
||||||
|
{stats.days} روز
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 mt-2">
|
||||||
|
{stats.records} رکورد
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-400">بدون داده</div>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 rounded-b-xl transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
164
src/app/day-details/page.tsx
Normal file
164
src/app/day-details/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"use client"
|
||||||
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/persian-date'
|
||||||
|
import { Calendar as CalendarIcon, ChevronRight, Database } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Loading from '@/components/Loading'
|
||||||
|
|
||||||
|
function useQueryParam(name: string) {
|
||||||
|
if (typeof window === 'undefined') return null as string | null
|
||||||
|
return new URLSearchParams(window.location.search).get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||||
|
|
||||||
|
export default function DayDetailsPage() {
|
||||||
|
const [items, setItems] = useState<{ persianDate: string; count: number }[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const deviceId = Number(useQueryParam('deviceId') ?? '1')
|
||||||
|
const year = Number(useQueryParam('year') ?? getCurrentPersianYear())
|
||||||
|
const month = Number(useQueryParam('month') ?? getCurrentPersianMonth())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
api.monthDays(deviceId, year, month)
|
||||||
|
.then(setItems)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [deviceId, year, month])
|
||||||
|
|
||||||
|
// Create a map of day -> count for quick lookup
|
||||||
|
const dataByDay = useMemo(() => {
|
||||||
|
const map = new Map<number, number>()
|
||||||
|
items.forEach(item => {
|
||||||
|
const day = parseInt(item.persianDate.split('/')[2])
|
||||||
|
map.set(day, item.count)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
// Calculate total days in the month
|
||||||
|
const totalDays = useMemo(() => {
|
||||||
|
return getPersianMonthDays(year, month)
|
||||||
|
}, [year, month])
|
||||||
|
|
||||||
|
// Get the starting weekday of the month (0 = Saturday)
|
||||||
|
const startWeekday = useMemo(() => {
|
||||||
|
const sw = getPersianMonthStartWeekday(year, month)
|
||||||
|
return sw;
|
||||||
|
}, [year, month])
|
||||||
|
|
||||||
|
// Generate calendar grid with proper spacing
|
||||||
|
const calendarGrid = useMemo(() => {
|
||||||
|
const grid = []
|
||||||
|
|
||||||
|
// Add empty cells for days before the month starts
|
||||||
|
for (let i = 0; i < startWeekday; i++) {
|
||||||
|
grid.push({ type: 'empty', day: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all days of the month
|
||||||
|
for (let day = 1; day <= totalDays; day++) {
|
||||||
|
grid.push({ type: 'day', day })
|
||||||
|
}
|
||||||
|
|
||||||
|
return grid
|
||||||
|
}, [startWeekday, totalDays])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loading message="در حال بارگذاری تقویم..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-4 md:p-6">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={`/calendar?deviceId=${deviceId}`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
بازگشت به تقویم
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl shadow-md">
|
||||||
|
<CalendarIcon className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||||
|
{monthNames[month - 1]} {year}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Grid */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
|
||||||
|
{/* Weekday Headers */}
|
||||||
|
<div className="grid grid-cols-7 text-center bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200">
|
||||||
|
{['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'].map(day => (
|
||||||
|
<div key={day} className="p-3 md:p-4 text-sm font-semibold text-gray-700">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Days Grid */}
|
||||||
|
<div className="grid grid-cols-7 gap-1 p-1 bg-gray-50">
|
||||||
|
{calendarGrid.map((cell, index) => {
|
||||||
|
if (cell.type === 'empty') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`empty-${index}`}
|
||||||
|
className="min-h-[90px] md:min-h-[100px] bg-transparent"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = cell.day!
|
||||||
|
const hasData = dataByDay.has(day)
|
||||||
|
const recordCount = dataByDay.get(day) || 0
|
||||||
|
|
||||||
|
if (hasData) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={day}
|
||||||
|
href={`/telemetry?deviceId=${deviceId}&date=${encodeURIComponent(`${year}/${month}/${day}`)}`}
|
||||||
|
className="group min-h-[90px] md:min-h-[100px] bg-white border-2 border-green-200 hover:border-green-400 hover:bg-gradient-to-br hover:from-green-50 hover:to-emerald-50 transition-all cursor-pointer rounded-lg p-2 md:p-3 flex flex-col items-center justify-center shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="text-base md:text-lg font-semibold text-gray-900 mb-1.5">{day}</div>
|
||||||
|
<div className="flex items-center gap-1 bg-gradient-to-r from-green-500 to-green-600 text-white text-xs rounded-full px-2.5 py-1 font-medium">
|
||||||
|
<Database className="w-3 h-3" />
|
||||||
|
{recordCount}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className="min-h-[90px] md:min-h-[100px] bg-gray-50 border border-gray-200 rounded-lg p-2 md:p-3 flex items-center justify-center text-gray-400"
|
||||||
|
>
|
||||||
|
<div className="text-sm md:text-base">{day}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="mt-6 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2 text-sm text-gray-700">
|
||||||
|
<Database className="w-4 h-4 text-green-600" />
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-green-700">{items.length}</span> روز دارای داده از{' '}
|
||||||
|
<span className="font-semibold text-green-700">{totalDays}</span> روز ماه
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import { useEffect, useState, useMemo } from 'react'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/persian-date'
|
|
||||||
|
|
||||||
function useQueryParam(name: string) {
|
|
||||||
if (typeof window === 'undefined') return null as string | null
|
|
||||||
return new URLSearchParams(window.location.search).get(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
|
||||||
|
|
||||||
export default function DayDetailsPage() {
|
|
||||||
const [items, setItems] = useState<{ persianDate: string; count: number }[]>([])
|
|
||||||
const deviceId = Number(useQueryParam('deviceId') ?? '1')
|
|
||||||
const year = Number(useQueryParam('year') ?? getCurrentPersianYear())
|
|
||||||
const month = Number(useQueryParam('month') ?? getCurrentPersianMonth())
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.monthDays(deviceId, year, month).then(setItems).catch(console.error)
|
|
||||||
}, [deviceId, year, month])
|
|
||||||
|
|
||||||
// Create a map of day -> count for quick lookup
|
|
||||||
const dataByDay = useMemo(() => {
|
|
||||||
const map = new Map<number, number>()
|
|
||||||
items.forEach(item => {
|
|
||||||
const day = parseInt(item.persianDate.split('/')[2])
|
|
||||||
map.set(day, item.count)
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
}, [items])
|
|
||||||
|
|
||||||
// Calculate total days in the month
|
|
||||||
const totalDays = useMemo(() => {
|
|
||||||
return getPersianMonthDays(year, month)
|
|
||||||
}, [year, month])
|
|
||||||
|
|
||||||
// Get the starting weekday of the month (0 = Saturday)
|
|
||||||
const startWeekday = useMemo(() => {
|
|
||||||
const sw = getPersianMonthStartWeekday(year, month)//- 1;
|
|
||||||
//if (sw < 0) sw += 7;
|
|
||||||
return sw;
|
|
||||||
}, [year, month])
|
|
||||||
|
|
||||||
// Generate calendar grid with proper spacing
|
|
||||||
const calendarGrid = useMemo(() => {
|
|
||||||
const grid = []
|
|
||||||
|
|
||||||
// Add empty cells for days before the month starts
|
|
||||||
for (let i = 0; i < startWeekday; i++) {
|
|
||||||
grid.push({ type: 'empty', day: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add all days of the month
|
|
||||||
for (let day = 1; day <= totalDays; day++) {
|
|
||||||
grid.push({ type: 'day', day })
|
|
||||||
}
|
|
||||||
|
|
||||||
return grid
|
|
||||||
}, [startWeekday, totalDays])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<a
|
|
||||||
href={`/calendar?deviceId=${deviceId}`}
|
|
||||||
className="border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
← بازگشت به تقویم
|
|
||||||
</a>
|
|
||||||
<h1 className="text-xl font-bold text-gray-900">
|
|
||||||
{monthNames[month - 1]} {year}
|
|
||||||
</h1>
|
|
||||||
<div></div> {/* Spacer for centering */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendar Grid */}
|
|
||||||
<div className="rounded-2xl border bg-white shadow-sm overflow-hidden">
|
|
||||||
{/* Weekday Headers */}
|
|
||||||
<div className="grid grid-cols-7 text-center bg-gray-100 text-sm font-medium">
|
|
||||||
{['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'].map(day => (
|
|
||||||
<div key={day} className="p-3 border-b border-gray-200 text-gray-700">
|
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Days Grid */}
|
|
||||||
<div className="grid grid-cols-7 gap-px bg-gray-200">
|
|
||||||
{calendarGrid.map((cell, index) => {
|
|
||||||
if (cell.type === 'empty') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`empty-${index}`}
|
|
||||||
className="min-h-[80px] bg-gray-100 p-3 flex items-center justify-center border-l-0"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const day = cell.day!
|
|
||||||
const hasData = dataByDay.has(day)
|
|
||||||
const recordCount = dataByDay.get(day) || 0
|
|
||||||
|
|
||||||
if (hasData) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={day}
|
|
||||||
href={`/month_select?deviceId=${deviceId}&date=${encodeURIComponent(`${year}/${month}/${day}`)}`}
|
|
||||||
className="min-h-[80px] bg-white p-3 flex flex-col items-center justify-center border border-transparent hover:border-green-400 hover:bg-green-50 transition-all cursor-pointer group rounded-xl"
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium text-gray-900 mb-1">{day}</div>
|
|
||||||
<div className="text-xs bg-green-600 text-white rounded-full px-2 py-1">
|
|
||||||
{recordCount} رکورد
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={day}
|
|
||||||
className="min-h-[80px] bg-gray-50 p-3 flex items-center justify-center border-l-0 text-gray-400 cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<div className="text-sm">{day}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
<div className="mt-6 text-center text-sm text-gray-600">
|
|
||||||
{items.length} روز دارای داده از {totalDays} روز ماه
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
345
src/app/device-settings/page.tsx
Normal file
345
src/app/device-settings/page.tsx
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
"use client"
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { api, DeviceSettingsDto } from '@/lib/api'
|
||||||
|
import { Settings, ChevronRight, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Loading from '@/components/Loading'
|
||||||
|
|
||||||
|
function useQueryParam(name: string) {
|
||||||
|
if (typeof window === 'undefined') return null as string | null
|
||||||
|
return new URLSearchParams(window.location.search).get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceSettingsPage() {
|
||||||
|
const [settings, setSettings] = useState<DeviceSettingsDto | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const deviceId = Number(useQueryParam('deviceId') ?? '1')
|
||||||
|
const deviceName = useQueryParam('deviceName') ?? 'دستگاه'
|
||||||
|
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const data = await api.getDeviceSettings(deviceId)
|
||||||
|
setSettings(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading settings:', err)
|
||||||
|
setError('خطا در بارگذاری تنظیمات')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [deviceId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings()
|
||||||
|
}, [loadSettings])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!settings) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
|
||||||
|
if (settings.id === 0) {
|
||||||
|
await api.createDeviceSettings(settings)
|
||||||
|
setSuccess('تنظیمات با موفقیت ایجاد شد')
|
||||||
|
} else {
|
||||||
|
await api.updateDeviceSettings(settings)
|
||||||
|
setSuccess('تنظیمات با موفقیت بهروزرسانی شد')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving settings:', err)
|
||||||
|
setError('خطا در ذخیره تنظیمات')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputChange = (field: keyof DeviceSettingsDto, value: number) => {
|
||||||
|
if (!settings) return
|
||||||
|
setSettings({ ...settings, [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializeDefaultSettings = () => {
|
||||||
|
const defaultSettings: DeviceSettingsDto = {
|
||||||
|
id: 0,
|
||||||
|
deviceId: deviceId,
|
||||||
|
deviceName: deviceName,
|
||||||
|
dangerMaxTemperature: 40,
|
||||||
|
dangerMinTemperature: 5,
|
||||||
|
maxTemperature: 35,
|
||||||
|
minTemperature: 10,
|
||||||
|
maxGasPPM: 1000,
|
||||||
|
minGasPPM: 0,
|
||||||
|
maxLux: 100000,
|
||||||
|
minLux: 0,
|
||||||
|
maxHumidityPercent: 80,
|
||||||
|
minHumidityPercent: 20,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
setSettings(defaultSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loading message="در حال بارگذاری تنظیمات..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deviceId) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||||
|
<div className="text-lg text-red-600 mb-4">شناسه دستگاه مشخص نشده است</div>
|
||||||
|
<Link
|
||||||
|
href="/devices"
|
||||||
|
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
بازگشت به انتخاب دستگاه
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-4 md:p-6">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={`/devices`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
بازگشت به انتخاب دستگاه
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl shadow-md">
|
||||||
|
<Settings className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||||
|
تنظیمات {deviceName}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-green-700 flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-5 h-5 flex-shrink-0" />
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!settings ? (
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-8 text-center">
|
||||||
|
<AlertCircle className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<div className="text-lg text-gray-600 mb-6">
|
||||||
|
تنظیمات برای این دستگاه وجود ندارد
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={initializeDefaultSettings}
|
||||||
|
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-8 py-3 rounded-xl transition-all shadow-md hover:shadow-lg font-medium"
|
||||||
|
>
|
||||||
|
ایجاد تنظیمات پیشفرض
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 md:p-8">
|
||||||
|
<div className="grid gap-8 md:grid-cols-2">
|
||||||
|
{/* Temperature Settings */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-red-500 to-orange-500 rounded-lg flex items-center justify-center">
|
||||||
|
<Thermometer className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
تنظیمات دما
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
حداکثر دما (°C)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={settings.maxTemperature}
|
||||||
|
onChange={(e) => handleInputChange('maxTemperature', parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-all bg-gray-50 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
حداقل دما (°C)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={settings.minTemperature}
|
||||||
|
onChange={(e) => handleInputChange('minTemperature', parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-all bg-gray-50 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gas Settings */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-gray-600 to-gray-700 rounded-lg flex items-center justify-center">
|
||||||
|
<Wind className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
تنظیمات گاز
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
حداکثر گاز CO (ppm)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={settings.maxGasPPM}
|
||||||
|
onChange={(e) => handleInputChange('maxGasPPM', parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-600/20 transition-all bg-gray-50 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
حداقل گاز CO (ppm)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={settings.minGasPPM}
|
||||||
|
onChange={(e) => handleInputChange('minGasPPM', parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-600/20 transition-all bg-gray-50 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Light Settings */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-yellow-500 to-orange-500 rounded-lg flex items-center justify-center">
|
||||||
|
<Sun className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
تنظیمات نور
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
حداکثر نور (Lux)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={settings.maxLux}
|
||||||
|
onChange={(e) => handleInputChange('maxLux', parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-500/20 transition-all bg-gray-50 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
حداقل نور (Lux)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={settings.minLux}
|
||||||
|
onChange={(e) => handleInputChange('minLux', parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-500/20 transition-all bg-gray-50 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Humidity Settings */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-lg flex items-center justify-center">
|
||||||
|
<Droplets className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
تنظیمات رطوبت هوا
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
حداکثر رطوبت (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={settings.maxHumidityPercent}
|
||||||
|
onChange={(e) => handleInputChange('maxHumidityPercent', parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all bg-gray-50 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
حداقل رطوبت (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={settings.minHumidityPercent}
|
||||||
|
onChange={(e) => handleInputChange('minHumidityPercent', parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all bg-gray-50 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="mt-8 flex justify-center">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className={`px-8 py-4 rounded-xl font-medium transition-all flex items-center gap-2 shadow-md ${
|
||||||
|
saving
|
||||||
|
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||||
|
: 'bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white hover:shadow-lg transform hover:-translate-y-0.5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
در حال ذخیره...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-5 h-5" />
|
||||||
|
ذخیره تنظیمات
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
|
||||||
import { api, DeviceSettingsDto } from '@/lib/api'
|
|
||||||
|
|
||||||
function useQueryParam(name: string) {
|
|
||||||
if (typeof window === 'undefined') return null as string | null
|
|
||||||
return new URLSearchParams(window.location.search).get(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeviceSettingsPage() {
|
|
||||||
const [settings, setSettings] = useState<DeviceSettingsDto | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const deviceId = Number(useQueryParam('deviceId') ?? '1')
|
|
||||||
const deviceName = useQueryParam('deviceName') ?? 'دستگاه'
|
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
const data = await api.getDeviceSettings(deviceId)
|
|
||||||
setSettings(data)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading settings:', err)
|
|
||||||
setError('خطا در بارگذاری تنظیمات')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [deviceId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSettings()
|
|
||||||
}, [loadSettings])
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!settings) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSaving(true)
|
|
||||||
setError(null)
|
|
||||||
setSuccess(null)
|
|
||||||
|
|
||||||
if (settings.id === 0) {
|
|
||||||
// Create new settings
|
|
||||||
await api.createDeviceSettings(settings)
|
|
||||||
setSuccess('تنظیمات با موفقیت ایجاد شد')
|
|
||||||
} else {
|
|
||||||
// Update existing settings
|
|
||||||
await api.updateDeviceSettings(settings)
|
|
||||||
setSuccess('تنظیمات با موفقیت بهروزرسانی شد')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error saving settings:', err)
|
|
||||||
setError('خطا در ذخیره تنظیمات')
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInputChange = (field: keyof DeviceSettingsDto, value: number) => {
|
|
||||||
if (!settings) return
|
|
||||||
setSettings({ ...settings, [field]: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
const initializeDefaultSettings = () => {
|
|
||||||
const defaultSettings: DeviceSettingsDto = {
|
|
||||||
id: 0,
|
|
||||||
deviceId: deviceId,
|
|
||||||
deviceName: deviceName,
|
|
||||||
dangerMaxTemperature: 40,
|
|
||||||
dangerMinTemperature: 5,
|
|
||||||
maxTemperature: 35,
|
|
||||||
minTemperature: 10,
|
|
||||||
maxGasPPM: 1000,
|
|
||||||
minGasPPM: 0,
|
|
||||||
maxLux: 100000,
|
|
||||||
minLux: 0,
|
|
||||||
maxHumidityPercent: 80,
|
|
||||||
minHumidityPercent: 20,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
}
|
|
||||||
setSettings(defaultSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6 text-center">
|
|
||||||
<div className="text-lg">در حال بارگذاری...</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!deviceId) {
|
|
||||||
return (
|
|
||||||
<div className="p-6 text-center">
|
|
||||||
<div className="text-lg text-red-600">شناسه دستگاه مشخص نشده است</div>
|
|
||||||
<a
|
|
||||||
href="/devices"
|
|
||||||
className="mt-4 inline-block border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
← بازگشت به انتخاب دستگاه
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<a
|
|
||||||
href={`/devices`}
|
|
||||||
className="border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
← بازگشت به انتخاب دستگاه
|
|
||||||
</a>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
|
||||||
⚙️ تنظیمات {deviceName}
|
|
||||||
</h1>
|
|
||||||
<div></div> {/* Spacer for centering */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{success && (
|
|
||||||
<div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg text-green-700">
|
|
||||||
{success}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!settings ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="text-lg text-gray-600 mb-4">
|
|
||||||
تنظیمات برای این دستگاه وجود ندارد
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={initializeDefaultSettings}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
ایجاد تنظیمات پیشفرض
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
{/* Temperature Settings */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
|
|
||||||
🌡️ تنظیمات دما
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* <div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
حداکثر دمای خطرناک (°C)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={settings.dangerMaxTemperature}
|
|
||||||
onChange={(e) => handleInputChange('dangerMaxTemperature', parseFloat(e.target.value) || 0)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
حداقل دمای خطرناک (°C)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={settings.dangerMinTemperature}
|
|
||||||
onChange={(e) => handleInputChange('dangerMinTemperature', parseFloat(e.target.value) || 0)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
حداکثر دما (°C)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={settings.maxTemperature}
|
|
||||||
onChange={(e) => handleInputChange('maxTemperature', parseFloat(e.target.value) || 0)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
حداقل دما (°C)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={settings.minTemperature}
|
|
||||||
onChange={(e) => handleInputChange('minTemperature', parseFloat(e.target.value) || 0)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gas Settings */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
|
|
||||||
🫁 تنظیمات گاز
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
حداکثر گاز CO (ppm)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={settings.maxGasPPM}
|
|
||||||
onChange={(e) => handleInputChange('maxGasPPM', parseInt(e.target.value) || 0)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
حداقل گاز CO (ppm)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={settings.minGasPPM}
|
|
||||||
onChange={(e) => handleInputChange('minGasPPM', parseInt(e.target.value) || 0)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Light Settings */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
|
|
||||||
💡 تنظیمات نور
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
حداکثر نور (Lux)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={settings.maxLux}
|
|
||||||
onChange={(e) => handleInputChange('maxLux', parseFloat(e.target.value) || 0)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
حداقل نور (Lux)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={settings.minLux}
|
|
||||||
onChange={(e) => handleInputChange('minLux', parseFloat(e.target.value) || 0)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Humidity Settings */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
|
|
||||||
💧 تنظیمات رطوبت هوا
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
حداکثر رطوبت (%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={settings.maxHumidityPercent}
|
|
||||||
onChange={(e) => handleInputChange('maxHumidityPercent', parseFloat(e.target.value) || 0)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
حداقل رطوبت (%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={settings.minHumidityPercent}
|
|
||||||
onChange={(e) => handleInputChange('minHumidityPercent', parseFloat(e.target.value) || 0)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
<div className="mt-8 flex justify-center">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className={`px-8 py-3 rounded-lg font-medium transition-colors ${saving
|
|
||||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
|
||||||
: 'bg-green-600 hover:bg-green-700 text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{saving ? 'در حال ذخیره...' : '💾 ذخیره تنظیمات'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useMemo, useState, useRef, useEffect } from 'react'
|
import { useMemo, useState, useRef, useEffect } from 'react'
|
||||||
import { api, DeviceDto } from '@/lib/api'
|
import { api, DeviceDto } from '@/lib/api'
|
||||||
|
import { Settings, CheckCircle2, ArrowRight, Calendar, BarChart3, Activity, RotateCcw } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Loading from '@/components/Loading'
|
||||||
|
|
||||||
export default function DevicesPage() {
|
export default function DevicesPage() {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -94,140 +97,173 @@ export default function DevicesPage() {
|
|||||||
if (passwordRef.current) passwordRef.current.value = ''
|
if (passwordRef.current) passwordRef.current.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="p-6">در حال بررسی دستگاه...</div>
|
if (loading) {
|
||||||
|
return <Loading message="در حال بررسی دستگاه..." />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
{/* Main Card */}
|
{/* Main Card */}
|
||||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-2xl shadow-lg p-8 border border-blue-200">
|
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||||
{/* Title Section */}
|
{/* Title Section */}
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-8">
|
||||||
<div className="flex items-center justify-center mb-2">
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-green-500 to-green-600 rounded-2xl shadow-lg mb-4">
|
||||||
<span className="text-red-500 text-xl mr-2">🚀</span>
|
{selected ? (
|
||||||
<h1 className="text-xl font-bold text-gray-900">
|
<CheckCircle2 className="w-8 h-8 text-white" />
|
||||||
{selected ? 'دستگاه فعال' : 'انتخاب دستگاه'}
|
) : (
|
||||||
</h1>
|
<Settings className="w-8 h-8 text-white" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-700">
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
{selected ? 'دستگاه فعال' : 'انتخاب دستگاه'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
{selected
|
{selected
|
||||||
? `دستگاه "${selected.deviceName}" با موفقیت تأیید شد`
|
? `دستگاه "${selected.deviceName}" با موفقیت تأیید شد`
|
||||||
: 'نام دستگاه و رمز عبور را وارد کنید تا عملیات فعال شود.'
|
: 'نام دستگاه و رمز عبور را وارد کنید'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* نمایش فرم فقط وقتی دستگاه انتخاب نشده */}
|
{/* نمایش فرم فقط وقتی دستگاه انتخاب نشده */}
|
||||||
{!selected ? (
|
{!selected ? (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
{/* Input Fields */}
|
{/* Input Fields */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<input
|
<div>
|
||||||
ref={deviceNameRef}
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
name="deviceName"
|
نام دستگاه
|
||||||
className="w-full px-4 py-3 rounded-lg border-2 bg-gray-50 border-gray-200 focus:border-blue-300 focus:outline-none transition-all duration-200"
|
</label>
|
||||||
placeholder="نام دستگاه"
|
<input
|
||||||
defaultValue={deviceName}
|
ref={deviceNameRef}
|
||||||
onInput={handleInputChange}
|
name="deviceName"
|
||||||
disabled={loading}
|
className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200"
|
||||||
/>
|
placeholder="نام دستگاه را وارد کنید"
|
||||||
<input
|
defaultValue={deviceName}
|
||||||
ref={passwordRef}
|
onInput={handleInputChange}
|
||||||
name="password"
|
disabled={loading}
|
||||||
className="w-full px-4 py-3 rounded-lg border-2 bg-gray-50 border-gray-200 focus:border-blue-300 focus:outline-none transition-all duration-200"
|
/>
|
||||||
placeholder="رمز عبور"
|
</div>
|
||||||
type="password"
|
<div>
|
||||||
defaultValue={password}
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
onInput={handleInputChange}
|
رمز عبور
|
||||||
disabled={loading}
|
</label>
|
||||||
/>
|
<input
|
||||||
|
ref={passwordRef}
|
||||||
|
name="password"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200"
|
||||||
|
placeholder="رمز عبور را وارد کنید"
|
||||||
|
type="password"
|
||||||
|
defaultValue={password}
|
||||||
|
onInput={handleInputChange}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-red-600 text-sm text-center">{error}</div>
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm text-center">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confirm Button - همیشه فعال */}
|
{/* Confirm Button */}
|
||||||
<div className="flex justify-center">
|
<button
|
||||||
<button
|
type="submit"
|
||||||
type="submit"
|
disabled={loading || !canSubmit}
|
||||||
disabled={loading}
|
className={`w-full py-3 rounded-xl text-white font-medium transition-all duration-200 flex items-center justify-center gap-2 shadow-md ${
|
||||||
className={`px-6 py-3 rounded-lg text-white font-medium transition-all duration-200 flex items-center gap-2 ${!loading
|
!loading && canSubmit
|
||||||
? 'bg-blue-600 hover:bg-blue-700 shadow-md hover:shadow-lg'
|
? 'bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 hover:shadow-lg transform hover:-translate-y-0.5'
|
||||||
: 'bg-gray-400 cursor-not-allowed'
|
: 'bg-gray-300 cursor-not-allowed'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<span className="animate-spin">⏳</span>
|
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
در حال بررسی...
|
در حال بررسی...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-green-400">✓</span>
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
تایید
|
تایید و ادامه
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
/* نمایش دکمههای عملیات وقتی دستگاه انتخاب شده */
|
/* نمایش دکمههای عملیات وقتی دستگاه انتخاب شده */
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
{/* اطلاعات دستگاه فعال */}
|
{/* اطلاعات دستگاه فعال */}
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-center">
|
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 text-center">
|
||||||
<p className="text-yellow-800 font-medium">{selected.deviceName}</p>
|
<div className="flex items-center justify-center gap-2 mb-1">
|
||||||
<p className="text-yellow-600 text-sm">دستگاه فعال</p>
|
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
||||||
|
<p className="text-green-800 font-semibold">{selected.deviceName}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-green-600 text-sm">دستگاه فعال و آماده استفاده</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Action Button */}
|
{/* Main Action Button */}
|
||||||
<div className="flex justify-center">
|
<Link
|
||||||
<a
|
href={`/telemetry?deviceId=${selected.id}`}
|
||||||
href={`/month_select?deviceId=${selected.id}`}
|
className="block w-full bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white font-medium py-4 px-6 rounded-xl shadow-md hover:shadow-lg transition-all duration-200 flex items-center justify-center gap-2 transform hover:-translate-y-0.5"
|
||||||
className="w-full bg-purple-600 hover:bg-purple-700 text-white font-medium py-4 px-6 rounded-lg shadow-md hover:shadow-lg transition-all duration-200 flex items-center justify-center gap-2"
|
>
|
||||||
>
|
<Activity className="w-5 h-5" />
|
||||||
<span className="text-white">💎</span>
|
دادههای امروز
|
||||||
داده امروز
|
</Link>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Secondary Action Buttons */}
|
{/* Secondary Action Buttons */}
|
||||||
<div className="flex gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
<a
|
<Link
|
||||||
href={`/device_settings?deviceId=${selected.id}&deviceName=${encodeURIComponent(selected.deviceName)}`}
|
href={`/device-settings?deviceId=${selected.id}&deviceName=${encodeURIComponent(selected.deviceName)}`}
|
||||||
className="flex-1 bg-blue-500 hover:bg-blue-600 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200 flex items-center justify-center gap-2"
|
className="flex items-center justify-center gap-2 bg-blue-500 hover:bg-blue-600 text-white font-medium py-3 px-4 rounded-xl transition-all duration-200 shadow-md hover:shadow-lg"
|
||||||
>
|
>
|
||||||
<span className="text-white">⚙️</span>
|
<Settings className="w-5 h-5" />
|
||||||
تنظیمات
|
تنظیمات دستگاه
|
||||||
</a>
|
</Link>
|
||||||
<a
|
<div className="grid grid-cols-2 gap-3">
|
||||||
href={`/calendar/month?deviceId=${selected.id}&year=1403&month=1`}
|
<Link
|
||||||
className="flex-1 bg-green-500 hover:bg-green-600 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200 text-center"
|
href={`/calendar/month?deviceId=${selected.id}&year=1403&month=1`}
|
||||||
>
|
className="flex items-center justify-center gap-2 bg-green-500 hover:bg-green-600 text-white font-medium py-3 px-4 rounded-xl transition-all duration-200 shadow-md hover:shadow-lg text-sm"
|
||||||
تقویم ماه جاری
|
>
|
||||||
</a>
|
<Calendar className="w-4 h-4" />
|
||||||
<a
|
تقویم ماه
|
||||||
href={`/calendar?deviceId=${selected.id}`}
|
</Link>
|
||||||
className="flex-1 bg-orange-500 hover:bg-orange-600 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200 text-center"
|
<Link
|
||||||
>
|
href={`/calendar?deviceId=${selected.id}`}
|
||||||
انتخاب سال و ماه
|
className="flex items-center justify-center gap-2 bg-orange-500 hover:bg-orange-600 text-white font-medium py-3 px-4 rounded-xl transition-all duration-200 shadow-md hover:shadow-lg text-sm"
|
||||||
</a>
|
>
|
||||||
|
<BarChart3 className="w-4 h-4" />
|
||||||
|
انتخاب ماه
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمه تغییر دستگاه */}
|
{/* دکمه تغییر دستگاه */}
|
||||||
<div className="flex justify-center pt-4">
|
<div className="pt-4 border-t border-gray-200">
|
||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
className="px-6 py-2 bg-gray-500 hover:bg-gray-600 text-white font-medium rounded-lg transition-all duration-200 flex items-center gap-2"
|
className="w-full flex items-center justify-center gap-2 px-6 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium rounded-xl transition-all duration-200"
|
||||||
>
|
>
|
||||||
<span>🔄</span>
|
<RotateCcw className="w-4 h-4" />
|
||||||
تغییر دستگاه
|
تغییر دستگاه
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Back to Home */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
بازگشت به صفحه اصلی
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
@theme inline {
|
@theme inline {
|
||||||
/* --color-background: var(--background); */
|
/* --color-background: var(--background); */
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: 'Vazirmatn', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -19,9 +19,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
/* background: var(--background); */
|
/* background: var(--background); */
|
||||||
background: linear-gradient(180deg, #eef7ff, #f6fbff);
|
background: linear-gradient(180deg, #eef7ff, #f6fbff);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: vazir,Arial, Helvetica, sans-serif;
|
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.persian-number {
|
||||||
|
font-feature-settings: 'ss01';
|
||||||
|
font-variant-numeric: persian;
|
||||||
|
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||||
|
}
|
||||||
@@ -1,21 +1,41 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'GreenHome',
|
title: 'GreenHome',
|
||||||
description: 'GreenHome PWA',
|
description: 'مدیریت هوشمند گلخانه',
|
||||||
manifest: 'manifest.json',
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: 'default',
|
||||||
|
title: 'GreenHome'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="fa" dir="rtl">
|
<html lang="fa" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<meta name="theme-color" content="#16a34a" />
|
<meta name="theme-color" content="#16a34a" />
|
||||||
|
<meta name="application-name" content="GreenHome" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="GreenHome" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-512.png" />
|
||||||
|
<link rel="stylesheet" href="/fonts/vazirmatn/style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>{children}</body>
|
<body className='persian-number'>
|
||||||
|
<ServiceWorkerRegistration />
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/app/manifest.ts
Normal file
27
src/app/manifest.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { MetadataRoute } from 'next'
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: 'GreenHome',
|
||||||
|
short_name: 'GreenHome',
|
||||||
|
description: 'مدیریت هوشمند گلخانه',
|
||||||
|
start_url: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: '#ffffff',
|
||||||
|
theme_color: '#16a34a',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icon-192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { api, TelemetryDto } from '@/lib/api'
|
|
||||||
import { Card, DashboardGrid } from '@/components/DashboardCards'
|
|
||||||
import { LineChart, Panel } from '@/components/Charts'
|
|
||||||
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/persian-date'
|
|
||||||
|
|
||||||
function useQueryParam(name: string) {
|
|
||||||
if (typeof window === 'undefined') return null as string | null
|
|
||||||
return new URLSearchParams(window.location.search).get(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MonthSelectPage() {
|
|
||||||
const [telemetry, setTelemetry] = useState<TelemetryDto[]>([])
|
|
||||||
const [total, setTotal] = useState(0)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const deviceId = Number(useQueryParam('deviceId') ?? '1')
|
|
||||||
const dateParam = useQueryParam('date') ?? `${getCurrentPersianYear()}/${getCurrentPersianMonth()}/${getCurrentPersianDay()}` // e.g., "1404/7/14" or "1404%2F7%2F14"
|
|
||||||
|
|
||||||
// Decode the date parameter
|
|
||||||
const selectedDate = useMemo(() => {
|
|
||||||
if (!dateParam) return null
|
|
||||||
try {
|
|
||||||
// Decode URL-encoded date (e.g., "1404%2F7%2F14" -> "1404/7/14")
|
|
||||||
const decodedDate = decodeURIComponent(dateParam)
|
|
||||||
return decodedDate
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error decoding date parameter:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}, [dateParam])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedDate) {
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Parse the Persian date (e.g., "1404/7/14")
|
|
||||||
const [year, month, day] = selectedDate.split('/').map(Number)
|
|
||||||
|
|
||||||
// Convert to Gregorian date for start of day
|
|
||||||
const startDate = persianToGregorian(year, month, day)
|
|
||||||
startDate.setHours(0, 0, 0, 0)
|
|
||||||
|
|
||||||
// End of day
|
|
||||||
const endDate = new Date(startDate)
|
|
||||||
endDate.setHours(23, 59, 59, 999)
|
|
||||||
|
|
||||||
// Convert to UTC strings
|
|
||||||
const startUtc = startDate.toISOString()
|
|
||||||
const endUtc = endDate.toISOString()
|
|
||||||
|
|
||||||
// Load telemetry data for the specific date range
|
|
||||||
api.listTelemetry({ deviceId, startUtc, endUtc }).then(r => {
|
|
||||||
setTelemetry(r.items)
|
|
||||||
setTotal(r.totalCount)
|
|
||||||
}).catch(console.error).finally(() => setLoading(false))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing date:', error)
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [deviceId, selectedDate])
|
|
||||||
|
|
||||||
// Sort telemetry data by timestamp to ensure proper chronological order
|
|
||||||
const sortedTelemetry = useMemo(() => {
|
|
||||||
return [...telemetry].sort((a, b) => new Date(a.timestampUtc).getTime() - new Date(b.timestampUtc).getTime())
|
|
||||||
}, [telemetry])
|
|
||||||
|
|
||||||
const labels = useMemo(() => sortedTelemetry.map(t => t.persianDate), [sortedTelemetry])
|
|
||||||
const soil = useMemo(() => sortedTelemetry.map(t => Number(t.soilPercent ?? 0)), [sortedTelemetry])
|
|
||||||
const temp = useMemo(() => sortedTelemetry.map(t => Number(t.temperatureC ?? 0)), [sortedTelemetry])
|
|
||||||
const hum = useMemo(() => sortedTelemetry.map(t => Number(t.humidityPercent ?? 0)), [sortedTelemetry])
|
|
||||||
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
|
|
||||||
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6 text-center">
|
|
||||||
<div className="text-lg">در حال بارگذاری...</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedDate) {
|
|
||||||
return (
|
|
||||||
<div className="p-6 text-center">
|
|
||||||
<div className="text-lg text-red-600">تاریخ انتخاب نشده است</div>
|
|
||||||
<a
|
|
||||||
href={`/calendar?deviceId=${deviceId}`}
|
|
||||||
className="mt-4 inline-block border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
← بازگشت به تقویم
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 space-y-4">
|
|
||||||
{/* Header with navigation */}
|
|
||||||
<div className="rounded-xl border border-slate-300 bg-white p-4 shadow-sm">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<a
|
|
||||||
href={`/calendar?deviceId=${deviceId}`}
|
|
||||||
className="border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
← بازگشت به تقویم
|
|
||||||
</a>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
|
||||||
📊 جزئیات دادههای {selectedDate}
|
|
||||||
</h1>
|
|
||||||
<div></div> {/* Spacer for centering */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DashboardGrid>
|
|
||||||
<Card title="💡 نور (Lux)" icon='' value={lux.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...lux)} | حداقل: ${Math.min(0, ...lux)}`} />
|
|
||||||
<Card title="🫁 گاز CO (ppm)" value={gas.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...gas)} | حداقل: ${Math.min(0, ...gas)}`} />
|
|
||||||
<Card title="🌱 رطوبت خاک (%)" value={soil.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...soil)} | حداقل: ${Math.min(0, ...soil)}`} />
|
|
||||||
<Card title="💧 رطوبت (%)" value={hum.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...hum)} | حداقل: ${Math.min(0, ...hum)}`} />
|
|
||||||
<Card title="دما (°C)" icon='🌡️' value={temp.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...temp)} | حداقل: ${Math.min(0, ...temp)}`} />
|
|
||||||
<Card title="📊 تعداد داده" value={total} subtitle="در روز انتخابی" />
|
|
||||||
</DashboardGrid>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Panel title="رطوبت خاک">
|
|
||||||
<LineChart labels={labels} series={[{ label: 'رطوبت خاک (%)', data: soil, borderColor: '#16a34a', backgroundColor: '#dcfce7', fill: true }]} />
|
|
||||||
</Panel>
|
|
||||||
<Panel title="دما و رطوبت">
|
|
||||||
<LineChart labels={labels} series={[{ label: 'دما (°C)', data: temp, borderColor: '#ef4444' }, { label: 'رطوبت (%)', data: hum, borderColor: '#3b82f6' }]} />
|
|
||||||
</Panel>
|
|
||||||
<Panel title="نور">
|
|
||||||
<LineChart labels={labels} series={[{ label: 'Lux', data: lux, borderColor: '#a855f7' }]} />
|
|
||||||
</Panel>
|
|
||||||
<Panel title="گاز CO">
|
|
||||||
<LineChart labels={labels} series={[{ label: 'CO (ppm)', data: gas, borderColor: '#f59e0b', backgroundColor: '#fef3c7', fill: true }]} />
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,90 @@
|
|||||||
|
import { Home as HomeIcon, Settings, Calendar, BarChart3, Leaf } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
title: 'انتخاب دستگاه',
|
||||||
|
description: 'اتصال به دستگاه گلخانه',
|
||||||
|
href: '/devices',
|
||||||
|
icon: Settings,
|
||||||
|
color: 'from-blue-500 to-blue-600',
|
||||||
|
iconColor: 'text-blue-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'تقویم',
|
||||||
|
description: 'مشاهده دادههای ماهانه',
|
||||||
|
href: '/calendar?deviceId=1',
|
||||||
|
icon: Calendar,
|
||||||
|
color: 'from-green-500 to-green-600',
|
||||||
|
iconColor: 'text-green-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'جزئیات روز',
|
||||||
|
description: 'دادههای روزانه',
|
||||||
|
href: '/day-details?deviceId=1&year=1403&month=1',
|
||||||
|
icon: BarChart3,
|
||||||
|
color: 'from-purple-500 to-purple-600',
|
||||||
|
iconColor: 'text-purple-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دادههای تلهمتری',
|
||||||
|
description: 'مشاهده دادههای لحظهای',
|
||||||
|
href: '/telemetry?deviceId=1',
|
||||||
|
icon: Leaf,
|
||||||
|
color: 'from-orange-500 to-orange-600',
|
||||||
|
iconColor: 'text-orange-500'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="p-6">
|
<main className="min-h-screen p-4 md:p-8">
|
||||||
<h1 className="text-2xl mb-4">GreenHome</h1>
|
<div className="max-w-6xl mx-auto">
|
||||||
<ul className="list-disc pl-5 space-y-2">
|
{/* Header */}
|
||||||
<li><a className="text-green-700 underline" href="/devices">انتخاب دستگاه</a></li>
|
<div className="text-center mb-12 mt-8">
|
||||||
<li><a className="text-green-700 underline" href="/calendar?deviceId=1">Calendar (انتخاب ماه)</a></li>
|
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-green-500 to-green-600 rounded-2xl shadow-lg mb-4">
|
||||||
<li><a className="text-green-700 underline" href="/day_details?deviceId=1&year=1403&month=1">Day Details نمونه</a></li>
|
<HomeIcon className="w-10 h-10 text-white" />
|
||||||
<li><a className="text-green-700 underline" href="/month_select?deviceId=1">Month Select/Telemetry</a></li>
|
</div>
|
||||||
</ul>
|
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-3">GreenHome</h1>
|
||||||
|
<p className="text-lg text-gray-600">مدیریت هوشمند گلخانه</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{menuItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="group relative bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100"
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className={`flex-shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br ${item.color} flex items-center justify-center shadow-md group-hover:scale-110 transition-transform duration-300`}>
|
||||||
|
<Icon className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-1 group-hover:text-green-600 transition-colors">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r ${item.color} transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300`} />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Info */}
|
||||||
|
<div className="mt-12 text-center">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
سیستم مدیریت و مانیتورینگ گلخانه هوشمند
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
95
src/app/service-worker.ts
Normal file
95
src/app/service-worker.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
export type {};
|
||||||
|
declare let self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
const CACHE_NAME = 'greenhome-v1';
|
||||||
|
|
||||||
|
// Add list of files to cache here.
|
||||||
|
const FILES_TO_CACHE = [
|
||||||
|
'/',
|
||||||
|
'/manifest.json',
|
||||||
|
'/icon-192.png',
|
||||||
|
'/icon-512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event: ExtendableEvent) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
await cache.addAll(FILES_TO_CACHE);
|
||||||
|
await self.skipWaiting();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event: ExtendableEvent) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
await self.clients.claim();
|
||||||
|
|
||||||
|
// Remove old caches
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key !== CACHE_NAME)
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event: FetchEvent) => {
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const preloadResponse = await event.preloadResponse;
|
||||||
|
if (preloadResponse) {
|
||||||
|
return preloadResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await fetch(event.request);
|
||||||
|
} catch (e) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
return await cache.match('/') || new Response('', {
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
const cachedResponse = await cache.match(event.request);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(event.request);
|
||||||
|
|
||||||
|
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.request.url.startsWith('http')) {
|
||||||
|
const responseToCache = response.clone();
|
||||||
|
await cache.put(event.request, responseToCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
return new Response('', {
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
207
src/app/telemetry/page.tsx
Normal file
207
src/app/telemetry/page.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"use client"
|
||||||
|
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
|
||||||
|
import { api, TelemetryDto } from '@/lib/api'
|
||||||
|
import { Card, DashboardGrid } from '@/components/DashboardCards'
|
||||||
|
import { LineChart, Panel } from '@/components/Charts'
|
||||||
|
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/persian-date'
|
||||||
|
import { BarChart3, ChevronRight, Calendar as CalendarIcon, RefreshCw } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Loading from '@/components/Loading'
|
||||||
|
|
||||||
|
// زمان بهروزرسانی خودکار (به میلیثانیه) - میتوانید این مقدار را تغییر دهید
|
||||||
|
const AUTO_REFRESH_INTERVAL = 10 * 1000 // 10 ثانیه
|
||||||
|
|
||||||
|
function useQueryParam(name: string) {
|
||||||
|
if (typeof window === 'undefined') return null as string | null
|
||||||
|
return new URLSearchParams(window.location.search).get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TelemetryPage() {
|
||||||
|
const [telemetry, setTelemetry] = useState<TelemetryDto[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null)
|
||||||
|
const deviceId = Number(useQueryParam('deviceId') ?? '1')
|
||||||
|
const dateParam = useQueryParam('date') ?? `${getCurrentPersianYear()}/${getCurrentPersianMonth()}/${getCurrentPersianDay()}`
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
const selectedDate = useMemo(() => {
|
||||||
|
if (!dateParam) return null
|
||||||
|
try {
|
||||||
|
const decodedDate = decodeURIComponent(dateParam)
|
||||||
|
return decodedDate
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error decoding date parameter:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [dateParam])
|
||||||
|
|
||||||
|
// تابع بارگذاری دادهها
|
||||||
|
const loadData = useCallback(async (showLoading = true) => {
|
||||||
|
if (!selectedDate) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLoading) {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [year, month, day] = selectedDate.split('/').map(Number)
|
||||||
|
const startDate = persianToGregorian(year, month, day)
|
||||||
|
startDate.setHours(0, 0, 0, 0)
|
||||||
|
const endDate = new Date(startDate)
|
||||||
|
endDate.setHours(23, 59, 59, 999)
|
||||||
|
const startUtc = startDate.toISOString()
|
||||||
|
const endUtc = endDate.toISOString()
|
||||||
|
|
||||||
|
const result = await api.listTelemetry({ deviceId, startUtc, endUtc })
|
||||||
|
setTelemetry(result.items)
|
||||||
|
setTotal(result.totalCount)
|
||||||
|
setLastUpdate(new Date())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading telemetry:', error)
|
||||||
|
} finally {
|
||||||
|
if (showLoading) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [deviceId, selectedDate])
|
||||||
|
|
||||||
|
// بارگذاری اولیه
|
||||||
|
useEffect(() => {
|
||||||
|
loadData(true)
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
// تنظیم بهروزرسانی خودکار
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedDate) return
|
||||||
|
|
||||||
|
// پاک کردن interval قبلی
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// تنظیم interval جدید
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
loadData(false) // بدون نمایش loading
|
||||||
|
}, AUTO_REFRESH_INTERVAL)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedDate, loadData])
|
||||||
|
|
||||||
|
const sortedTelemetry = useMemo(() => {
|
||||||
|
return [...telemetry].sort((a, b) => new Date(a.timestampUtc).getTime() - new Date(b.timestampUtc).getTime())
|
||||||
|
}, [telemetry])
|
||||||
|
|
||||||
|
// تبدیل timestamp به ساعت (HH:MM:SS)
|
||||||
|
const labels = useMemo(() => {
|
||||||
|
return sortedTelemetry.map(t => {
|
||||||
|
const date = new Date(t.timestampUtc)
|
||||||
|
const hours = date.getHours().toString().padStart(2, '0')
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||||
|
const seconds = date.getSeconds().toString().padStart(2, '0')
|
||||||
|
return `${hours}:${minutes}:${seconds}`
|
||||||
|
})
|
||||||
|
}, [sortedTelemetry])
|
||||||
|
const soil = useMemo(() => sortedTelemetry.map(t => Number(t.soilPercent ?? 0)), [sortedTelemetry])
|
||||||
|
const temp = useMemo(() => sortedTelemetry.map(t => Number(t.temperatureC ?? 0)), [sortedTelemetry])
|
||||||
|
const hum = useMemo(() => sortedTelemetry.map(t => Number(t.humidityPercent ?? 0)), [sortedTelemetry])
|
||||||
|
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
|
||||||
|
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loading message="در حال بارگذاری دادهها..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedDate) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||||
|
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
||||||
|
<Link
|
||||||
|
href={`/calendar?deviceId=${deviceId}`}
|
||||||
|
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
بازگشت به تقویم
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-4 md:p-6">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={`/calendar?deviceId=${deviceId}`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
بازگشت به تقویم
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl shadow-md">
|
||||||
|
<BarChart3 className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||||
|
جزئیات دادههای {selectedDate}
|
||||||
|
</h1>
|
||||||
|
{lastUpdate && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
آخرین بهروزرسانی: {lastUpdate.toLocaleTimeString('fa-IR')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => loadData(true)}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg"
|
||||||
|
title="بهروزرسانی دستی"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
<span className="text-sm">بهروزرسانی</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DashboardGrid>
|
||||||
|
<Card title="نور (Lux)" icon="💡" value={lux.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...lux)} | حداقل: ${Math.min(0, ...lux)}`} />
|
||||||
|
<Card title="گاز CO (ppm)" icon="🫁" value={gas.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...gas)} | حداقل: ${Math.min(0, ...gas)}`} />
|
||||||
|
<Card title="رطوبت خاک (%)" icon="🌱" value={soil.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...soil)} | حداقل: ${Math.min(0, ...soil)}`} />
|
||||||
|
<Card title="رطوبت (%)" icon="💧" value={hum.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...hum)} | حداقل: ${Math.min(0, ...hum)}`} />
|
||||||
|
<Card title="دما (°C)" icon="🌡️" value={temp.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...temp)} | حداقل: ${Math.min(0, ...temp)}`} />
|
||||||
|
<Card title="تعداد داده" icon="📊" value={total} subtitle="در روز انتخابی" />
|
||||||
|
</DashboardGrid>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Panel title="رطوبت خاک">
|
||||||
|
<LineChart labels={labels} series={[{ label: 'رطوبت خاک (%)', data: soil, borderColor: '#16a34a', backgroundColor: '#dcfce7', fill: true }]} />
|
||||||
|
</Panel>
|
||||||
|
<Panel title="دما و رطوبت">
|
||||||
|
<LineChart labels={labels} series={[{ label: 'دما (°C)', data: temp, borderColor: '#ef4444' }, { label: 'رطوبت (%)', data: hum, borderColor: '#3b82f6' }]} />
|
||||||
|
</Panel>
|
||||||
|
<Panel title="نور">
|
||||||
|
<LineChart labels={labels} series={[{ label: 'Lux', data: lux, borderColor: '#a855f7' }]} />
|
||||||
|
</Panel>
|
||||||
|
<Panel title="گاز CO">
|
||||||
|
<LineChart labels={labels} series={[{ label: 'CO (ppm)', data: gas, borderColor: '#f59e0b', backgroundColor: '#fef3c7', fill: true }]} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -13,6 +13,12 @@ import {
|
|||||||
|
|
||||||
Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Legend, Tooltip, Filler)
|
Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Legend, Tooltip, Filler)
|
||||||
|
|
||||||
|
// تابع تبدیل ارقام انگلیسی به فارسی
|
||||||
|
function toPersianDigits(str: string | number): string {
|
||||||
|
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']
|
||||||
|
return str.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)])
|
||||||
|
}
|
||||||
|
|
||||||
type Series = { label: string; data: number[]; borderColor: string; backgroundColor?: string; fill?: boolean }
|
type Series = { label: string; data: number[]; borderColor: string; backgroundColor?: string; fill?: boolean }
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -23,34 +29,106 @@ type Props = {
|
|||||||
|
|
||||||
export function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
export function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-300 bg-white p-4 shadow-sm">
|
<div className="bg-white rounded-xl border border-gray-200 shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden">
|
||||||
<div className="text-center font-medium mb-2">{title}</div>
|
<div className="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200 px-5 py-4">
|
||||||
{children}
|
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LineChart({ labels, series }: Props) {
|
export function LineChart({ labels, series }: Props) {
|
||||||
return (
|
return (
|
||||||
<Line
|
<div className="persian-number">
|
||||||
data={{
|
<Line
|
||||||
labels,
|
data={{
|
||||||
datasets: series.map(s => ({
|
labels,
|
||||||
label: s.label,
|
datasets: series.map(s => ({
|
||||||
data: s.data,
|
label: s.label,
|
||||||
borderColor: s.borderColor,
|
data: s.data,
|
||||||
backgroundColor: s.backgroundColor ?? s.borderColor,
|
borderColor: s.borderColor,
|
||||||
fill: s.fill ?? false,
|
backgroundColor: s.backgroundColor ?? s.borderColor,
|
||||||
tension: 0.3,
|
fill: s.fill ?? false,
|
||||||
pointRadius: 2
|
tension: 0.3,
|
||||||
}))
|
pointRadius: 2
|
||||||
}}
|
}))
|
||||||
options={{
|
}}
|
||||||
responsive: true,
|
options={{
|
||||||
plugins: { legend: { display: true, position: 'bottom' } },
|
responsive: true,
|
||||||
scales: { x: { display: true }, y: { display: true } }
|
maintainAspectRatio: true,
|
||||||
}}
|
font: {
|
||||||
/>
|
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
font: {
|
||||||
|
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
|
||||||
|
size: 12
|
||||||
|
},
|
||||||
|
padding: 15
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
titleFont: {
|
||||||
|
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
|
||||||
|
},
|
||||||
|
bodyFont: {
|
||||||
|
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return `${context.dataset.label}: ${toPersianDigits(context.parsed.y.toFixed(2))}`
|
||||||
|
},
|
||||||
|
title: function(context) {
|
||||||
|
return `ساعت: ${context[0].label}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: true,
|
||||||
|
ticks: {
|
||||||
|
font: {
|
||||||
|
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
|
||||||
|
size: 11
|
||||||
|
},
|
||||||
|
callback: function(value, index) {
|
||||||
|
const label = labels[index]
|
||||||
|
return label ? toPersianDigits(label) : ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
display: true,
|
||||||
|
ticks: {
|
||||||
|
font: {
|
||||||
|
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
|
||||||
|
size: 11
|
||||||
|
},
|
||||||
|
callback: function(value) {
|
||||||
|
return toPersianDigits(value.toString())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,115 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Sun, Wind, Droplets, Thermometer, Database, TrendingUp, TrendingDown } from 'lucide-react'
|
||||||
|
|
||||||
type CardProps = {
|
type CardProps = {
|
||||||
title: string
|
title: string
|
||||||
value: string | number
|
value: string | number
|
||||||
subtitle?: string,
|
subtitle?: string
|
||||||
icon?:string
|
icon?: string
|
||||||
|
color?: 'light' | 'gas' | 'soil' | 'humidity' | 'temperature' | 'data'
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
'💡': Sun,
|
||||||
|
'🫁': Wind,
|
||||||
|
'🌱': Droplets,
|
||||||
|
'💧': Droplets,
|
||||||
|
'🌡️': Thermometer,
|
||||||
|
'📊': Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorConfig: Record<string, { gradient: string; border: string; iconBg: string }> = {
|
||||||
|
light: {
|
||||||
|
gradient: 'from-yellow-500 to-orange-500',
|
||||||
|
border: 'border-yellow-200',
|
||||||
|
iconBg: 'from-yellow-500 to-orange-500'
|
||||||
|
},
|
||||||
|
gas: {
|
||||||
|
gradient: 'from-gray-600 to-gray-700',
|
||||||
|
border: 'border-gray-300',
|
||||||
|
iconBg: 'from-gray-600 to-gray-700'
|
||||||
|
},
|
||||||
|
soil: {
|
||||||
|
gradient: 'from-green-500 to-emerald-600',
|
||||||
|
border: 'border-green-200',
|
||||||
|
iconBg: 'from-green-500 to-emerald-600'
|
||||||
|
},
|
||||||
|
humidity: {
|
||||||
|
gradient: 'from-blue-500 to-cyan-500',
|
||||||
|
border: 'border-blue-200',
|
||||||
|
iconBg: 'from-blue-500 to-cyan-500'
|
||||||
|
},
|
||||||
|
temperature: {
|
||||||
|
gradient: 'from-red-500 to-orange-500',
|
||||||
|
border: 'border-red-200',
|
||||||
|
iconBg: 'from-red-500 to-orange-500'
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
gradient: 'from-purple-500 to-indigo-600',
|
||||||
|
border: 'border-purple-200',
|
||||||
|
iconBg: 'from-purple-500 to-indigo-600'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect color based on title
|
||||||
|
function detectColor(title: string, icon?: string): 'light' | 'gas' | 'soil' | 'humidity' | 'temperature' | 'data' {
|
||||||
|
const lowerTitle = title.toLowerCase()
|
||||||
|
if (lowerTitle.includes('نور') || icon === '💡') return 'light'
|
||||||
|
if (lowerTitle.includes('گاز') || icon === '🫁') return 'gas'
|
||||||
|
if (lowerTitle.includes('خاک') || icon === '🌱') return 'soil'
|
||||||
|
if (lowerTitle.includes('رطوبت') || icon === '💧') return 'humidity'
|
||||||
|
if (lowerTitle.includes('دما') || icon === '🌡️') return 'temperature'
|
||||||
|
if (lowerTitle.includes('داده') || icon === '📊') return 'data'
|
||||||
|
return 'data' // default
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardGrid({ children }: { children: React.ReactNode }) {
|
export function DashboardGrid({ children }: { children: React.ReactNode }) {
|
||||||
return <div className="grid gap-4 md:grid-cols-3 xl:grid-cols-6">{children}</div>
|
return <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">{children}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ title, value, subtitle,icon }: CardProps) {
|
export function Card({ title, value, subtitle, icon, color }: CardProps) {
|
||||||
|
const IconComponent = icon && iconMap[icon] ? iconMap[icon] : null
|
||||||
|
const cardColor = color || detectColor(title, icon)
|
||||||
|
const colors = colorConfig[cardColor]
|
||||||
|
|
||||||
|
// Extract max and min from subtitle if available
|
||||||
|
const hasStats = subtitle?.includes('حداکثر') && subtitle?.includes('حداقل')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-300 text-center bg-white p-4 shadow-sm">
|
<div className={`group relative bg-white rounded-xl border-2 ${colors.border} shadow-md hover:shadow-lg transition-all duration-300 overflow-hidden`}>
|
||||||
<div className="text-xs text-gray-500 mb-2">{icon} {title}</div>
|
<div className="p-5">
|
||||||
<div className="text-2xl font-semibold">{value}</div>
|
<div className="flex items-center justify-between mb-3">
|
||||||
{subtitle && <div className="text-[11px] text-gray-400 mt-1">{subtitle}</div>}
|
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
{title.replace(/[💡🫁🌱💧🌡️📊]/g, '').trim()}
|
||||||
|
</div>
|
||||||
|
{IconComponent && (
|
||||||
|
<div className={`w-8 h-8 bg-gradient-to-br ${colors.iconBg} rounded-lg flex items-center justify-center shadow-sm`}>
|
||||||
|
<IconComponent className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-gray-900 mb-2">{value}</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div className="text-xs text-gray-500 flex items-center gap-2 flex-wrap">
|
||||||
|
{hasStats ? (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-3 h-3 text-green-600" />
|
||||||
|
{subtitle.match(/حداکثر:\s*([0-9.]+)/)?.[1]}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<TrendingDown className="w-3 h-3 text-red-600" />
|
||||||
|
{subtitle.match(/حداقل:\s*([0-9.]+)/)?.[1]}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
subtitle
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r ${colors.gradient} transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300`} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/components/Loading.tsx
Normal file
27
src/components/Loading.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
type LoadingProps = {
|
||||||
|
message?: string
|
||||||
|
fullScreen?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Loading({ message = 'در حال بارگذاری...', fullScreen = true }: LoadingProps) {
|
||||||
|
const containerClass = fullScreen
|
||||||
|
? 'min-h-screen flex items-center justify-center p-4'
|
||||||
|
: 'flex items-center justify-center p-8'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClass}>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<Loader2 className="w-12 h-12 text-green-600 animate-spin mx-auto mb-4" />
|
||||||
|
<div className="absolute inset-0 w-12 h-12 border-4 border-green-200 border-t-green-600 rounded-full animate-spin mx-auto opacity-50" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 font-medium">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
47
src/components/ServiceWorkerRegistration.tsx
Normal file
47
src/components/ServiceWorkerRegistration.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ServiceWorkerRegistration() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
'serviceWorker' in navigator
|
||||||
|
) {
|
||||||
|
const registerServiceWorker = async () => {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||||
|
scope: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Service Worker registered successfully:', registration.scope);
|
||||||
|
|
||||||
|
// Handle updates
|
||||||
|
registration.addEventListener('updatefound', () => {
|
||||||
|
const newWorker = registration.installing;
|
||||||
|
if (newWorker) {
|
||||||
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
// New service worker available
|
||||||
|
console.log('New service worker available');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Service Worker registration failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register immediately if page is already loaded
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
registerServiceWorker();
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', registerServiceWorker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user