feat: add GreenHome web frontend and weather dashboard
This commit is contained in:
154
src/GreenHome.Api/Controllers/WeatherController.cs
Normal file
154
src/GreenHome.Api/Controllers/WeatherController.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace GreenHome.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Proxy for external weather services (Open-Meteo / Nominatim) with tiered caching:
|
||||
/// - current conditions : 30 minutes
|
||||
/// - forecast : 6 hours
|
||||
/// - historical archive : 30 days (historical data never changes)
|
||||
/// - reverse geocoding : 30 days (location name rarely changes)
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class WeatherController : ControllerBase
|
||||
{
|
||||
private const double DefaultLatitude = 34.39674800; // Kahak Qom
|
||||
private const double DefaultLongitude = 50.86594800; // Kahak Qom
|
||||
|
||||
private static readonly TimeSpan CurrentTtl = TimeSpan.FromMinutes(30);
|
||||
private static readonly TimeSpan ForecastTtl = TimeSpan.FromHours(6);
|
||||
private static readonly TimeSpan HistoricalTtl = TimeSpan.FromDays(30);
|
||||
private static readonly TimeSpan LocationTtl = TimeSpan.FromDays(30);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<WeatherController> _logger;
|
||||
|
||||
public WeatherController(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache,
|
||||
ILogger<WeatherController> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Current conditions only (temperature, humidity, wind, weather code). Cached 30 minutes.</summary>
|
||||
[HttpGet("current")]
|
||||
public Task<IActionResult> Current(
|
||||
[FromQuery] double latitude = DefaultLatitude,
|
||||
[FromQuery] double longitude = DefaultLongitude,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string query = "current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=Asia/Tehran";
|
||||
return GetOrAddAsync(
|
||||
$"weather:current:{latitude}:{longitude}",
|
||||
"https://api.open-meteo.com/v1/forecast",
|
||||
$"latitude={latitude}&longitude={longitude}&{query}",
|
||||
CurrentTtl,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Hourly + daily forecast (no current, which is fetched separately). Cached 6 hours.</summary>
|
||||
[HttpGet("forecast")]
|
||||
public Task<IActionResult> Forecast(
|
||||
[FromQuery] double latitude = DefaultLatitude,
|
||||
[FromQuery] double longitude = DefaultLongitude,
|
||||
[FromQuery] int forecastDays = 7,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string query = "hourly=temperature_2m,relative_humidity_2m,weather_code,precipitation"
|
||||
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunshine_duration,wind_speed_10m_max"
|
||||
+ "&timezone=Asia/Tehran";
|
||||
|
||||
return GetOrAddAsync(
|
||||
$"weather:forecast:{latitude}:{longitude}:{forecastDays}",
|
||||
"https://api.open-meteo.com/v1/forecast",
|
||||
$"latitude={latitude}&longitude={longitude}&{query}&forecast_days={forecastDays}",
|
||||
ForecastTtl,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Historical archive data for a past date range. Cached 30 days.</summary>
|
||||
[HttpGet("historical")]
|
||||
public Task<IActionResult> Historical(
|
||||
[FromQuery] double latitude = DefaultLatitude,
|
||||
[FromQuery] double longitude = DefaultLongitude,
|
||||
[FromQuery] string? start_date = null,
|
||||
[FromQuery] string? end_date = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string query = "daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunshine_duration&timezone=Asia/Tehran";
|
||||
|
||||
var cacheKey = $"weather:historical:{latitude}:{longitude}:{start_date}:{end_date}";
|
||||
var urlQuery = $"latitude={latitude}&longitude={longitude}&start_date={start_date}&end_date={end_date}&{query}";
|
||||
|
||||
return GetOrAddAsync(
|
||||
cacheKey,
|
||||
"https://archive-api.open-meteo.com/v1/archive",
|
||||
urlQuery,
|
||||
HistoricalTtl,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Reverse geocoding (coordinates -> location name). Cached 30 days.</summary>
|
||||
[HttpGet("location")]
|
||||
public Task<IActionResult> Location(
|
||||
[FromQuery] double latitude = DefaultLatitude,
|
||||
[FromQuery] double longitude = DefaultLongitude,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = $"weather:location:{latitude}:{longitude}";
|
||||
var url = $"https://nominatim.openstreetmap.org/reverse?format=json&lat={latitude}&lon={longitude}&zoom=10&addressdetails=1&accept-language=fa,en";
|
||||
|
||||
return GetOrAddAsync(
|
||||
cacheKey,
|
||||
url,
|
||||
null,
|
||||
LocationTtl,
|
||||
cancellationToken,
|
||||
userAgent: "GreenHome/1.0 (greenhouse weather proxy)");
|
||||
}
|
||||
|
||||
private async Task<IActionResult> GetOrAddAsync(
|
||||
string cacheKey,
|
||||
string baseUrl,
|
||||
string? queryString,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken,
|
||||
string? userAgent = null)
|
||||
{
|
||||
if (_cache.TryGetValue(cacheKey, out string? cachedJson))
|
||||
{
|
||||
return Content(cachedJson!, "application/json");
|
||||
}
|
||||
|
||||
var url = string.IsNullOrEmpty(queryString) ? baseUrl : $"{baseUrl}?{queryString}";
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
if (!string.IsNullOrEmpty(userAgent))
|
||||
{
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
}
|
||||
|
||||
var response = await client.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Upstream weather service returned {StatusCode} for {Url}",
|
||||
(int)response.StatusCode,
|
||||
url);
|
||||
return StatusCode((int)response.StatusCode, "Upstream weather service error");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_cache.Set(cacheKey, json, ttl);
|
||||
|
||||
return Content(json, "application/json");
|
||||
}
|
||||
}
|
||||
@@ -28,4 +28,12 @@
|
||||
<ProjectReference Include="..\GreenHome.AI.DeepSeek\GreenHome.AI.DeepSeek.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="BuildFrontend" BeforeTargets="Build" Condition="'$(Configuration)' == 'Release' And Exists('..\GreenHome.Web\package.json')">
|
||||
<Exec Command="npm.cmd run build" WorkingDirectory="..\GreenHome.Web" />
|
||||
<ItemGroup>
|
||||
<FrontendDist Include="..\GreenHome.Web\dist\**\*" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(FrontendDist)" DestinationFiles="@(FrontendDist->'wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -31,6 +31,9 @@ ConfigureMiddleware(app);
|
||||
ConfigureStaticFiles(app);
|
||||
app.MapControllers();
|
||||
|
||||
// SPA fallback: serve index.html for non-API routes (client-side routing)
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
app.Run();
|
||||
|
||||
// ============================================================
|
||||
@@ -43,6 +46,10 @@ void ConfigureCoreServices(WebApplicationBuilder builder)
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// Caching & HTTP client factory (used by WeatherController proxy with tiered caching)
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
builder.Services.AddAutoMapper(typeof(GreenHome.Application.MappingProfile));
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<GreenHome.Application.DeviceDtoValidator>();
|
||||
|
||||
@@ -146,4 +153,8 @@ void ConfigureStaticFiles(WebApplication app)
|
||||
RequestPath = "/files",
|
||||
ContentTypeProvider = provider
|
||||
});
|
||||
|
||||
// Serve the SPA from wwwroot (Vite build output)
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
}
|
||||
|
||||
@@ -18,5 +18,9 @@
|
||||
"DefaultTemperature": 1.0
|
||||
},
|
||||
|
||||
"Auth": {
|
||||
"TokenSecret": "CHANGE_ME_USE_A_STRONG_RANDOM_SECRET_AT_LEAST_32_CHARS"
|
||||
},
|
||||
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
19
src/GreenHome.Api/wwwroot/assets/AnalysisTab-BlSU02rO.js
Normal file
19
src/GreenHome.Api/wwwroot/assets/AnalysisTab-BlSU02rO.js
Normal file
File diff suppressed because one or more lines are too long
1
src/GreenHome.Api/wwwroot/assets/WeatherTab-CUfVpZFI.js
Normal file
1
src/GreenHome.Api/wwwroot/assets/WeatherTab-CUfVpZFI.js
Normal file
@@ -0,0 +1 @@
|
||||
import{j as s,T as g,P as N,S as b,W as v,r,i as m,f as T,a as W,b as E,Q as S,c as w,L as C,E as L,B as M,R as H,M as P,d as k}from"./index-DukGkLqQ.js";function O({weatherData:e,selectedDate:a}){return s.jsx("div",{className:"space-y-6",children:s.jsxs("div",{className:"bg-white rounded-lg border border-gray-200 overflow-hidden",children:[s.jsx("div",{className:"bg-blue-600 p-5",children:s.jsxs("div",{className:"text-center text-white",children:[s.jsx("p",{className:"text-sm opacity-90 mb-1",children:"📅 وضعیت آب و هوای روز"}),s.jsx("p",{className:"text-xl font-bold",children:a})]})}),s.jsxs("div",{className:"p-4 grid grid-cols-2 gap-3",children:[s.jsx(g,{weatherData:e,isToday:!1}),s.jsx(N,{weatherData:e}),s.jsx(b,{weatherData:e}),s.jsx(v,{weatherData:e})]})]})})}function R({selectedDate:e}){const[a,t]=r.useState(null),[h,o]=r.useState(!1),[c,n]=r.useState(null),[f,d]=r.useState(null),[j,i]=r.useState("در حال دریافت..."),x=r.useCallback(async()=>{if(!a){o(!0),n(null);try{const l=m(e),[p,y]=await Promise.all([l?T():W(e),E(S,w)]);t(p),i(y)}catch(l){console.error("Error loading weather:",l),n("خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید."),i("کهک قم، ایران")}finally{o(!1)}}},[e,a]);if(r.useEffect(()=>{t(null),n(null),d(null),i("در حال دریافت...")},[e]),h)return s.jsx(C,{message:"در حال دریافت اطلاعات آب و هوا...",fullScreen:!1});if(c)return s.jsx(L,{message:c,action:s.jsx(M,{onClick:()=>{t(null),x()},variant:"primary",icon:H,children:"تلاش مجدد"})});if(!a)return x(),null;const u=m(e);return s.jsxs("div",{className:"space-y-6",children:[s.jsxs("div",{className:"flex items-center justify-between",children:[s.jsxs("div",{className:"flex items-center gap-2 text-gray-600",children:[s.jsx(P,{className:"w-5 h-5 text-sky-500"}),s.jsx("span",{className:"font-medium",children:j})]}),s.jsx("span",{className:"text-xs text-gray-400",children:u?"پیشبینی هواشناسی برای مدیریت گلخانه":"وضعیت آب و هوای روز انتخاب شده"})]}),u?s.jsx(k,{weatherData:a,expandedDayIndex:f,onDayToggle:d}):s.jsx(O,{weatherData:a,selectedDate:e})]})}export{R as WeatherTab};
|
||||
1
src/GreenHome.Api/wwwroot/assets/index-8ZWM35nx.css
Normal file
1
src/GreenHome.Api/wwwroot/assets/index-8ZWM35nx.css
Normal file
File diff suppressed because one or more lines are too long
357
src/GreenHome.Api/wwwroot/assets/index-DukGkLqQ.js
Normal file
357
src/GreenHome.Api/wwwroot/assets/index-DukGkLqQ.js
Normal file
File diff suppressed because one or more lines are too long
1
src/GreenHome.Api/wwwroot/file.svg
Normal file
1
src/GreenHome.Api/wwwroot/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Black.woff2
Normal file
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Black.woff2
Normal file
Binary file not shown.
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Bold.woff2
Normal file
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Bold.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Light.woff2
Normal file
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Light.woff2
Normal file
Binary file not shown.
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Medium.woff2
Normal file
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Medium.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Thin.woff2
Normal file
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Thin.woff2
Normal file
Binary file not shown.
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn[wght].woff2
Normal file
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn[wght].woff2
Normal file
Binary file not shown.
72
src/GreenHome.Api/wwwroot/fonts/vazirmatn/style.css
Normal file
72
src/GreenHome.Api/wwwroot/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
src/GreenHome.Api/wwwroot/globe.svg
Normal file
1
src/GreenHome.Api/wwwroot/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
src/GreenHome.Api/wwwroot/icon-192.png
Normal file
1
src/GreenHome.Api/wwwroot/icon-192.png
Normal file
@@ -0,0 +1 @@
|
||||
PNG_PLACEHOLDER_192
|
||||
1
src/GreenHome.Api/wwwroot/icon-512.png
Normal file
1
src/GreenHome.Api/wwwroot/icon-512.png
Normal file
@@ -0,0 +1 @@
|
||||
PNG_PLACEHOLDER_512
|
||||
21
src/GreenHome.Api/wwwroot/index.html
Normal file
21
src/GreenHome.Api/wwwroot/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<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" />
|
||||
<title>GreenHome</title>
|
||||
<script type="module" crossorigin src="/assets/index-DukGkLqQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-8ZWM35nx.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
0
src/GreenHome.Api/wwwroot/manifest.json
Normal file
0
src/GreenHome.Api/wwwroot/manifest.json
Normal file
1
src/GreenHome.Api/wwwroot/next.svg
Normal file
1
src/GreenHome.Api/wwwroot/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
193
src/GreenHome.Api/wwwroot/sw.js
Normal file
193
src/GreenHome.Api/wwwroot/sw.js
Normal file
@@ -0,0 +1,193 @@
|
||||
const CACHE_NAME = 'greenhome-1780331120215';
|
||||
const STATIC_CACHE_NAME = 'greenhome-static-1780331120215';
|
||||
|
||||
// Static assets to cache on install
|
||||
const STATIC_FILES_TO_CACHE = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
];
|
||||
|
||||
// Check if request is for static assets
|
||||
function isStaticAsset(url) {
|
||||
return (
|
||||
url.includes('/_next/static/') ||
|
||||
url.includes('/_next/image') ||
|
||||
url.match(/\.(js|css|woff|woff2|ttf|eot|svg|png|jpg|jpeg|gif|webp|ico)$/i)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if request is for external API (skip caching)
|
||||
function isExternalApiRequest(url) {
|
||||
return url.startsWith('http') && !url.startsWith(self.location.origin);
|
||||
}
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cache = await caches.open(STATIC_CACHE_NAME);
|
||||
try {
|
||||
await cache.addAll(STATIC_FILES_TO_CACHE);
|
||||
} catch (error) {
|
||||
console.log('Some static files failed to cache:', error);
|
||||
}
|
||||
// Don't skip waiting automatically - let user decide when to update
|
||||
// await self.skipWaiting();
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
await self.clients.claim();
|
||||
|
||||
// Remove old caches
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME && key !== STATIC_CACHE_NAME)
|
||||
.map((key) => caches.delete(key))
|
||||
);
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
// Listen for messages from clients (e.g., when user clicks update button)
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting().then(() => {
|
||||
// Notify all clients about update
|
||||
return self.clients.matchAll();
|
||||
}).then((clients) => {
|
||||
clients.forEach((client) => {
|
||||
client.postMessage({ type: 'SW_UPDATED' });
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests and external API requests
|
||||
if (request.method !== 'GET' || isExternalApiRequest(url.href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle navigation requests (pages)
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
// Try network first
|
||||
const preloadResponse = await event.preloadResponse;
|
||||
if (preloadResponse) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, preloadResponse.clone());
|
||||
return preloadResponse;
|
||||
}
|
||||
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
// Cache successful responses
|
||||
if (networkResponse.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
// Network failed, try cache
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const cachedResponse = await cache.match(request);
|
||||
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Fallback to home page if available
|
||||
const homePage = await cache.match('/');
|
||||
if (homePage) {
|
||||
return homePage;
|
||||
}
|
||||
|
||||
// Last resort: return offline page
|
||||
return new Response('Offline - No internet connection', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
}
|
||||
})()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle static assets (Cache First strategy)
|
||||
if (isStaticAsset(url.href)) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(STATIC_CACHE_NAME);
|
||||
const cachedResponse = await cache.match(request);
|
||||
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
// Cache successful responses
|
||||
if (networkResponse.ok) {
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
// If network fails and no cache, return error
|
||||
return new Response('Resource not available offline', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
});
|
||||
}
|
||||
})()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle other requests (Network First strategy)
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
|
||||
try {
|
||||
// Try network first
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
// Cache successful responses
|
||||
if (networkResponse.ok) {
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
// Network failed, try cache
|
||||
const cachedResponse = await cache.match(request);
|
||||
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Return error if no cache available
|
||||
return new Response('Resource not available offline', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
});
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
1
src/GreenHome.Api/wwwroot/vercel.svg
Normal file
1
src/GreenHome.Api/wwwroot/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
3
src/GreenHome.Api/wwwroot/version.json
Normal file
3
src/GreenHome.Api/wwwroot/version.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": "1780331120215"
|
||||
}
|
||||
1
src/GreenHome.Api/wwwroot/window.svg
Normal file
1
src/GreenHome.Api/wwwroot/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
1
src/GreenHome.Api/wwwroot/workbox-00a24876.js
Normal file
1
src/GreenHome.Api/wwwroot/workbox-00a24876.js
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user