New and improved in sveltekit

This commit is contained in:
2023-05-22 22:29:43 +02:00
parent 4c5ede45a1
commit 23d4b727e6
78 changed files with 6148 additions and 648 deletions

View File

@@ -3,11 +3,17 @@
<head>
<meta charset="utf-8" />
<meta name="description" content="" />
<link rel="icon" href="%svelte.assets%/favicon.png" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="stylesheet" type="text/css" href="/global.css" />
<link rel="stylesheet" type="text/css" href="/variables.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%svelte.head%
%sveltekit.head%
</head>
<body>
<div id="svelte">%svelte.body%</div>
<body data-sveltekit-prefetch>
<div>%sveltekit.body%</div>
</body>
</html>
<style lang="scss"></style>

91
src/brews.json Normal file
View File

@@ -0,0 +1,91 @@
[{
"beer": {
"name": "Kveldsbris",
"brewery": "Kinn Bryggeri",
"category": "Pilsner/Lys Lager",
"description": ""
},
"date": "1682272800",
"by": ["Alf", "Kevin"],
"abv": "5.6",
"description": "",
"image": "kinn_kveldsbris.png",
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
"order_page": "https://oslo.bryggselv.no/finest/104923/finest-originals-utepils-allgrain-ølsett-25-liter",
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
}, {
"beer": {
"name": "FUCK YEAH IPA",
"brewery": "Finest",
"category": "American IPA",
"description": ""
},
"date": "1648922400",
"by": ["Alf", "Kevin"],
"abv": "7",
"description": "",
"image": "finest_fuck-yeah-IPA.jpg",
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
"order_page": "https://web.archive.org/web/20210225043236/https://www.bryggselv.no/finest/105943/fuck-yeah-ipa-ultra-american-west-coast-ipa-25-liter",
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
}, {
"beer": {
"name": "Love in a canoe",
"brewery": "Finest",
"category": "Mexican Lager",
"description": ""
},
"date": "1646420400",
"by": ["Alf", "Kevin"],
"abv": "4.7",
"description": "",
"image": "finest_love-in-a-canoe.jpeg",
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
"order_page": "https://oslo.bryggselv.no/finest/104092/love-in-a-canoe-allgrain-ølsett-25-liter",
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
}, {
"beer": {
"name": "Utepils",
"brewery": "Finest",
"category": "",
"description": ""
},
"date": "1637694000",
"by": ["Alf", "Kevin"],
"abv": "5.0",
"description": "",
"image": "finest_utepils.jpeg",
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
"order_page": "https://www.bryggselv.no/finest/105932/kinn-kveldsbris-allgrain-ølsett-25-liter",
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
}, {
"beer": {
"name": "HELLES Tysk Lager",
"brewery": "Münchener Helles",
"category": "Tysk Lager",
"description": ""
},
"date": "1629396000",
"by": ["Adrian", "Kevin", "Mats"],
"abv": "5.3",
"description": "",
"image": "helles_tysk-lager.jpeg",
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
"order_page": "https://oslo.bryggselv.no/finest/106231/finest-helles-allgrain-ølsett-25-liter",
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
}, {
"beer": {
"name": "Lazy Days Weiss",
"brewery": "Finest",
"category": "Weissbier",
"description": ""
},
"date": "1621706400",
"by": ["Alf", "Kevin", "Kristian"],
"abv": "5.3",
"description": "",
"image": "finest_lazy-days.jpeg",
"recipe": "https://docs.google.com/document/u/0/d/1I6qX4l4jDzK51GxBt3IdEv-HyNQHAx8ijc5dMlG1Xkk",
"order_page": "https://oslo.bryggselv.no/finest/106231/finest-helles-allgrain-ølsett-25-liter",
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
}]

7
src/global.d.ts vendored
View File

@@ -1 +1,6 @@
/// <reference types="@sveltejs/kit" />
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
string;
}

View File

@@ -0,0 +1,25 @@
<div class="button-circle">
<slot></slot>
</div>
<style lang="scss" module="scoped">
.button-circle {
position: absolute;
top: 1rem;
right: 1rem;
height: 40px;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
background-color: var(--backdrop);
padding: 0.5rem;
border-radius: 50%;
transition: all 0.25s ease-in-out;
&:hover {
transition: scale(1.15);
scale: 1.15;
}
}
</style>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { onMount } from 'svelte'
// import { setTheme, theme } from '../themeStore'
import type { Theme } from '../types'
function toggleDarkmode() {
// setTheme(nextTheme);
// document.body.className = $theme
}
function systemDarkModeEnabled() {
const computedStyle = window.getComputedStyle(document.body);
if (computedStyle['colorScheme'] != null) {
return computedStyle.colorScheme.includes('dark');
}
return false;
}
$: icon = true ? '🌝' : '🌚';
// $: icon = $theme === 'dark' ? '🌝' : '🌚';
// $: nextTheme = ($theme === 'dark' ? 'light' : 'dark') as Theme
// onMount(() => document.body.className = $theme)
</script>
<div class="darkToggle">
<span on:click={() => toggleDarkmode()}>{icon}</span>
</div>
<style lang="scss" module="scoped">
.darkToggle {
height: 25px;
width: 25px;
cursor: pointer;
// background-color: red;
position: fixed;
bottom: 1rem;
right: 2rem;
z-index: 10;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>

View File

@@ -0,0 +1,199 @@
<script lang="ts">
import { onMount, afterUpdate } from 'svelte';
import {
Chart,
LineElement,
LineController,
CategoryScale,
LinearScale,
PointElement,
Title,
Legend
} from 'chart.js';
import type { ChartDataset } from 'chart.js';
import type IChartFrame from '../interfaces/IChartFrame';
Chart.register(
LineElement,
LineController,
CategoryScale,
LinearScale,
PointElement,
Title,
Legend
);
export let name: string;
export let dataFrames: IChartFrame[];
export let beginAtZero: boolean = true;
let chartCanvas: HTMLCanvasElement;
let chart: Chart;
let prevData: any = {};
interface IDataset {
labels: string[];
data?: ChartDataset<'line', number[]>;
}
interface ITemperatureDataset extends IDataset {
inside: ChartDataset<'line', number[]>;
outside?: ChartDataset<'line', number[]>;
}
interface IHumidityDataset extends IDataset {}
interface IPressureDataset extends IDataset {}
function pad(num) {
if (num < 10) {
return `0${num}`;
}
return num;
}
function prettierDateString(date) {
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${pad(date.getYear() - 100)}`;
}
function computeTemperatureDataset(): ITemperatureDataset {
const labels: string[] = dataFrames.map(
(frame) => prettierDateString(new Date(frame.key)) || String(frame.key_as_string)
);
const data: number[] = dataFrames.map((frame) => frame.value);
return {
labels,
inside: {
label: '℃ inside',
borderColor: '#10e783',
backgroundColor: '#c8f9df',
lineTension: 0.5,
borderWidth: 3,
data
}
};
}
function computeHumidityDataset(): IHumidityDataset {
const labels: string[] = dataFrames.map(
(frame) => prettierDateString(new Date(frame.key)) || String(frame.key_as_string)
);
const data: number[] = dataFrames.map((frame) => frame.value);
return {
labels,
data: {
label: '% humidity',
borderColor: '#57d2fb',
backgroundColor: '#d4f2fe',
lineTension: 0.5,
borderWidth: 3,
data
}
};
}
function computePressureDataset(): IPressureDataset {
const labels: string[] = dataFrames.map(
(frame) => prettierDateString(new Date(frame.key)) || String(frame.key_as_string)
);
const data: number[] = dataFrames.map((frame) => frame.value);
return {
labels,
data: {
label: 'Bar of pressure',
borderColor: '#ef5878',
backgroundColor: '#fbd7de',
lineTension: 0.5,
borderWidth: 3,
data
}
};
}
function renderChart() {
const context: CanvasRenderingContext2D = chartCanvas.getContext('2d');
let dataset: IDataset | ITemperatureDataset | IHumidityDataset | IPressureDataset;
if (name === 'Temperature') dataset = computeTemperatureDataset();
else if (name === 'Humidity') dataset = computeHumidityDataset();
else if (name === 'Pressure') dataset = computePressureDataset();
chart = new Chart(context, {
type: 'line',
data: {
labels: dataset.labels,
datasets: [dataset?.inside || dataset.data]
},
options: {
elements: {
point: {
radius: 1
}
},
maintainAspectRatio: false,
plugins: {
title: {
display: true,
position: 'left',
text: `${name} over time`,
font: {
size: 20
}
},
legend: {
display: true,
usePointStyle: true,
borderRadius: 10,
labels: {
padding: 12,
boxWidth: 20,
usePointStyle: true
}
},
zoom: {
zoom: {
wheel: {
enabled: true,
speed: 0.001
},
// pinch: {
// enabled: true
// },
mode: 'xy'
}
}
},
scales: {
y: {
beginAtZero: false,
offset: true,
ticks: {
color: 'black'
},
grid: {
color: 'rgba(0,0,0,0.06)'
}
},
x: {
grid: {
color: 'rgba(0,0,0,0.06)'
}
}
}
}
});
chart.update();
}
onMount(() => renderChart());
afterUpdate(() => {
console.log('after update run');
chart.destroy();
renderChart();
});
</script>
<canvas id="{name}" bind:this="{chartCanvas}" width="400" height="400"></canvas>

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import Navigation from './Navigation.svelte';
import GithubIcon from '../icons/Github.svelte';
let open: boolean = false;
function toggleMenu() {
open = !open;
}
function close() {
open = false;
}
$: headerText = !open ? 'Menu' : 'Close';
</script>
{#if open}
<div class="slideout-menu" transition:fly="{{ x: 550, duration: 300 }}">
<h1>Navigation</h1>
<Navigation on:click={close} />
<ul class="bottom-content">
<li>
<a href="https://github.com/kevinmidboe/brewpi">
<GithubIcon />
<span class="meta">View on Github</span>
</a>
</li>
</ul>
</div>
{/if}
<header>
<div on:click={toggleMenu} class:open aria-label="Open menu" class="menu">
{#if !open}
<span class="page-header-buttons__open">
<span /> <span /> <span />
</span>
{:else}
<span class="page-header-buttons__close">
<span />
<span />
</span>
{/if}
<span class="page-header-text">{headerText}</span>
</div>
</header>
<style lang="scss" module="scoped">
.slideout-menu {
position: fixed;
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
max-width: 550px;
right: 0;
top: 0;
z-index: 1;
background-color: #fff3f6;
color: black;
padding: calc(100px + 2rem) 2rem 1rem;
border-top-left-radius: 4rem;
h1 {
padding-bottom: 4rem;
}
@media screen and (max-width: 640px) {
padding: 100px 2rem 2rem;
}
.bottom-content {
margin-top: auto;
li, li a {
display: flex;
align-items: center;
.meta {
margin-left: 1rem;
}
}
li:not(:last-of-type) {
margin-bottom: 1rem;
}
}
}
header {
top: 0;
right: 0;
display: flex;
position: fixed;
z-index: 10;
padding: 0 1rem;
justify-content: flex-end;
align-items: center;
width: 100%;
height: var(--header-height);
background-color: transparent;
pointer-events: none;
.menu {
display: flex;
place-items: center;
background-color: var(--green);
color: #fff3f6;
padding: 14px 20px;
border-radius: 25px;
-webkit-transition: all 0.3s ease;
transition: all 0.3s ease;
cursor: pointer;
pointer-events: auto;
-webkit-user-select: none;
user-select: none;
&.open {
background-color: salmon;
color: black;
}
&:hover {
transform: scale(1.04);
}
}
.page-header-text {
padding-left: 11px;
display: inline-block;
}
.page-header-buttons__open {
position: relative;
display: inline-block;
width: 24px;
height: 24px;
span {
display: block;
width: 22px;
height: 2px;
background: currentColor;
position: absolute;
left: 1px;
&:first-child {
top: 4px;
}
&:nth-child(2) {
top: 11px;
}
&:nth-child(3) {
top: 18px;
}
}
}
.page-header-buttons__close {
position: relative;
display: grid;
width: 24px;
height: 24px;
place-items: center;
span {
display: block;
width: 22px;
height: 2px;
background: currentColor;
position: absolute;
left: 1px;
&:first-child {
transform: rotate(-45deg);
}
&:nth-child(2) {
transform: rotate(45deg);
}
}
}
}
</style>

View File

@@ -0,0 +1,52 @@
<div class="livestream-container">
<img src="https://i.imgur.com/T4fCMI5.png" alt="livestream" />
<div class="pulse"></div>
</div>
<style lang="scss" module="scoped">
.livestream-container {
position: relative;
border-radius: 1rem;
border: 0.5rem solid var(--background);
transition: border-color var(--color-transition-duration) ease-in-out;
display: inline-block;
height: fit-content;
img {
border-radius: 0.5rem;
width: 100%;
max-width: 860px;
}
.pulse {
position: absolute;
top: 1.2rem;
left: 1.2rem;
border-radius: 50%;
background-color: rgba(255, 82, 82, 1);
box-shadow: 0 0 0 0 rgba(255, 82, 82, 1);
height: 1.7rem;
width: 1.7rem;
transform: scale(1);
animation: pulse-red 2s infinite;
@keyframes pulse-red {
0% {
transform: scale(0.9);
box-shadow: 0 0 0 0 rgba(255, 82, 82, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 12px rgba(255, 82, 82, 0);
}
100% {
transform: scale(0.9);
box-shadow: 0 0 0 0 rgba(255, 82, 82, 0);
}
}
}
}
</style>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import ArrowRight from "../icons/ArrowRight.svelte";
interface IRoute {
name: string
path: string
}
const routes: Array<IRoute> = [{
name: 'Home',
path: '/'
}, {
name: 'Past brews',
path: '/brews'
}, {
name: 'Graphs',
path: '/graphs'
}]
</script>
<ul class="navigation-cards" on:click>
{#each routes as route}
<a href={route.path}>
<li>
<span>{ route.name }</span>
<ArrowRight />
</li>
</a>
{/each}
</ul>
<style lang="scss" module="scoped">
.navigation-cards a {
display: block;
border-radius: 2rem;
background: var(--green);
transition: background-color var(--color-transition-duration) ease-in-out, transform 0.2s ease;
&:hover {
transform: scale(1.04);
}
margin: 1rem 0;
&:first-of-type {
margin: 0;
}
li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.75rem;
color: white;
font-size: 1.3rem;
}
}
</style>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import Navigation from './Navigation.svelte'
</script>
<div class="front-menu">
<Navigation />
</div>
<style lang="scss">
.front-menu {
display: flex;
flex-wrap: wrap;
width: 100%;
}
</style>

View File

@@ -0,0 +1,27 @@
<div class="page-header">
<h1>Schleppe Brew</h1>
<span class="subtitle">Monitor beer brewing refridgerator</span>
</div>
<style lang="scss" module="scoped">
@import '../../styles/media-queries.scss';
.page-header {
width: 100%;
text-align: center;
font-family: 'Overpass';
}
h1 {
font-size: 3rem;
margin-bottom: 0.2rem;
@include tablet {
font-size: 5rem;
}
}
.subtitle {
font-size: 2rem;
}
</style>

View File

@@ -0,0 +1,90 @@
<script lang="ts">
// import { toggleRelay } from '$lib/server/relayToggle'
import Switch from './Switch.svelte';
export let relays = [];
function toggleRelay(location) {
const url = `/api/relay/${location}`;
const options = {
method: 'POST'
};
fetch(url, options)
.then((resp) => resp.json())
.then(console.log);
}
function handleChange(event) {
let isChecked = event.detail.checked;
// Perform any desired actions based on the new value
console.log('New value:', isChecked);
}
</script>
<div class="relays-container">
<h1>Manual relay controls</h1>
<div class="vertical-sensor-display">
{#each relays as relay}
<div>
<h2>{relay.location} relay</h2>
<div class="sensor-reading">
<Switch checked="{relay.state}" on:change="{() => toggleRelay(relay.location)}" />
</div>
</div>
{/each}
</div>
</div>
<style lang="scss" module="scoped">
@import '../../styles/media-queries.scss';
.relays-container {
height: fit-content;
border-radius: 12px;
background-color: var(--background);
transition: background-color var(--color-transition-duration) ease-in-out;
padding: 2.25rem 1rem;
@include tablet {
padding: 2.25rem 3rem;
}
h1 {
margin-top: 0;
}
}
.vertical-sensor-display {
position: relative;
height: fit-content;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
h2 {
font-size: 1.4rem;
margin-bottom: 1.5rem;
font-weight: 400;
color: var(--text-color);
text-transform: capitalize;
@include tablet {
font-size: 1.6rem;
}
}
.sensor-reading {
display: flex;
justify-content: center;
margin-bottom: 1.75rem;
font-size: 2.5rem;
line-height: 1;
font-weight: 500;
color: var(--text-color);
}
</style>

View File

@@ -0,0 +1,177 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let checked = false;
function handleChange() {
dispatch('change', { checked });
}
</script>
<div class="switch-wrapper">
<div class="switch-button-container">
<input class="switch-checkbox" type="checkbox" bind:checked="{checked}" on:change="{handleChange}" >
<div class="switch-button">
<div class="switch-button-top">
<svg class="switch-icon on" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44">
<path d="M19 3h6v38h-6z"/>
</svg>
<svg class="switch-icon off" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
<path d="M44 22C44 34.1503 34.1503 44 22 44C9.84974 44 0 34.1503 0 22C0 9.84974 9.84974 0 22 0C34.1503 0 44 9.84974 44 22ZM5.5 22C5.5 31.1127 12.8873 38.5 22 38.5C31.1127 38.5 38.5 31.1127 38.5 22C38.5 12.8873 31.1127 5.5 22 5.5C12.8873 5.5 5.5 12.8873 5.5 22Z"/>
</svg>
</div>
</div>
</div>
</div>
<style lang="scss">
.switch-wrapper {
border-radius: 3px;
padding: 10px;
width: 100px;
height: 125px;
background-image: linear-gradient(to bottom, #414049, #30282c);
box-shadow:
0 0 1px #050506,
inset 0 0 0 2px #050506,
inset 0 3px 1px #66646c;
}
.switch-button-container {
position: relative;
border-radius: 3px;
padding: 3px 2px;
width: 100%;
height: 100%;
background-color: #000;
box-shadow: 0 0 1px #000;
}
.switch-checkbox {
-webkit-appearance: none;
appearance: none;
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.switch-button {
position: relative;
z-index: 0;
border-radius: 5px 5px 2px 2px / 16px 16px 2px 2px;
width: 100%;
height: 100%;
background-color: #7f070d;
background-image:
linear-gradient(to bottom, rgba(#770505, .6) 40%, rgba(#ff8d93, .6) 60% 75%, rgba(#fff, .9) 90%),
linear-gradient(to bottom, rgba(#710206, .6), rgba(#d12127, .6))
;
background-size: 100% calc(15% + 1px), 100% 0%;
background-position: top, bottom;
background-repeat: no-repeat;
box-shadow:
inset 0 -3px 2px rgba(#000, .4),
inset 0 3px 1px rgba(#000, .8),
inset 1px 0 0 rgba(#691016, .8),
inset -1px 0 0 rgba(#691016, .8),
inset 1px 0 0 rgba(#d6585f, .8),
inset -1px 0 0 rgba(#d6585f, .8);
transition: border-radius .4s, background-size .4s;
overflow: hidden;
.switch-checkbox:checked + & {
border-radius: 2px 2px 5px 5px / 2px 2px 16px 16px;
background-size: 100% 0, 100% 15%;
}
}
.switch-button-top {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
position: absolute;
top: 15%;
width: 100%;
height: 85%;
background-color: rgba(#770505, .8);
background-image: radial-gradient(rgba(#900006, .8) 1px, transparent 0);
background-size: 3px 3px;
background-position: 50%;
box-shadow:
inset 1px 0 0 rgba(#691016, .8),
inset -1px 0 0 rgba(#691016, .8),
inset 1px 0 0 rgba(#d6585f, .8),
inset -1px 0 0 rgba(#d6585f, .8),
inset 0 -1px 0 rgba(#fff, .2),
inset 0 1px 0 rgba(#fff, .9);
transition: all .4s;
.switch-checkbox:checked + .switch-button > & {
top: 0;
background-color: rgba(#e30320, .2);
}
&::before {
content: '';
position: absolute;
z-index: -1;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
transform: translate(-50%, -50%);
background-image: radial-gradient(closest-side at 50% 50%, #ffaf0f 0% 10%, #fc071e 30%, #4b0100);
opacity: 0;
.switch-checkbox:checked + .switch-button > & {
opacity: 1;
animation: flick .2s infinite;
}
}
&::after {
content: '';
position: absolute;
bottom: 0;
width: 100%;
height: 50%;
background-color: rgba(#fdd8d8, .14);
filter: blur(4px);
transition: transform .4s, background-color .4s;
.switch-checkbox:checked + .switch-button > & {
background-color: rgba(#fdd8d8, .2);
transform: translateY(-100%);
}
}
}
.switch-icon {
width: 22px;
height: 22px;
fill: #aa9094;
transition: fill .4s;
.switch-checkbox:checked + .switch-button > .switch-button-top > & {
fill: #f3d5df;
}
}
@keyframes flick {
0% {
opacity: 1;
}
80% {
opacity: .8;
}
100% {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,135 @@
<script lang="ts">
import { onMount } from 'svelte'
import CardButton from "./CardButton.svelte";
import Activity from "../icons/Activity.svelte";
export let inside;
export let outside;
let loadedTime: number = new Date().getTime();
let currentTime: number = new Date().getTime()
function updateTime() {
currentTime = new Date().getTime();
}
function flipCard(): void {
console.log("flip-a-delphia")
}
onMount(() => setInterval(updateTime, 1000));
$: secondsSinceUpdate = Math.floor((currentTime - loadedTime) / 1000)
</script>
<div class="vertical-sensor-display">
<CardButton>
<Activity on:click={flipCard} />
</CardButton>
<h2>Current target temperature</h2>
<div class="sensor-reading">
<div class="red">
<span class="value">16</span>
<span class="unit">°C</span>
</div>
</div>
<h2>Inside temperature</h2>
<div class="sensor-reading">
<div class="blue">
<span class="value">{ inside?.temperature }</span>
<span class="unit">{ inside?.temperature_unit }</span>
</div>
<div>
<span class="value">{ Math.floor(inside?.humidity) }</span>
<span class="unit">{ inside?.humidity_unit || '%' }</span>
</div>
</div>
<h2>Outside temperature</h2>
<div class="sensor-reading">
<div class="blue">
<span class="value">{ outside?.temperature }</span>
<span class="unit">{ outside?.temperature_unit }</span>
</div>
<div>
<span class="value">{ Math.floor(outside?.humidity) }</span>
<span class="unit">{ outside?.humidity_unit }</span>
</div>
</div>
<h2>Pressure</h2>
<div class="sensor-reading">
<span class="value">{ inside?.pressure || 0}</span>
<span class="unit">bar</span>
</div>
<div class="button-timer">
<span>Updated { secondsSinceUpdate === 0 ? 'now' : secondsSinceUpdate + 's ago' }</span>
</div>
</div>
<style lang="scss" module="scoped">
@import '../../styles/media-queries.scss';
.vertical-sensor-display {
position: relative;
padding: 2.25rem 1rem;
border-radius: 12px;
background-color: var(--background);
transition: background-color var(--color-transition-duration) ease-in-out;
@include tablet {
padding: 2.25rem 3rem;
}
}
h2 {
font-size: 1.4rem;
margin-bottom: 1.5rem;
font-weight: 400;
color: var(--text-color);
@include tablet {
font-size: 1.6rem;
}
}
.sensor-reading {
display: flex;
justify-content: space-between;
margin-bottom: 1.75rem;
font-size: 3rem;
line-height: 1;
font-weight: 500;
color: var(--text-color);
@include tablet {
font-size: 4.5rem;
}
.unit {
font-weight: 300;
}
.red {
color: var(--red);
}
.blue {
color: var(--blue);
}
}
.button-timer {
width: 100%;
text-align: right;
color: rgba(0, 0, 0, .5);
}
</style>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
export const title: string = "Temperature"
let temp = 23
</script>
<div>
<header>{ title }</header>
<div class="body">
<p>Inside temperature: {23}</p>
</div>
</div>
<style lang="scss" module="scoped">
.body {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,270 @@
import type IESTelemetry from './interfaces/IESTelemetry';
import type IChartFrame from './interfaces/IChartFrame';
const TELEMETRY_ENDPOINT = 'REPLACE_WITH_ES_HOST/brewlogger-*/_search';
const ES_APIKEY = '***REMOVED***';
function dateToESString(date: Date) {
return date.toISOString();
}
const buildLatestQuery = (place: string) => {
return {
sort: [{ '@timestamp': { order: 'desc', mode: 'max' } }],
query: {
bool: {
filter: [
{
match_all: {}
},
{
match_phrase: {
'location.keyword': place
}
},
{
match_phrase: {
'message.keyword': 'Sensor readings'
}
}
]
}
},
size: 1
};
};
function buildQuery(field: String, from: Date, to: Date, interval: String) {
const fromDateString: string = dateToESString(from);
const toDateString: string = dateToESString(to);
return {
aggs: {
data: {
date_histogram: {
field: '@timestamp',
fixed_interval: interval,
time_zone: 'Europe/Oslo',
min_doc_count: 1
},
aggs: {
maxValue: {
max: {
field: field
}
}
}
}
},
size: 0,
fields: [
{
field: '@timestamp',
format: 'date_time'
}
],
script_fields: {},
stored_fields: ['*'],
_source: {
excludes: []
},
query: {
bool: {
must: [],
filter: [
{
match_all: {}
},
{
match_phrase: {
'location.keyword': 'inside'
}
},
{
range: {
'@timestamp': {
gte: toDateString,
lte: fromDateString,
format: 'strict_date_optional_time'
}
}
}
],
should: [],
must_not: []
}
}
};
}
function roundInterval(interval) {
switch (true) {
case interval <= 500:
return '100ms';
case interval <= 5e3:
return '1s';
case interval <= 7500:
return '5s';
case interval <= 15e3:
return '10s';
case interval <= 45e3:
return '30s';
case interval <= 18e4:
return '1m';
case interval <= 45e4:
return '5m';
case interval <= 12e5:
return '10m';
case interval <= 27e5:
return '30m';
case interval <= 72e5:
return '1h';
case interval <= 216e5:
return '3h';
case interval <= 864e5:
return '12h';
case interval <= 6048e5:
return '24h';
case interval <= 18144e5:
return '3d';
case interval < 36288e5:
return '30d';
default:
return '1y';
}
}
function calculateInterval(from, to, interval, size) {
if (interval !== 'auto') {
return interval;
}
const dateMathInterval = roundInterval((from - to) / size);
// const dateMathIntervalMs = toMS(dateMathInterval);
// const minMs = toMS(min);
// if (dateMathIntervalMs !== undefined && minMs !== undefined && dateMathIntervalMs < minMs) {
// return min;
// }
return dateMathInterval;
}
function parseTempResponse(data: IESTelemetry): IChartFrame[] {
return data?.aggregations?.data?.buckets.map((bucket) => {
return {
value: bucket?.maxValue?.value,
key_as_string: bucket?.key_as_string,
key: bucket?.key
};
});
}
function parseLatestResponse(data: IESTelemetry) {
return data?.hits?.hits[0]?._source;
}
export function fetchTemperature(
from: Date,
to: Date,
size: number = 50,
fetch: Function
): Promise<IChartFrame[]> {
const fromMS = from.getTime();
const toMS = to.getTime();
const interval = calculateInterval(fromMS, toMS, 'auto', size);
const fieldName = 'temperature';
const esSearchQuery = buildQuery(fieldName, from, to, interval);
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `ApiKey ${ES_APIKEY}`
},
body: JSON.stringify(esSearchQuery)
};
return fetch(TELEMETRY_ENDPOINT, options)
.then((resp) => resp.json())
.then(parseTempResponse);
}
export function fetchHumidity(
from: Date,
to: Date,
size: number = 50,
fetch: Function
): Promise<IChartFrame[]> {
const fromMS = from.getTime();
const toMS = to.getTime();
const interval = calculateInterval(fromMS, toMS, 'auto', size);
const fieldName = 'humidity';
const esSearchQuery = buildQuery(fieldName, from, to, interval);
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `ApiKey ${ES_APIKEY}`
},
body: JSON.stringify(esSearchQuery)
};
return fetch(TELEMETRY_ENDPOINT, options)
.then((resp) => resp.json())
.then(parseTempResponse);
}
export function fetchPressure(
from: Date,
to: Date,
size: number = 50,
fetch: Function
): Promise<IChartFrame[]> {
const fromMS = from.getTime();
const toMS = to.getTime();
const interval = calculateInterval(fromMS, toMS, 'auto', size);
const fieldName = 'pressure';
const esSearchQuery = buildQuery(fieldName, from, to, interval);
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `ApiKey ${ES_APIKEY}`
},
body: JSON.stringify(esSearchQuery)
};
return fetch(TELEMETRY_ENDPOINT, options)
.then((resp) => resp.json())
.then(parseTempResponse);
}
export function getLatestInsideReadings(fetch: Function) {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `ApiKey ${ES_APIKEY}`
},
body: JSON.stringify(buildLatestQuery('inside'))
};
return fetch(TELEMETRY_ENDPOINT, options)
.then((resp) => resp.json())
.then(parseLatestResponse);
}
export function getLatestOutsideReadings(fetch: Function) {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `ApiKey ${ES_APIKEY}`
},
body: JSON.stringify(buildLatestQuery('outside'))
};
return fetch(TELEMETRY_ENDPOINT, options)
.then((resp) => resp.json())
.then(parseLatestResponse);
}

View File

@@ -0,0 +1 @@
<svg on:click xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-activity"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@@ -0,0 +1 @@
<svg on:click xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-left"><line x1="17" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="17" y1="18" x2="3" y2="18"></line></svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@@ -0,0 +1 @@
<svg on:click xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-right"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>

After

Width:  |  Height:  |  Size: 323 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>

After

Width:  |  Height:  |  Size: 814 B

View File

@@ -0,0 +1 @@
<svg on:click xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trending-up"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@@ -0,0 +1,5 @@
export default interface IChartFrame {
value: number;
key: number;
key_as_string: string;
}

View File

@@ -0,0 +1,52 @@
type ESRelation = 'eq' | 'lt' | 'gt';
interface IESHit {
_index: string;
_type: string;
_id: string;
_score: number;
_source: IESSource;
}
interface IESSource {
temperature: number;
humidity: number;
location: string;
severity: string;
message: string;
'@timestamp': string;
sessionID: string;
}
export default interface ESTelemetry {
took: number;
timed_out: boolean;
_shards: {
total: number;
successful: number;
skipped: number;
failed: number;
};
hits: {
total: {
value: number;
relation: ESRelation;
};
max_score: null;
hits: Array<IESHit>;
};
aggregations: {
data: {
buckets: [
{
maxValue: {
value: number;
};
key_as_string: string;
key: number;
doc_count: number;
}
];
};
};
}

17
src/lib/themeStore.ts Normal file
View File

@@ -0,0 +1,17 @@
import { writable, get, derived } from 'svelte/store';
import type { Writable } from 'svelte/store';
// import { session } from '$app/stores';
import type { Theme } from './types';
export const theme = derived<Writable<App.Session>, Theme>(session, ($session, set) => {
if ($session.theme) {
set($session.theme);
} else if (browser) {
set(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
});
export const setTheme = (theme: Theme) => {
session.update(($session) => ({ ...$session, theme }));
fetch('/theme', { method: 'PUT', body: theme });
};

4
src/lib/types.ts Normal file
View File

@@ -0,0 +1,4 @@
const themes = ['light', 'dark'] as const;
export type Theme = typeof themes[number];
export const isTheme = (theme: string): theme is Theme => themes.includes(theme as Theme);

29
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,29 @@
<script lang="ts">
import HeaderComponent from '$lib/components/Header.svelte';
// import Darkmode from '$lib/components/Darkmode.svelte'
</script>
<HeaderComponent />
<!-- <Darkmode/> -->
<div class="page-content">
<slot></slot>
</div>
<style lang="scss">
@import '../styles/media-queries.scss';
.page-content {
display: flex;
flex-direction: column;
width: 100%;
min-height: 100vh;
margin: 0 auto;
padding: 2.5em;
margin-top: var(--header-height);
@include mobile {
padding: 1em;
}
}
</style>

View File

@@ -0,0 +1,38 @@
import { getLatestInsideReadings, getLatestOutsideReadings } from '$lib/graphQueryGenerator';
import type { PageServerLoad } from './$types';
let DEFAULT_MINUTES = 14400;
const host = 'http://brewpi.schleppe:5000';
const sensorsUrl = `${host}/api/sensors`;
const relaysUrl = `${host}/api/relays`;
async function getSensors() {
return fetch(sensorsUrl)
.then((resp) => resp.json())
.then((response) => {
return response?.sensors;
});
}
async function getRelays() {
return fetch(relaysUrl)
.then((resp) => resp.json())
.then((response) => {
return response?.relays || [];
});
}
export const load: PageServerLoad = async () => {
const [sensors, relays] = await Promise.all([getSensors(), getRelays()]);
console.log('got sensors and relays');
console.log(sensors, relays);
const inside = sensors.find((sensor) => sensor.location === 'inside');
const outside = sensors.find((sensor) => sensor.location === 'outside');
return {
inside: inside || null,
outside: outside || null,
relays
};
};

41
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,41 @@
<script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte'
import Display from '$lib/components/display.svelte'
import VerticalSensorDisplay from '$lib/components/VerticalSensorDisplay.svelte'
import Livestream from '$lib/components/Livestream.svelte'
import Navigation from '$lib/components/Navigation.svelte';
import type { PageData } from './$types';
import RelayControls from '../lib/components/RelayControls.svelte';
export let data: PageData
const { inside, outside, relays } = data;
</script>
<PageHeader />
<div class="vertical-grid">
<Navigation />
<VerticalSensorDisplay {inside} {outside} />
<RelayControls {relays} />
<!-- <Livestream /> -->
</div>
<style lang="scss">
@import '../styles/media-queries.scss';
.vertical-grid {
display: grid;
grid-template-columns: 1fr;
column-gap: 2rem;
row-gap: 15px;
margin: 1rem;
@include desktop {
grid-template-columns: 1fr 2fr 3fr;
margin: 2rem;
}
}
</style>

View File

@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// const BREWPI_URL = ''
const BREWPI_URL = 'http://brewpi.schleppe:5000';
export const POST = (async ({ request }) => {
const { pathname } = new URL(request.url);
const options = { method: 'POST' };
return fetch(BREWPI_URL + pathname, options)
.then((resp) => resp.json())
.then((response) => json(response));
}) satisfies RequestHandler;

View File

@@ -0,0 +1,6 @@
import brews from '../../brews.json'
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return { brews };
};

View File

@@ -0,0 +1,38 @@
<script lang="ts">
export let data;
const brews = data?.brews || [];
const path = (date: string) => '/brews/' + String(date);
const dateFormat = { year: 'numeric', month: 'short', day: 'numeric' };
const dateString = (date) => new Date(date * 1000).toLocaleDateString('no-NB', dateFormat);
</script>
<main class="page-content">
<h1>Past brews</h1>
<ul>
{#each brews as brew}
<li><a href="{path(brew.date)}">{ brew.beer.name } av { brew.beer.brewery }</a> - {dateString(brew.date)}</li>
{/each}
</ul>
</main>
<style lang="scss">
main.page-content {
ul {
margin-left: 1.2em;
}
ul li {
list-style-type: disc;
line-height: 1.5;
a {
color: #19A786;
}
}
}
</style>

View File

View File

@@ -0,0 +1,14 @@
import { error } from '@sveltejs/kit';
import brews from '../../../brews.json';
import type { PageLoad } from './$types';
export const load = (({ params }) => {
const { date } = params;
const brew = brews.find((b) => b?.date === date);
if (!brew) {
throw error(404, 'Brew not found');
}
return { brew };
}) satisfies PageLoad;

View File

@@ -0,0 +1,182 @@
<script lang="ts">
let height: number;
// let brew = {
// recipe: 'https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ',
// bryggselv: 'https://www.bryggselv.no/finest/105932/kinn-kveldsbris-allgrain-ølsett-25-liter',
// untapped: 'https://untappd.com/b/kinn-bryggeri-kveldsbris/695024'
// }
export let data;
let brew = data.brew;
const dateFormat = { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' };
const dateString = new Date(Number(brew.date * 1000)).toLocaleDateString('no-NB', dateFormat);
const wizards = brew.by.join(', ');
</script>
<section>
<div class="desktop-only image-container" style="height: {height}px">
<img src="/images/{ brew.image }" alt="Tuborg Sommerøl" aria-label="Tuborg Sommerøl" />
</div>
<div class="beer-container" bind:clientHeight="{height}">
<h1>{brew.beer.name}</h1>
<div class="links">
<a href="{brew.recipe}" target="_blank" rel="noreferrer">Recipe</a>
<a href="{brew.order_page}" target="_blank" rel="noreferrer">Bryggselv</a>
<a href="{brew.untapped}" target="_blank" rel="noreferrer">Untapped</a>
</div>
<table>
<tbody>
<tr>
<td><b>Brygget:</b></td>
<td>{ dateString }</td>
</tr>
<tr>
<td><b>Laget av:</b></td>
<td>{ wizards }</td>
</tr>
<tr>
<td><b>Kategori:</b></td>
<td>{ brew.beer.category }</td>
</tr>
<tr>
<td><b>Alkoholprosent:</b></td>
<td>~ { brew.abv }%</td>
</tr>
</tbody>
</table>
<div class="mobile-only image-container">
<img src="/images/{ brew.image }" alt="Tuborg Sommerøl" aria-label="Tuborg Sommerøl" />
</div>
<h3>Historie</h3>
<p>
I 1873 ble Tuborg Bryggeri grunnlagt av Carl Frederik Tietgen på Hellerud i Danmark. I 1970
ble Tuborg Bryggeri en del av Carlsberg.
</p>
<h3>Smak</h3>
<p>
Tuborg Sommerøl er en nordisk pilsner med en svært lys strågul farge. Aromaen er preget av
fruktighet fra gjær, noter av blomster fra humle og lette noter av halm og honning fra malt.
Ølet har en svært lett karakter med en lett maltsødme som er godt balansert mot en lav
bitterhet.
</p>
<h3>Mat</h3>
<p>
Tuborg Sommerøl egner seg godt til lys sommermat som pizza, pastaretter, salat, fisk og
skalldyr.
</p>
<p>Bruk av alkohol kan gi ulike skadevirkninger. Nærmere informasjon finner du her.</p>
</div>
</section>
<style lang="scss">
@import '../../../styles/media-queries.scss';
section {
@import url('https://fonts.googleapis.com/css2?family=Epilogue:wght@200;300;400;500;600;700;800&display=swap');
font-family: 'Epilogue', sans-serif;
position: absolute;
top: 0;
left: 0;
}
.image-container {
display: flex;
justify-content: center;
min-height: 1px;
background-color: #93a4a0;
padding: 3rem 1rem;
@include tablet {
width: 33.33%;
float: left;
padding: 5rem 1rem;
}
@include mobile {
margin: 2rem 0;
}
img {
display: block;
height: 100%;
object-fit: contain;
object-position: top;
width: calc(100% - 3rem);
}
}
.beer-container {
background-color: rgba(215, 224, 223, 0.6);
padding: 2rem 1rem;
@include tablet {
float: left;
width: 66.66%;
padding: 4rem 3rem;
}
h1 {
margin-bottom: 2.4rem;
font-size: 3rem;
}
.links {
margin-bottom: 2rem;
a {
position: relative;
&:not(&:first-of-type) {
margin-left: 1rem;
}
}
a::before {
content: '';
position: absolute;
width: 100%;
height: 1px;
bottom: 1.5px;
background-color: black;
}
}
table {
width: 100%;
max-width: 550px;
list-style: none;
tr:last-child td {
border-bottom: 1px solid #bdc8ca;
}
td {
border-top: 1px solid #bdc8ca;
padding: 1rem 0;
}
}
h3 {
letter-spacing: 0.4px;
}
p {
line-height: 1.2;
font-weight: 300;
}
}
</style>

View File

@@ -0,0 +1,38 @@
import { fetchTemperature, fetchHumidity, fetchPressure } from '$lib/graphQueryGenerator';
import type { PageServerLoad } from './$types';
import type IChartFrame from '$lib/interfaces/IChartFrame';
let DEFAULT_MINUTES = 10080;
export const load: PageServerLoad = async ({ fetch }) => {
const temperatureData: IChartFrame[] = await getTemp(DEFAULT_MINUTES, fetch);
const humidityData: IChartFrame[] = await getHumidity(DEFAULT_MINUTES, fetch);
const pressureData: IChartFrame[] = await getPressure(DEFAULT_MINUTES, fetch);
return {
temperatureData,
humidityData,
pressureData,
DEFAULT_MINUTES
};
};
function getSensor(func: Function, minutes: number, fetch: Function) {
const from: Date = new Date();
const to = new Date(from.getTime() - minutes * 60 * 1000);
const size = 40;
return func(from, to, size, fetch);
}
function getTemp(minutes: number, fetch: Function): IChartFrame[] {
return getSensor(fetchTemperature, minutes, fetch);
}
function getHumidity(minutes: number, fetch: Function): IChartFrame[] {
return getSensor(fetchHumidity, minutes, fetch);
}
function getPressure(minutes: number, fetch: Function): IChartFrame[] {
return getSensor(fetchPressure, minutes, fetch);
}

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchTemperature, fetchHumidity, fetchPressure } from '$lib/graphQueryGenerator';
import Graph from '$lib/components/Graph.svelte';
import type IChartFrame from '$lib/interfaces/IChartFrame';
import type { PageData } from './$types';
export let data: PageData
let temperatureData: IChartFrame[] = data?.temperatureData;
let humidityData: IChartFrame[] = data?.temperatureData;
let pressureData: IChartFrame[] = data?.temperatureData;
let DEFAULT_MINUTES: number = data?.DEFAULT_MINUTES
let minutes: number = DEFAULT_MINUTES;
const buttonMinutes = [{
value: 15,
name: 'Last 15 minutes'
}, {
value: 60,
name: 'Last hour'
}, {
value: 1440,
name: 'Last day'
}, {
value: 10080,
name: 'Last week'
}, {
value: 43200,
name: 'Last month'
}, {
value: 129600,
name: 'Last 3 months'
}, {
value: 259200,
name: 'Last 6 months'
}, {
value: 518400,
name: 'Last year',
}]
function reload(mins: number) {
minutes = mins
const from: Date = new Date();
const to = new Date(from.getTime() - minutes * 60 * 1000);
const size = 40;
fetchTemperature(from, to, size, window.fetch).then((resp) => (temperatureData = resp));
fetchHumidity(from, to, size, window.fetch).then((resp) => (humidityData = resp));
fetchPressure(from, to, size, window.fetch).then((resp) => (pressureData = resp));
}
</script>
<!-- <h1>Server: {emoji.emoji}</h1> -->
<input type="number" bind:value={minutes} on:input={() => reload(minutes)} />
<div class="button-wrapper">
{#each buttonMinutes as button}
<button on:click={() => reload(button.value)} class="{button.value === minutes ? 'selected' : ''}">{ button.name }</button>
{/each}
</div>
<section class="graphs">
{#if temperatureData}
<div class="graphWrapper">
<Graph dataFrames={temperatureData} name="Temperature" />
</div>
{/if}
{#if humidityData}
<div class="graphWrapper">
<Graph dataFrames={humidityData} name="Humidity" beginAtZero={false} />
</div>
{/if}
{#if pressureData}
<div class="graphWrapper">
<Graph dataFrames={pressureData} name="Pressure" beginAtZero={false} />
</div>
{/if}
</section>
<style lang="scss" module="scoped">
@import '../../styles/media-queries.scss';
.graphs {
display: grid;
grid-gap: 1rem;
grid-template-columns: repeat(2, 1fr);
width: 100%;
@include mobile {
grid-template-columns: 1fr;
}
.graphWrapper {
max-width: 100vw;
}
}
.button-wrapper {
display: flex;
width: min-content;
}
button {
display: block;
border-radius: 2rem;
white-space: nowrap;
font-size: 1.1rem;
border: none;
width: content;
padding: 0.5rem 1rem;
margin: 0.3rem;
color: white;
cursor: pointer;
background: var(--green);
transition: background-color var(--color-transition-duration) ease-in-out, transform 0.2s ease;
&:hover {
transform: scale(1.04);
}
&.selected {
background-color: salmon;
color: black;
}
}
</style>

View File

@@ -1,2 +0,0 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>

View File

@@ -0,0 +1,32 @@
$tablet-width: 1200px;
$mobile-width: 768px;
@mixin tablet {
@media (min-width: #{$mobile-width}) {
@content;
}
}
@mixin mobile {
@media (max-width: #{$mobile-width}) {
@content;
}
}
@mixin desktop {
@media (min-width: #{$tablet-width + 1px}) {
@content;
}
}
.desktop-only {
@include mobile {
display: none !important;
}
}
.mobile-only {
@include tablet {
display: none !important;
}
}

35
src/styles/variables.css Normal file
View File

@@ -0,0 +1,35 @@
:root {
--background: white;
--backdrop: #f5f5f7;
--text-color: black;
--red: #ff97a3;
--blue: #9ad9ff;
--header-height: 200px;
--color-transition-duration: 0.4s;
}
.dark {
--background: pink;
--backdrop: #202124;
--text-color: white;
}
* {
box-sizing: border-box;
}
a {
color: inherit; /* blue colors for links too */
text-decoration: inherit; /* no underline */
}
ul,
li {
margin: 0;
padding: 0;
}
li {
list-style-type: none;
}