feat: add GreenHome web frontend and weather dashboard

This commit is contained in:
2026-09-03 07:48:56 +03:30
parent 434f1dcba3
commit 9eaa5b70d1
220 changed files with 16895 additions and 0 deletions

View 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");
}
}

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -18,5 +18,9 @@
"DefaultTemperature": 1.0
},
"Auth": {
"TokenSecret": "CHANGE_ME_USE_A_STRONG_RANDOM_SECRET_AT_LEAST_32_CHARS"
},
"AllowedHosts": "*"
}

File diff suppressed because one or more lines are too long

View 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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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

View 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;
}

View 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

View File

@@ -0,0 +1 @@
PNG_PLACEHOLDER_192

View File

@@ -0,0 +1 @@
PNG_PLACEHOLDER_512

View 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>

View File

View 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

View 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',
});
}
})()
);
});

View 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

View File

@@ -0,0 +1,3 @@
{
"version": "1780331120215"
}

View 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

File diff suppressed because one or more lines are too long