Implement the settings page and an example chart
This commit is contained in:
parent
564cc84db1
commit
75f50cae88
25 changed files with 902 additions and 52 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
/node_modules/
|
||||
/dist/
|
||||
/.idea/
|
||||
*.env
|
||||
*.env
|
||||
/src/generated-types
|
|
@ -29,8 +29,14 @@
|
|||
"@iconify-json/svg-spinners": "^1.1.2",
|
||||
"@unhead/vue": "^1.9.10",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"chart.js": "^4.4.3",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^5.0.7",
|
||||
"temporal-polyfill": "^0.2.4",
|
||||
"vue": "^3.4.27",
|
||||
"vue-chartjs": "^5.3.1",
|
||||
"vue-router": "^4.3.2"
|
||||
}
|
||||
}
|
||||
|
|
77
pnpm-lock.yaml
generated
77
pnpm-lock.yaml
generated
|
@ -26,12 +26,30 @@ importers:
|
|||
'@vueuse/core':
|
||||
specifier: ^10.9.0
|
||||
version: 10.9.0(vue@3.4.27(typescript@5.4.5))
|
||||
chart.js:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
chartjs-adapter-date-fns:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0(chart.js@4.4.3)(date-fns@3.6.0)
|
||||
date-fns:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
lodash-es:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
nanoid:
|
||||
specifier: ^5.0.7
|
||||
version: 5.0.7
|
||||
temporal-polyfill:
|
||||
specifier: ^0.2.4
|
||||
version: 0.2.4
|
||||
vue:
|
||||
specifier: ^3.4.27
|
||||
version: 3.4.27(typescript@5.4.5)
|
||||
vue-chartjs:
|
||||
specifier: ^5.3.1
|
||||
version: 5.3.1(chart.js@4.4.3)(vue@3.4.27(typescript@5.4.5))
|
||||
vue-router:
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2(vue@3.4.27(typescript@5.4.5))
|
||||
|
@ -418,6 +436,9 @@ packages:
|
|||
resolution: {integrity: sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@kurkle/color@0.3.2':
|
||||
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
|
@ -742,6 +763,16 @@ packages:
|
|||
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
chart.js@4.4.3:
|
||||
resolution: {integrity: sha512-qK1gkGSRYcJzqrrzdR6a+I0vQ4/R+SoODXyAjscQ/4mzuNzySaMCd+hyVxitSY1+L2fjPD1Gbn+ibNqRmwQeLw==}
|
||||
engines: {pnpm: '>=8'}
|
||||
|
||||
chartjs-adapter-date-fns@3.0.0:
|
||||
resolution: {integrity: sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==}
|
||||
peerDependencies:
|
||||
chart.js: '>=2.8.0'
|
||||
date-fns: '>=2.0.0'
|
||||
|
||||
chokidar@3.6.0:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
@ -776,6 +807,9 @@ packages:
|
|||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
date-fns@3.6.0:
|
||||
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
|
||||
|
||||
debug@4.3.4:
|
||||
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
|
@ -976,6 +1010,11 @@ packages:
|
|||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.0.7:
|
||||
resolution: {integrity: sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
node-fetch-native@1.6.4:
|
||||
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
|
||||
|
||||
|
@ -1095,6 +1134,12 @@ packages:
|
|||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
temporal-polyfill@0.2.4:
|
||||
resolution: {integrity: sha512-WA5p0CjQTkMjF9m8sP4wSYgpqI8m2d4q7wPUyaJOWhy4bI9mReLb2yGvTV4qf/DPMTe6H6M/Dig5KmTMB7ev6Q==}
|
||||
|
||||
temporal-spec@0.2.4:
|
||||
resolution: {integrity: sha512-lDMFv4nKQrSjlkHKAlHVqKrBG4DyFfa9F74cmBZ3Iy3ed8yvWnlWSIdi4IKfSqwmazAohBNwiN64qGx4y5Q3IQ==}
|
||||
|
||||
to-fast-properties@2.0.0:
|
||||
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
|
||||
engines: {node: '>=4'}
|
||||
|
@ -1210,6 +1255,12 @@ packages:
|
|||
terser:
|
||||
optional: true
|
||||
|
||||
vue-chartjs@5.3.1:
|
||||
resolution: {integrity: sha512-rZjqcHBxKiHrBl0CIvcOlVEBwRhpWAVf6rDU3vUfa7HuSRmGtCslc0Oc8m16oAVuk0erzc1FCtH1VCriHsrz+A==}
|
||||
peerDependencies:
|
||||
chart.js: ^4.1.1
|
||||
vue: ^3.0.0-0 || ^2.7.0
|
||||
|
||||
vue-demi@0.14.7:
|
||||
resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==}
|
||||
engines: {node: '>=12'}
|
||||
|
@ -1601,6 +1652,8 @@ snapshots:
|
|||
string-argv: 0.3.2
|
||||
type-detect: 4.0.8
|
||||
|
||||
'@kurkle/color@0.3.2': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
|
@ -1998,6 +2051,15 @@ snapshots:
|
|||
escape-string-regexp: 1.0.5
|
||||
supports-color: 5.5.0
|
||||
|
||||
chart.js@4.4.3:
|
||||
dependencies:
|
||||
'@kurkle/color': 0.3.2
|
||||
|
||||
chartjs-adapter-date-fns@3.0.0(chart.js@4.4.3)(date-fns@3.6.0):
|
||||
dependencies:
|
||||
chart.js: 4.4.3
|
||||
date-fns: 3.6.0
|
||||
|
||||
chokidar@3.6.0:
|
||||
dependencies:
|
||||
anymatch: 3.1.3
|
||||
|
@ -2037,6 +2099,8 @@ snapshots:
|
|||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
date-fns@3.6.0: {}
|
||||
|
||||
debug@4.3.4:
|
||||
dependencies:
|
||||
ms: 2.1.2
|
||||
|
@ -2218,6 +2282,8 @@ snapshots:
|
|||
|
||||
nanoid@3.3.7: {}
|
||||
|
||||
nanoid@5.0.7: {}
|
||||
|
||||
node-fetch-native@1.6.4: {}
|
||||
|
||||
node-releases@2.0.14: {}
|
||||
|
@ -2338,6 +2404,12 @@ snapshots:
|
|||
dependencies:
|
||||
has-flag: 3.0.0
|
||||
|
||||
temporal-polyfill@0.2.4:
|
||||
dependencies:
|
||||
temporal-spec: 0.2.4
|
||||
|
||||
temporal-spec@0.2.4: {}
|
||||
|
||||
to-fast-properties@2.0.0: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
|
@ -2456,6 +2528,11 @@ snapshots:
|
|||
fsevents: 2.3.3
|
||||
sass: 1.77.2
|
||||
|
||||
vue-chartjs@5.3.1(chart.js@4.4.3)(vue@3.4.27(typescript@5.4.5)):
|
||||
dependencies:
|
||||
chart.js: 4.4.3
|
||||
vue: 3.4.27(typescript@5.4.5)
|
||||
|
||||
vue-demi@0.14.7(vue@3.4.27(typescript@5.4.5)):
|
||||
dependencies:
|
||||
vue: 3.4.27(typescript@5.4.5)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<template>
|
||||
<div class="fixed z-100 pointer-events-none -top-20 -left-20 bg-orange-800 rounded-full w-150 h-50 filter blur-250px opacity-60"/>
|
||||
<NavigationMenuWrapper>
|
||||
<div class="p-8 flex-shrink-0 flex-grow-0">
|
||||
<div class="relative p-10 pl-20 pt-15 flex-shrink-0 flex-grow-0">
|
||||
<SuspenseRouterView/>
|
||||
</div>
|
||||
</NavigationMenuWrapper>
|
||||
|
@ -12,8 +13,8 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { useHead } from "@unhead/vue"
|
||||
import SuspenseRouterView from "./components/reusable/SuspenseRouterView.vue"
|
||||
import NavigationMenuWrapper from "@/components/NavigationMenuWrapper.vue"
|
||||
import SuspenseRouterView from "@/components/SuspenseRouterView.vue"
|
||||
|
||||
useHead({
|
||||
titleTemplate: title => title === undefined ? "Tea Dashboard" : `${title} — Tea Dashboard`
|
||||
|
|
55
src/backend.ts
Normal file
55
src/backend.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { Temporal } from "temporal-polyfill"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { fakeServerState } from "@/fakeServerState"
|
||||
|
||||
export interface TypeOfTea {
|
||||
id: string
|
||||
name: string
|
||||
steepingTime: Temporal.Duration
|
||||
registrationTimestamp: Temporal.Instant
|
||||
}
|
||||
|
||||
export interface SteepingTimeChange {
|
||||
id: string
|
||||
typeOfTeaId: string
|
||||
timestamp: Temporal.Instant
|
||||
newValue: Temporal.Duration
|
||||
}
|
||||
|
||||
export interface BrewingEvent {
|
||||
id: string
|
||||
typeOfTeaId: string
|
||||
timestamp: Temporal.Instant
|
||||
}
|
||||
|
||||
export interface Configuration {
|
||||
defaultSteepingTime: Temporal.Duration
|
||||
feedbackTimeout: Temporal.Duration
|
||||
}
|
||||
|
||||
const delay = () => new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
export async function fetchConfiguration(): Promise<Configuration> {
|
||||
await delay()
|
||||
return cloneDeep(fakeServerState.configuration)
|
||||
}
|
||||
|
||||
export async function updateConfiguration(value: Configuration) {
|
||||
await delay()
|
||||
fakeServerState.configuration = cloneDeep(value)
|
||||
}
|
||||
|
||||
export async function fetchBrewingEvents(): Promise<BrewingEvent[]> {
|
||||
await delay()
|
||||
return cloneDeep(fakeServerState.brewingEvents)
|
||||
}
|
||||
|
||||
export async function fetchTypesOfTeaById(): Promise<Map<string, TypeOfTea>> {
|
||||
await delay()
|
||||
return new Map(fakeServerState.typesOfTea.map(t => [t.id, t]))
|
||||
}
|
||||
|
||||
export async function updateTypeOfTea(id: string, data: Pick<TypeOfTea, "name" | "steepingTime">) {
|
||||
await delay()
|
||||
Object.assign(fakeServerState.typesOfTea.find(t => t.id === id)!, data)
|
||||
}
|
11
src/charts.ts
Normal file
11
src/charts.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export const borderColors: string[] = [
|
||||
"hsl(20 70% 30%)",
|
||||
"hsl(40,100%,76%)",
|
||||
"hsl(13,100%,66%)"
|
||||
]
|
||||
|
||||
export async function registerChartJsDateFnsAdapter() {
|
||||
// has no type definition
|
||||
// @ts-expect-error
|
||||
await import("chartjs-adapter-date-fns")
|
||||
}
|
21
src/components/Card.vue
Normal file
21
src/components/Card.vue
Normal file
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div class="bg-white border-1 border-solid border-gray-300 rounded-2xl shadow-sm flex flex-col gap-3 p-6">
|
||||
<div class="font-bold text-lg font-serif">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="h-2px w-full bg-orange-800 opacity-40"/>
|
||||
<div class="relative">
|
||||
<slot/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
}>()
|
||||
</script>
|
84
src/components/DurationInput.vue
Normal file
84
src/components/DurationInput.vue
Normal file
|
@ -0,0 +1,84 @@
|
|||
<template>
|
||||
<div class="flex gap-2">
|
||||
<div v-if="unitOrder.indexOf(props.largestUnit) >= unitOrder.indexOf('hours')" class="flex gap-0.5 items-baseline">
|
||||
<TextualInput aria-label="Hours" v-model="hours" type="number" min="0" max="23" :disabled="disabled"/>
|
||||
<span>h</span>
|
||||
</div>
|
||||
<div v-if="unitOrder.indexOf(props.largestUnit) >= unitOrder.indexOf('minutes')" class="flex gap-0.5 items-baseline">
|
||||
<TextualInput aria-label="Minutes" v-model="minutes" type="number" min="0" max="59" :disabled="disabled"/>
|
||||
<span>m</span>
|
||||
</div>
|
||||
<div v-if="unitOrder.indexOf(props.largestUnit) >= unitOrder.indexOf('seconds')" class="flex gap-0.5 items-baseline">
|
||||
<TextualInput aria-label="Seconds" v-model="seconds" type="number" min="0" max="59" :disabled="disabled"/>
|
||||
<span>s</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TextualInput from "@/components/TextualInput.vue"
|
||||
import { computed, watchEffect } from "vue"
|
||||
import { Temporal } from "temporal-polyfill"
|
||||
import { getSameButDifferentString } from "@/utils/getSameButDifferentString"
|
||||
|
||||
type TimeUnit = "seconds" | "minutes" | "hours"
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Temporal.Duration
|
||||
minimum: Temporal.Duration
|
||||
disabled: boolean
|
||||
largestUnit: TimeUnit
|
||||
}>()
|
||||
|
||||
const unitOrder: TimeUnit[] = ["seconds", "minutes", "hours"]
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [Temporal.Duration]
|
||||
}>()
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.modelValue.total({ unit: "seconds" }) < props.minimum.total({ unit: "seconds" })) {
|
||||
emit("update:modelValue", Temporal.Duration.from(props.minimum))
|
||||
}
|
||||
})
|
||||
|
||||
const hours = computed<string>({
|
||||
get() {
|
||||
return getSameButDifferentString(String(props.modelValue.hours))
|
||||
},
|
||||
set(newValue: string) {
|
||||
let parsed = parseInt(newValue, 10)
|
||||
if (isNaN(parsed) || parsed < 0) parsed = 0
|
||||
parsed = Math.min(parsed, 23)
|
||||
emit("update:modelValue", props.modelValue.with({ hours: parsed }).round({ largestUnit: props.largestUnit }))
|
||||
}
|
||||
})
|
||||
|
||||
const minutes = computed<string>({
|
||||
get() {
|
||||
return getSameButDifferentString(String(props.modelValue.minutes))
|
||||
},
|
||||
set(newValue: string) {
|
||||
let parsed = parseInt(newValue, 10)
|
||||
if (isNaN(parsed) || parsed < 0) parsed = 0
|
||||
parsed = Math.min(parsed, 59)
|
||||
emit("update:modelValue", props.modelValue.with({ minutes: parsed }).round({ largestUnit: props.largestUnit }))
|
||||
}
|
||||
})
|
||||
|
||||
const seconds = computed<string>({
|
||||
get() {
|
||||
return getSameButDifferentString(String(props.modelValue.seconds))
|
||||
},
|
||||
set(newValue: string) {
|
||||
let parsed = parseInt(newValue, 10)
|
||||
if (isNaN(parsed) || parsed < 0) parsed = 0
|
||||
parsed = Math.min(parsed, 59)
|
||||
emit("update:modelValue", props.modelValue.with({ seconds: parsed }).round({ largestUnit: props.largestUnit }))
|
||||
}
|
||||
})
|
||||
</script>
|
21
src/components/FieldWrapper.vue
Normal file
21
src/components/FieldWrapper.vue
Normal file
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label :for="inputId" class="ps-0.5 font-bold text-xs uppercase tracking-widest">{{ label }}</label>
|
||||
<slot :input-id="inputId"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nanoid } from "nanoid/non-secure"
|
||||
import { computed } from "vue"
|
||||
|
||||
const props = defineProps<{
|
||||
label: string,
|
||||
}>()
|
||||
|
||||
const inputId = computed(() => `field-${nanoid(5)}`)
|
||||
</script>
|
11
src/components/LoadingIcon.vue
Normal file
11
src/components/LoadingIcon.vue
Normal file
|
@ -0,0 +1,11 @@
|
|||
<template>
|
||||
<Icon/>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Icon from "virtual:icons/svg-spinners/bars-rotate-fade"
|
||||
</script>
|
|
@ -1,15 +1,24 @@
|
|||
<template>
|
||||
<nav class="w-70 bg-gray-900 fixed top-0 left-0 bottom-0 p-8 flex flex-col gap-3" :class="$style.root">
|
||||
<ul class="list-none p-0 grid grid-cols-2 gap-1">
|
||||
<li v-for="type in mediaTypes" :key="type.id">
|
||||
<nav class="w-70 fixed top-0 left-0 bottom-0 py-8 flex flex-col gap-3 border-r-solid border-1 border-gray-300 bg-gray-100">
|
||||
<div class="font-serif font-400 text-3xl pl-7">
|
||||
Tea Inc.
|
||||
</div>
|
||||
<ul class="list-none p-0 flex flex-col gap-2">
|
||||
<li v-for="item in items" :key="item.target">
|
||||
<router-link
|
||||
class="relative rounded-sm w-full p-2 no-underline flex flex-col items-center gap-1 group"
|
||||
:to="`/media/${type.id}`"
|
||||
:style="{ color: type.color }"
|
||||
class="relative w-full py-2 pl-8 flex items-center gap-4 bg-orange-800 bg-opacity-0"
|
||||
:class="$style.item"
|
||||
:to="item.target"
|
||||
:active-class="item.matchExact ? null : $style.active"
|
||||
:exact-active-class="item.matchExact ? $style.active : null"
|
||||
>
|
||||
<component class="absolute -top-5 left-0 opacity-5 blur-5 pointer-events-none group-hover:blur-lg group-hover:opacity-30 transition duration-300" :is="type.blob"/>
|
||||
<component class="text-3xl" :is="type.icon"/>
|
||||
<span class="text-base">{{ type.label }}</span>
|
||||
<div class="text-3xl relative" :class="$style.icons" :style="{ top: item.icon.yOffset === undefined ? null : `${item.icon.yOffset}px` }">
|
||||
<component class="absolute inset-0 opacity-0" :is="item.icon.normal"/>
|
||||
<component class="duration-200" :is="item.icon.duotone"/>
|
||||
</div>
|
||||
<div class="text-lg" :class="$style.label">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -20,52 +29,91 @@
|
|||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
box-shadow: 0 0 4px 0 theme("colors.gray.700");
|
||||
.item {
|
||||
transition: background 200ms ease;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: url("../assets/noise.png") repeat;
|
||||
opacity: 15%;
|
||||
&:hover, &.active {
|
||||
.label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.icons {
|
||||
& > :first-child {
|
||||
opacity: 100;
|
||||
}
|
||||
|
||||
& > :last-child {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-opacity-5;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply bg-opacity-15 border-r-solid border-2 border-orange-800;
|
||||
}
|
||||
}
|
||||
|
||||
.icons > * {
|
||||
transition: opacity 200ms linear;
|
||||
}
|
||||
|
||||
.label {
|
||||
transition: font-weight 100ms linear;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue"
|
||||
import BooksIcon from "virtual:icons/ph/books-light"
|
||||
import FilmStripIcon from "virtual:icons/ph/film-strip-light"
|
||||
import LiteratureBlob from "./blobs/LiteratureBlob.vue"
|
||||
import MoviesBlob from "./blobs/MoviesBlob.vue"
|
||||
import TeaCupOutlineIcon from "virtual:icons/solar/tea-cup-outline"
|
||||
import TeaCupLineDuotoneIcon from "virtual:icons/solar/tea-cup-line-duotone"
|
||||
import Tuning2OutlineIcon from "virtual:icons/solar/tuning-2-outline"
|
||||
import Tuning2LineDuotoneIcon from "virtual:icons/solar/tuning-2-line-duotone"
|
||||
import ChartOutlineIcon from "virtual:icons/solar/chart-outline"
|
||||
import ChartLineDuotoneIcon from "virtual:icons/solar/chart-line-duotone"
|
||||
|
||||
interface MediaType {
|
||||
id: string
|
||||
interface NavigationItem {
|
||||
target: string
|
||||
matchExact?: boolean
|
||||
label: string
|
||||
icon: Component
|
||||
blob: Component
|
||||
color: string
|
||||
icon: {
|
||||
normal: Component
|
||||
duotone: Component
|
||||
yOffset?: number
|
||||
}
|
||||
}
|
||||
|
||||
const mediaTypes: MediaType[] = [
|
||||
{
|
||||
id: "literature",
|
||||
label: "Literature",
|
||||
icon: BooksIcon,
|
||||
blob: LiteratureBlob,
|
||||
color: "#11b970"
|
||||
},
|
||||
{
|
||||
id: "films",
|
||||
label: "Movies",
|
||||
icon: FilmStripIcon,
|
||||
blob: MoviesBlob,
|
||||
color: "#d2b214"
|
||||
}
|
||||
const items: NavigationItem[] = [
|
||||
{
|
||||
target: "/",
|
||||
label: "Overview",
|
||||
matchExact: true,
|
||||
icon: {
|
||||
normal: TeaCupOutlineIcon,
|
||||
duotone: TeaCupLineDuotoneIcon,
|
||||
yOffset: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
target: "/stats",
|
||||
label: "Statistics",
|
||||
icon: {
|
||||
normal: ChartOutlineIcon,
|
||||
duotone: ChartLineDuotoneIcon,
|
||||
yOffset: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
target: "/settings",
|
||||
label: "Settings",
|
||||
icon: {
|
||||
normal: Tuning2OutlineIcon,
|
||||
duotone: Tuning2LineDuotoneIcon,
|
||||
yOffset: 5
|
||||
}
|
||||
}
|
||||
]
|
||||
</script>
|
22
src/components/PageTitle.vue
Normal file
22
src/components/PageTitle.vue
Normal file
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<div class="font-serif font-400 text-5xl text-orange-900 pb-10">
|
||||
{{ title }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useHead } from "@unhead/vue"
|
||||
import { toRef } from "vue"
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
}>()
|
||||
|
||||
useHead({
|
||||
title: toRef(props, "title")
|
||||
})
|
||||
</script>
|
|
@ -27,11 +27,9 @@
|
|||
&[data-is-visible="true"] {
|
||||
opacity: 100%;
|
||||
transition-duration: 200ms;
|
||||
transition-delay: 200ms;
|
||||
|
||||
.loadingIndicator {
|
||||
opacity: 100%;
|
||||
transition-delay: 1500ms;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
27
src/components/TextButton.vue
Normal file
27
src/components/TextButton.vue
Normal file
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<button
|
||||
class="bg-green-400 bg-opacity-40 rounded-lg border-none text-black font-bold px-4 py-2.5 outline-none
|
||||
transition duration-200 disabled:opacity-40"
|
||||
:class="$style.root"
|
||||
:type="isSubmit ? 'submit' : 'button'"
|
||||
:disabled="disabled"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
&:not(:disabled) {
|
||||
@apply hover:bg-opacity-60 focus-visible:bg-opacity-65 active:bg-opacity-80
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
isSubmit?: boolean
|
||||
disabled?: boolean
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
35
src/components/TextualInput.vue
Normal file
35
src/components/TextualInput.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<component
|
||||
:is="type === 'area' ? 'textarea' : 'input'"
|
||||
:value="modelValue"
|
||||
class="px-2 leading-loose bg-light-300 border-solid border-1 border-gray-300 rounded-sm outline-none focus:bg-light-100 transition duration-200 text-black accent-current disabled:cursor-not-allowed disabled:bg-light-600"
|
||||
:class="[type === 'number' && 'text-end', type === 'area' && 'resize-y min-h-20 max-h-50']"
|
||||
:type="type === 'area' ? undefined : type"
|
||||
:disabled="disabled"
|
||||
@input="(event: InputEvent) => modelValue = (event.target as HTMLInputElement | HTMLTextAreaElement).value"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts" generic="Nullability extends null | never">
|
||||
import { computed } from "vue"
|
||||
|
||||
const props = defineProps<{
|
||||
type: "normal" | "password" | "datetime-local" | "number" | "area"
|
||||
modelValue: string | Nullability
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "blur"])
|
||||
|
||||
const modelValue = computed({
|
||||
get: () => props.modelValue ?? "",
|
||||
set: (newValue: string | number) => {
|
||||
emit("update:modelValue", String(newValue))
|
||||
}
|
||||
})
|
||||
</script>
|
131
src/components/charts/TeaConsumptionOverTimeChart.vue
Normal file
131
src/components/charts/TeaConsumptionOverTimeChart.vue
Normal file
|
@ -0,0 +1,131 @@
|
|||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="relative h-60">
|
||||
<LineChart :data="data" :options="options"/>
|
||||
</div>
|
||||
<div class="flex flex-wrap px-1 justify-center items-center gap-x-4 gap-y-1 text-sm">
|
||||
<div
|
||||
v-for="(group, index) in groupedTopEvents"
|
||||
:key="group.typeOfTeaId"
|
||||
class="flex gap-1.5 items-center flex-shrink-0"
|
||||
>
|
||||
<div class="h-2px w-5" :style="{ backgroundColor: borderColors[index] }"></div>
|
||||
<div>
|
||||
{{ typesOfTeaById.get(group.typeOfTeaId)!.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Line as LineChart } from "vue-chartjs"
|
||||
import { BrewingEvent, TypeOfTea } from "@/backend"
|
||||
import { computed } from "vue"
|
||||
import {
|
||||
type ChartOptions,
|
||||
type ScriptableContext,
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
TimeScale,
|
||||
ChartData,
|
||||
Filler,
|
||||
} from "chart.js"
|
||||
import { groupBy, take } from "lodash-es"
|
||||
import { borderColors, registerChartJsDateFnsAdapter } from "@/charts"
|
||||
|
||||
await registerChartJsDateFnsAdapter()
|
||||
|
||||
const props = defineProps<{
|
||||
typesOfTeaById: Map<string, TypeOfTea>
|
||||
brewingEvents: BrewingEvent[]
|
||||
}>()
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, TimeScale, Filler)
|
||||
|
||||
const groupedTopEvents = computed<Array<{ typeOfTeaId: string; events: BrewingEvent[] }>>(() => take(
|
||||
Object
|
||||
.entries(groupBy(props.brewingEvents, e => e.typeOfTeaId))
|
||||
.sort((a, b) => b[1].length - a[1].length),
|
||||
3
|
||||
).map(([typeOfTeaId, events]) => ({
|
||||
typeOfTeaId,
|
||||
events
|
||||
})))
|
||||
|
||||
const options: ChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
time: {
|
||||
minUnit: "month"
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: "linear",
|
||||
grace: "10%",
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 20,
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
font: {
|
||||
family: "Manrope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = computed<ChartData>(() => ({
|
||||
datasets: [
|
||||
{
|
||||
pointStyle: false,
|
||||
borderColor: "hsl(20 100% 40%)",
|
||||
fill: "start",
|
||||
backgroundColor: (ctx: ScriptableContext<"line">) => {
|
||||
const canvas = ctx.chart.ctx
|
||||
const gradient = canvas.createLinearGradient(0, -160, 0, 220)
|
||||
|
||||
gradient.addColorStop(0, "hsl(20 100% 40% / 100%)")
|
||||
gradient.addColorStop(1, "hsl(20 100% 50% / 0%)")
|
||||
|
||||
return gradient
|
||||
},
|
||||
data: props.brewingEvents.map((e, index) => ({
|
||||
x: e.timestamp.epochMilliseconds,
|
||||
y: index + 1
|
||||
}))
|
||||
},
|
||||
...groupedTopEvents.value
|
||||
.map((group, index) => ({
|
||||
pointStyle: false,
|
||||
borderColor: borderColors[index],
|
||||
borderWidth: 2,
|
||||
data: group.events.map((e, index) => ({
|
||||
x: e.timestamp.epochMilliseconds,
|
||||
y: index + 1
|
||||
}))
|
||||
}))
|
||||
]
|
||||
}))
|
||||
</script>
|
59
src/components/specific/ConfigurationForm.vue
Normal file
59
src/components/specific/ConfigurationForm.vue
Normal file
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<form class="flex flex-col gap-7" @submit.prevent="onSubmit()">
|
||||
<section class="flex flex-col gap-2">
|
||||
<div class="font-serif font-300 text-xl text-orange-900">
|
||||
Default steeping time
|
||||
</div>
|
||||
<p class="text-opacity-80 font-serif font-300 m-0">
|
||||
You can always change the steeping time when you register a new type of tea.<br>
|
||||
This is the time that will be used if you do not change it manually.
|
||||
</p>
|
||||
<DurationInput v-model="data.defaultSteepingTime" largest-unit="minutes" :minimum="Temporal.Duration.from({ seconds: 10 })" :disabled="isLoading"/>
|
||||
</section>
|
||||
<section class="flex flex-col gap-2">
|
||||
<div class="font-serif font-300 text-xl text-orange-900">
|
||||
Feedback timeout
|
||||
</div>
|
||||
<p class="text-opacity-80 font-serif font-300 m-0">
|
||||
After you brew a tea, you can give the machine feedback on how you liked it <em>for this long</em>.<br>
|
||||
It will adjust its steeping time accordingly.
|
||||
</p>
|
||||
<DurationInput v-model="data.feedbackTimeout" largest-unit="hours" :minimum="Temporal.Duration.from({ seconds: 10 })" :disabled="isLoading"/>
|
||||
</section>
|
||||
<TextButton class="self-start max-w-full w-30" is-submit label="Save" :disabled="!isDirty || isLoading"/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FieldWrapper from "@/components/FieldWrapper.vue"
|
||||
import { Configuration, updateConfiguration } from "@/backend"
|
||||
import { computed, reactive, ref, toRaw } from "vue"
|
||||
import { cloneDeep, isEqual } from "lodash-es"
|
||||
import DurationInput from "@/components/DurationInput.vue"
|
||||
import { Temporal } from "temporal-polyfill"
|
||||
import TextButton from "@/components/TextButton.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
initialData: Configuration
|
||||
}>()
|
||||
|
||||
const savedData = ref(cloneDeep(props.initialData))
|
||||
const data = reactive(cloneDeep(props.initialData))
|
||||
|
||||
const isDirty = computed(() => !isEqual(data, savedData.value))
|
||||
const isLoading = ref(false)
|
||||
|
||||
async function onSubmit() {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
|
||||
await updateConfiguration(toRaw(data))
|
||||
|
||||
savedData.value = cloneDeep(toRaw(data))
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
46
src/components/specific/TypeOfTeaForm.vue
Normal file
46
src/components/specific/TypeOfTeaForm.vue
Normal file
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<form class="flex gap-4 w-full" @submit.prevent="onSubmit()">
|
||||
<FieldWrapper class="flex-grow" label="Name" v-slot="{ inputId }">
|
||||
<TextualInput v-model="data.name" type="normal" :id="inputId" :disabled="isLoading"/>
|
||||
</FieldWrapper>
|
||||
<FieldWrapper label="Steeping time">
|
||||
<DurationInput v-model="data.steepingTime" largest-unit="minutes" :minimum="Temporal.Duration.from({ seconds: 10 })" :disabled="isLoading"/>
|
||||
</FieldWrapper>
|
||||
<TextButton class="self-end" is-submit label="Save" :disabled="!isDirty || isLoading"/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FieldWrapper from "@/components/FieldWrapper.vue"
|
||||
import TextualInput from "@/components/TextualInput.vue"
|
||||
import { type TypeOfTea, updateTypeOfTea } from "@/backend"
|
||||
import { computed, reactive, ref, toRaw } from "vue"
|
||||
import { cloneDeep, isEqual } from "lodash-es"
|
||||
import DurationInput from "@/components/DurationInput.vue"
|
||||
import { Temporal } from "temporal-polyfill"
|
||||
import TextButton from "@/components/TextButton.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
initialData: TypeOfTea
|
||||
}>()
|
||||
|
||||
const savedData = ref(cloneDeep(props.initialData))
|
||||
const data = reactive(cloneDeep(props.initialData))
|
||||
|
||||
const isDirty = computed(() => !isEqual(data, savedData.value))
|
||||
const isLoading = ref(false)
|
||||
|
||||
async function onSubmit() {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
|
||||
await updateTypeOfTea(props.initialData.id, toRaw(data))
|
||||
|
||||
savedData.value = cloneDeep(toRaw(data))
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
97
src/fakeServerState.ts
Normal file
97
src/fakeServerState.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
import { Temporal } from "temporal-polyfill"
|
||||
import type { BrewingEvent, Configuration, SteepingTimeChange, TypeOfTea } from "@/backend"
|
||||
import { random } from "lodash-es"
|
||||
import { nanoid } from "nanoid"
|
||||
|
||||
const localTimeZoneId = Temporal.Now.timeZoneId()
|
||||
|
||||
const configuration: Configuration = {
|
||||
defaultSteepingTime: Temporal.Duration.from({ minutes: 4, seconds: 30 }),
|
||||
feedbackTimeout: Temporal.Duration.from({ hours: 2 })
|
||||
}
|
||||
|
||||
const typesOfTea: TypeOfTea[] = [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: "Pfefferminz",
|
||||
steepingTime: Temporal.Duration.from({ minutes: 5, seconds: 20 }),
|
||||
registrationTimestamp: Temporal.ZonedDateTime.from({ timeZone: localTimeZoneId, year: 2024, month: 2, day: 20, hour: 14, minute: 24 }).toInstant()
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
name: "Türkischer Apfel",
|
||||
steepingTime: Temporal.Duration.from({ minutes: 3, seconds: 50 }),
|
||||
registrationTimestamp: Temporal.ZonedDateTime.from({ timeZone: localTimeZoneId, year: 2024, month: 3, day: 1, hour: 16, minute: 1 }).toInstant()
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
name: "Fenchel-Anis-Kümmel",
|
||||
steepingTime: Temporal.Duration.from({ minutes: 8 }),
|
||||
registrationTimestamp: Temporal.ZonedDateTime.from({ timeZone: localTimeZoneId, year: 2024, month: 3, day: 9, hour: 9, minute: 55 }).toInstant()
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
name: "Hagebutte",
|
||||
steepingTime: Temporal.Duration.from({ minutes: 4, seconds: 30 }),
|
||||
registrationTimestamp: Temporal.ZonedDateTime.from({ timeZone: localTimeZoneId, year: 2024, month: 3, day: 29, hour: 15, minute: 34 }).toInstant()
|
||||
},
|
||||
]
|
||||
|
||||
const steepingTimeChanges: SteepingTimeChange[] = []
|
||||
const brewingEvents: BrewingEvent[] = []
|
||||
|
||||
for (const type of typesOfTea) {
|
||||
const numberOfBrewingEvents = random(5, 10)
|
||||
let nextTimestamp = type.registrationTimestamp
|
||||
let lastTwoSteepingTimeSeconds: number[] = [type.steepingTime.total({ unit: "seconds" })]
|
||||
|
||||
for (let i = 0; i < numberOfBrewingEvents; i++) {
|
||||
brewingEvents.push({
|
||||
id: nanoid(),
|
||||
typeOfTeaId: type.id,
|
||||
timestamp: nextTimestamp,
|
||||
})
|
||||
|
||||
if (random(0, 100) < 20) {
|
||||
let newSteepingTimeSeconds: number
|
||||
const direction = random(0, 1) === 1 ? "increase" : "decrease"
|
||||
const currentSeconds = lastTwoSteepingTimeSeconds[0]
|
||||
|
||||
if (lastTwoSteepingTimeSeconds.length === 1) {
|
||||
newSteepingTimeSeconds = direction === "increase" ? currentSeconds + 30 : currentSeconds - 30
|
||||
} else {
|
||||
const lastDelta = lastTwoSteepingTimeSeconds[1] - lastTwoSteepingTimeSeconds[0]
|
||||
const lastDirection = lastDelta > 0 ? "increase" : "decrease"
|
||||
if (direction === lastDirection) {
|
||||
newSteepingTimeSeconds = direction === "increase" ? currentSeconds + 30 : currentSeconds - 30
|
||||
} else {
|
||||
newSteepingTimeSeconds = Math.round(Math.abs(lastTwoSteepingTimeSeconds[0] - lastTwoSteepingTimeSeconds[1]) / 2)
|
||||
}
|
||||
}
|
||||
|
||||
steepingTimeChanges.push({
|
||||
id: nanoid(),
|
||||
typeOfTeaId: type.id,
|
||||
timestamp: nextTimestamp.add({ hours: random(0, 1), minutes: random(0, 30) }),
|
||||
newValue: Temporal.Duration.from({ seconds: newSteepingTimeSeconds })
|
||||
})
|
||||
}
|
||||
|
||||
nextTimestamp = nextTimestamp.add(Temporal.Duration.from({ hours: random(0, 14 * 24), minutes: random(0, 60) }))
|
||||
}
|
||||
}
|
||||
|
||||
brewingEvents.sort((a, b) => Temporal.Instant.compare(a.timestamp, b.timestamp))
|
||||
steepingTimeChanges.sort((a, b) => Temporal.Instant.compare(a.timestamp, b.timestamp))
|
||||
|
||||
export const fakeServerState: {
|
||||
configuration: Configuration
|
||||
typesOfTea: TypeOfTea[]
|
||||
steepingTimeChanges: SteepingTimeChange[]
|
||||
brewingEvents: BrewingEvent[]
|
||||
} = {
|
||||
configuration,
|
||||
typesOfTea,
|
||||
steepingTimeChanges,
|
||||
brewingEvents
|
||||
}
|
|
@ -1,5 +1,15 @@
|
|||
@import "modern-normalize/modern-normalize.css";
|
||||
|
||||
/* fraunces-latin-wght-normal */
|
||||
@font-face {
|
||||
font-family: "Fraunces Variable";
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
font-weight: 100 900;
|
||||
src: url("@fontsource-variable/fraunces/files/fraunces-latin-wght-normal.woff2") format('woff2-variations');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
|
||||
/* fraunces-latin-wght-italic */
|
||||
@font-face {
|
||||
font-family: "Fraunces Variable";
|
||||
|
@ -23,7 +33,7 @@
|
|||
html, body, #app {
|
||||
@apply font-sans;
|
||||
width: 100%;
|
||||
background: theme("colors.gray.3");
|
||||
background: theme("colors.background");
|
||||
color: hsl(0 0% 10%);
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
|
|
21
src/pages/index.vue
Normal file
21
src/pages/index.vue
Normal file
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<PageTitle title="Overview"/>
|
||||
<main class="grid grid-cols-1 2xl:grid-cols-2 gap-8 -mx-2">
|
||||
<Card title="Tea consumption over time">
|
||||
<TeaConsumptionOverTimeChart :types-of-tea-by-id="typesOfTeaById" :brewing-events="brewingEvents"/>
|
||||
</Card>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from "@/components/Card.vue"
|
||||
import TeaConsumptionOverTimeChart from "@/components/charts/TeaConsumptionOverTimeChart.vue"
|
||||
import { fetchBrewingEvents, fetchTypesOfTeaById } from "@/backend"
|
||||
import PageTitle from "@/components/PageTitle.vue"
|
||||
|
||||
const [brewingEvents, typesOfTeaById] = await Promise.all([fetchBrewingEvents(), fetchTypesOfTeaById()])
|
||||
</script>
|
48
src/pages/settings.vue
Normal file
48
src/pages/settings.vue
Normal file
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<PageTitle title="Settings"/>
|
||||
<main class="max-w-800px flex flex-col gap-10">
|
||||
<section>
|
||||
<div class="bg-white rounded-md border-1 border-solid border-gray-200 p-5">
|
||||
<ConfigurationForm :initial-data="configuration"/>
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="font-serif font-300 text-2xl text-orange-900">
|
||||
Types of tea
|
||||
</div>
|
||||
<p class="text-opacity-80 font-serif font-300 text-lg m-0 pb-2">
|
||||
New types of tea are registered when you scan them for the first time.
|
||||
</p>
|
||||
<ul v-if="typesOfTea.length > 0" class="list-none m-0 p-0 flex flex-col gap-4 -mx-2">
|
||||
<li
|
||||
v-for="type in typesOfTea"
|
||||
:key="type.id"
|
||||
class="bg-white rounded-md border-1 border-solid border-gray-200 p-5"
|
||||
>
|
||||
<TypeOfTeaForm :initial-data="type"/>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="flex flex-col gap-0.5 justify-center py-3 pl-8 pr-2">
|
||||
<div class="font-bold text-xl text-gray-500">
|
||||
No types registered yet.
|
||||
</div>
|
||||
<div class="text-gray-700 text-lg font-300">
|
||||
Get started by scanning a type of tea with the machine.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PageTitle from "@/components/PageTitle.vue"
|
||||
import { fetchConfiguration, fetchTypesOfTeaById } from "@/backend"
|
||||
import TypeOfTeaForm from "@/components/specific/TypeOfTeaForm.vue"
|
||||
import ConfigurationForm from "@/components/specific/ConfigurationForm.vue"
|
||||
|
||||
const [typesOfTea, configuration] = await Promise.all([fetchTypesOfTeaById().then(v => [...v.values()]), fetchConfiguration()])
|
||||
</script>
|
11
src/pages/stats.vue
Normal file
11
src/pages/stats.vue
Normal file
|
@ -0,0 +1,11 @@
|
|||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
7
src/utils/getSameButDifferentString.ts
Normal file
7
src/utils/getSameButDifferentString.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
// Can be used to force watchers to rerun when the string stayed the same value-wise but changed semantically.
|
||||
// It’s a hack.
|
||||
export function getSameButDifferentString(original: string) {
|
||||
return {
|
||||
toString: () => original
|
||||
} as unknown as string
|
||||
}
|
|
@ -20,7 +20,9 @@ export default defineConfig({
|
|||
colors: {
|
||||
dark: colors.dark,
|
||||
light: colors.light,
|
||||
gray: colors.warmGray
|
||||
gray: colors.warmGray,
|
||||
orange: colors.orange,
|
||||
background: "hsl(34, 100%, 99%)"
|
||||
}
|
||||
},
|
||||
variants: [
|
||||
|
|
Loading…
Add table
Reference in a new issue