Compare commits

...

10 Commits

31 changed files with 2919 additions and 680 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
end_of_line = true
insert_final_newline = true
charset = utf-8
[*.{vue,js,ts,json}]
indent_style = space
indent_size = 2
[*.{rs,yaml}]
indent_style = space
indent_size = 4
[{justfile,*.just}]
indent_style = tab
indent_size = 4
+3 -3
View File
@@ -3,9 +3,9 @@ application:
version: "0.1.0"
rate_limit:
enabled: true
burst_size: 10
per_seconds: 60
enabled: false
burst_size: 100
per_seconds: 10
modbus:
host: 192.168.1.200
+3 -3
View File
@@ -2,11 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>STA</title>
</head>
<body>
<body class="bg-background text-text font-body">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'oxfmt';
export default defineConfig({
ignorePatterns: ['.direnv/**/*', '.gitea/**/*', 'backend/**/*', '**/*.toml', '**/*.md', '.sqlx/**/*'],
printWidth: 120,
tabWidth: 2,
useTabs: false,
semi: true,
singleQuote: true,
trailingComma: 'es5',
sortTailwindcss: true,
sortPackageJson: true,
allowParens: 'always',
jsdoc: true,
sortImports: true,
vueIndentScriptAndStyle: false,
});
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'oxlint';
export default defineConfig({
plugins: ['typescript', 'unicorn', 'oxc', 'vue'],
categories: {
correctness: 'error',
},
rules: {},
env: {
builtin: true,
},
ignorePatterns: ['.direnv/**/*'],
});
+23 -9
View File
@@ -1,25 +1,39 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"generate:api": "curl -s http://localhost:3100/specs > openapi.yaml && openapi-typescript openapi.yaml -o src/api/schema.ts && rm openapi.yaml"
"generate:api": "curl -s http://localhost:3100/specs > openapi.yaml && openapi-typescript openapi.yaml -o src/api/schema.ts && rm openapi.yaml",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check"
},
"dependencies": {
"openapi-fetch": "^0.15.0",
"vue": "^3.5.24"
"@primeuix/themes": "^2.0.3",
"@tailwindcss/vite": "^4.3.0",
"openapi-fetch": "^0.15.2",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"tailwindcss": "^4.3.0",
"vue": "^3.5.34"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@types/node": "^24.12.4",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.8.1",
"openapi-typescript": "^7.10.1",
"less": "^4.6.4",
"less-loader": "^12.3.2",
"openapi-typescript": "^7.13.0",
"oxfmt": "^0.49.0",
"oxlint": "^1.64.0",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
"vite": "^7.3.3",
"vite-plugin-vue-devtools": "^8.1.2",
"vue-tsc": "^3.2.9"
}
}
+2177 -404
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -891,8 +891,8 @@ CLOSED: [2026-05-14 jeu. 20:09]
- *File*: =src/presentation/api/relay_api.rs=
- *Complexity*: Low | *Uncertainty*: Low
*** TODO Frontend Implementation [0/2]
- [ ] *T052* [P] [US1] [TDD] Create =RelayDto= TypeScript interface
*** TODO Frontend Implementation [1/2]
- [X] *T052* [P] [US1] [TDD] Create =RelayDto= TypeScript interface
- Generate from OpenAPI spec or manually define
- *File*: =frontend/src/types/relay.ts=
- *Complexity*: Low | *Uncertainty*: Low
+11 -26
View File
@@ -1,30 +1,15 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<div>
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
<div class="min-h-screen flex flex-col">
<StaHeader />
<main class="grow px-6 py-10 max-w-4xl mx-auto w-full">
<RelaysView />
</main>
<StaFooter />
</div>
<HelloWorld msg="Vite + Vue" />
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>
<script setup lang="ts">
import StaHeader from './components/StaHeader.vue';
import StaFooter from './components/StaFooter.vue';
import RelaysView from './pages/RelaysView.vue';
</script>
+3 -8
View File
@@ -13,17 +13,12 @@ To regenerate the TypeScript client after backend API changes:
1. Start the backend server:
```bash
cargo run
just backend run
```
2. Download the OpenAPI spec:
2. Execute the update script:
```bash
curl http://localhost:3100/specs > openapi.yaml
```
3. Generate TypeScript types:
```bash
pnpm exec openapi-typescript openapi.yaml -o src/api/schema.ts
just frontend sync
```
## Usage Example
+4 -4
View File
@@ -12,11 +12,11 @@
* ```
*/
import createClient from 'openapi-fetch';
import type { paths } from './schema';
import createClient from "openapi-fetch";
import type { paths } from "./schema";
// Get the API base URL from environment variables or default to localhost
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3100';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3100";
/**
* Typed API client instance.
@@ -28,4 +28,4 @@ export const apiClient = createClient<paths>({ baseUrl: API_BASE_URL });
/**
* Re-export the types for convenience
*/
export type { paths, components } from './schema';
export type { paths, components } from "./schema";
+97 -11
View File
@@ -1,10 +1,7 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
/** This file was auto-generated by openapi-typescript. Do not make direct changes to the file. */
export interface paths {
"/api/health": {
'/api/health': {
parameters: {
query?: never;
header?: never;
@@ -20,14 +17,14 @@ export interface paths {
};
requestBody?: never;
responses: {
/** @description Success */
/** Success */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Too Many Requests - rate limit exceeded */
/** Too Many Requests - rate limit exceeded */
429: {
headers: {
[name: string]: unknown;
@@ -44,7 +41,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/meta": {
'/api/meta': {
parameters: {
query?: never;
header?: never;
@@ -60,16 +57,16 @@ export interface paths {
};
requestBody?: never;
responses: {
/** @description Success */
/** Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json; charset=utf-8": components["schemas"]["Meta"];
'application/json; charset=utf-8': components['schemas']['Meta'];
};
};
/** @description Too Many Requests - rate limit exceeded */
/** Too Many Requests - rate limit exceeded */
429: {
headers: {
[name: string]: unknown;
@@ -86,6 +83,76 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/relays': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json; charset=utf-8': components['schemas']['RelayDto'][];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/relays/{id}/toggle': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: {
parameters: {
query?: never;
header?: never;
path: {
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json; charset=utf-8': components['schemas']['RelayDto'];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -95,6 +162,25 @@ export interface components {
version: string;
name: string;
};
/**
* RelayDto
*
* Data Transfer Object for relay information. This struct represents a relay in a serialized format suitable for
* API responses. It contains the relay's ID, current state, and label in a format that can be easily serialized to
* JSON.
*/
RelayDto: {
/**
* Format: uint8
*
* The relay's unique identifier (1-8).
*/
id: number;
/** The relay's current state as a string ("on" or "off"). */
state: string;
/** The relay's user-friendly label. */
label: string;
};
};
responses: never;
parameters: never;
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

-41
View File
@@ -1,41 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<template>
<div
:class="
'relay flex flex-col gap-10 bg-background-100 rounded-lg border-2 p-6 transition-all duration-300 ' +
relayClass
"
>
<div class="flex flex-row justify-between items-center">
<div class="flex flex-row gap-3 items-center">
<i class="pi pi-circle-fill"></i> <i class="pi pi-power-off"></i>
</div>
<div>
<Badge
:value="'Relay ' + props.relay.id"
:severity="isRelayOn ? 'primary' : 'secondary'"
/>
</div>
</div>
<div class="flex flex-row justify-between items-center">
<div>{{ props.relay.label }}</div>
<ToggleSwitch v-model="isRelayOn" v-on:click="toggleRelay(relay.id)" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRelay } from '../composables/useRelay';
import { RelayState, type Relay } from '../types/relay';
import { Badge, ToggleSwitch } from 'primevue';
const props = defineProps<{
relay: Relay;
}>();
const isRelayOn = computed(() => props.relay.state === RelayState.On);
const relayClass = computed(() => {
if (props.relay.state === RelayState.Off) {
return 'border-secondary shadow-md relay-off';
}
return 'border-primary shadow-lg shadow-primary-200 relay-on';
});
const { toggleRelay } = useRelay();
</script>
<style lang="less" scoped>
.relay {
width: 15rem;
&:hover {
scale: 1.02;
}
}
i {
font-weight: 700;
font-size: 1.5rem;
&.pi-circle-fill {
font-size: 1.15rem;
}
.relay-on & {
color: var(--color-primary);
}
.relay-off & {
color: var(--color-secondary-400);
}
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<template>
<footer
class="bg-background-200 border-t border-background-200 px-6 py-4 text-sm text-text"
>
<div class="max-w-4xl mx-auto text-center">
&copy; {{ currentYear }} {{ appName }} &dash; Lucien Cartier-Tilet.
<a href="https://labs.phundrak.com/phundrak/sta"> Source code </a>
under the
<a
href="https://labs.phundrak.com/phundrak/sta/src/branch/develop/LICENSE.md"
>
AGPL 3.0 license </a
>.
</div>
</footer>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useMeta } from '../composables/useMeta';
import { isNil } from '../utils/isNil';
const currentYear = new Date().getFullYear();
const { metadata } = useMeta();
const appName = computed(() =>
isNil(metadata.value)
? 'STA'
: `${metadata.value.name} v${metadata.value.version}`,
);
</script>
<style scoped="scoped">
a {
color: var(--color-secondary-500);
}
@layer base {
a {
@apply underline decoration-wavy underline-offset-2;
}
}
</style>
+11
View File
@@ -0,0 +1,11 @@
<template>
<header
class="sticky top-0 z-10 bg-background-200 border-b border-background-200 shadow-sm px-6 py-4"
>
<nav class="flex items-center justify-between max-w-4xl mx-auto">
<span class="text-lg font-heading">
STA &dash; Smart Temperature & Appliance Control
</span>
</nav>
</header>
</template>
+29
View File
@@ -0,0 +1,29 @@
import { onMounted, ref } from 'vue';
import type { components } from '../api/schema';
import { apiClient } from '../api/client';
type Meta = components['schemas']['Meta'];
export function useMeta() {
const isLoading = ref(false);
const metadata = ref<Meta | null>(null);
const error = ref<string | null>(null);
const getMetadata = async () => {
isLoading.value = true;
try {
const { data } = await apiClient.GET('/api/meta');
error.value = null;
metadata.value = data as Meta;
} catch (err: any) {
console.error('Failed to fetch metadata:', err);
error.value = err.message || 'Failed to fetch metadata';
} finally {
isLoading.value = false;
}
};
onMounted(getMetadata);
return { isLoading, metadata, error };
}
+37
View File
@@ -0,0 +1,37 @@
import { ref } from 'vue';
import { apiClient } from '../api/client';
import { relayDtoToDomain } from '../types/mappers/relayDtoMapper';
import type { Relay, RelayDto } from '../types/relay';
export function useRelay() {
const isLoading = ref(false);
const error = ref<string | null>(null);
const response = ref<Relay | null>(null);
const toggleRelay = async (id: number) => {
isLoading.value = true;
try {
const { data } = await apiClient.POST('/api/relays/{id}/toggle', {
params: {
path: {
id,
},
},
});
error.value = null;
response.value = relayDtoToDomain(data as RelayDto);
} catch (err: any) {
console.error(`Failed to toggle relay ${id}:`, err);
error.value = err.message || `Failed to toggle relay ${id}`;
} finally {
isLoading.value = false;
}
};
return {
toggleRelay,
isLoading,
error,
response,
};
}
+51
View File
@@ -0,0 +1,51 @@
import { onMounted, onUnmounted, ref } from 'vue';
import { apiClient } from '../api/client';
import { relayDtoToDomain } from '../types/mappers/relayDtoMapper';
import type { Relay } from '../types/relay';
import { isNil } from '../utils/isNil';
export function useRelayPolling(intervalMs: number = 2000) {
const relays = ref<Relay[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
let pollingInterval: number | null = null;
const fetchData = async () => {
isLoading.value = true;
try {
const { data } = await apiClient.GET('/api/relays');
relays.value = data?.map(relayDtoToDomain) ?? [];
error.value = null;
} catch (err: any) {
console.error('Polling error:', err);
error.value = err.message || 'Failed to fetch data';
} finally {
isLoading.value = false;
}
};
const startPolling = () => {
fetchData();
pollingInterval = window.setInterval(fetchData, intervalMs);
};
const stopPolling = () => {
if (isNil(pollingInterval)) {
return;
}
clearInterval(pollingInterval);
pollingInterval = null;
};
onMounted(startPolling);
onUnmounted(stopPolling);
return {
relays,
isLoading,
error,
refresh: fetchData,
};
}
+16 -4
View File
@@ -1,5 +1,17 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createApp } from 'vue';
import PrimeVue from 'primevue/config';
import Lara from '@primeuix/themes/lara';
createApp(App).mount('#app')
import 'primeicons/primeicons.css';
import './style.css';
import './style.less';
import App from './App.vue';
const app = createApp(App);
app.use(PrimeVue, {
theme: {
preset: Lara,
},
ripple: true,
});
app.mount('#app');
+30
View File
@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col gap-8">
<div v-if="isLoading && !relays">
<ProgressSpinner class="--p-progressspinner-color-primary" />
</div>
<div
v-else-if="error"
class="bg-accent text-background py-4 px-3 rounded-md"
>
{{ error }}
</div>
<div v-else class="flex flex-row flex-wrap gap-4">
<RelayCard v-for="relay in relays" :relay="relay" />
</div>
<div class="flex flex-row flex-wrap justify-evenly" style="display: none">
<Button severity="primary" class="min-w-2xs">Tout activer</Button>
<Button severity="secondary" class="min-w-2xs">Tout désactiver</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRelayPolling } from '../composables/useRelayPolling';
import { ProgressSpinner } from 'primevue';
import RelayCard from '../components/RelayCard.vue';
import { Button } from 'primevue';
const { relays, isLoading, error, refresh } = useRelayPolling();
refresh();
</script>
+118 -71
View File
@@ -1,79 +1,126 @@
@import url('https://fonts.googleapis.com/css?family=Plus%20Jakarta%20Sans:700|Noto%20Sans:400');
@import "tailwindcss";
@theme {
--font-jakarta: Plus Jakarta Sans, sans-serif;
--font-heading: Plus Jakarta Sans, sans-serif;
--font-noto: Noto Sans, sans-serif;
--font-body: Noto Sans, sans-serif;
--font-normal: 400;
--font-bold: 700;
--text-sm: 0.750rem;
--text-base: 1rem;
--text-xl: 1.333rem;
--text-2xl: 1.777rem;
--text-3xl: 2.369rem;
--text-4xl: 3.158rem;
--text-5xl: 4.210rem;
--color-text: oklch(20.55% 0.026 159.60);
--color-text-50: oklch(96.73% 0.012 164.80);
--color-text-100: oklch(93.53% 0.024 163.13);
--color-text-200: oklch(87.08% 0.048 162.29);
--color-text-300: oklch(80.85% 0.075 161.20);
--color-text-400: oklch(74.56% 0.099 159.20);
--color-text-500: oklch(68.48% 0.121 157.47);
--color-text-600: oklch(58.25% 0.101 157.47);
--color-text-700: oklch(47.56% 0.080 158.24);
--color-text-800: oklch(35.96% 0.056 158.77);
--color-text-900: oklch(23.61% 0.032 159.65);
--color-text-950: oklch(16.99% 0.020 157.52);
--color-background: oklch(98.85% 0.003 174.49);
--color-background-50: oklch(96.66% 0.009 179.60);
--color-background-100: oklch(93.48% 0.020 172.77);
--color-background-200: oklch(86.98% 0.039 173.82);
--color-background-300: oklch(80.46% 0.058 172.26);
--color-background-400: oklch(74.00% 0.077 170.71);
--color-background-500: oklch(67.67% 0.094 169.62);
--color-background-600: oklch(57.52% 0.079 169.17);
--color-background-700: oklch(46.93% 0.062 169.68);
--color-background-800: oklch(35.70% 0.045 170.66);
--color-background-900: oklch(23.47% 0.026 169.60);
--color-background-950: oklch(16.82% 0.014 169.51);
--color-primary: oklch(70.75% 0.113 157.63);
--color-primary-50: oklch(96.73% 0.012 164.80);
--color-primary-100: oklch(93.53% 0.024 163.13);
--color-primary-200: oklch(87.05% 0.049 161.02);
--color-primary-300: oklch(80.82% 0.076 160.38);
--color-primary-400: oklch(74.54% 0.100 158.60);
--color-primary-500: oklch(68.46% 0.122 157.00);
--color-primary-600: oklch(58.22% 0.102 156.89);
--color-primary-700: oklch(47.54% 0.081 157.46);
--color-primary-800: oklch(35.94% 0.057 157.56);
--color-primary-900: oklch(23.61% 0.032 159.65);
--color-primary-950: oklch(16.99% 0.020 157.52);
--color-secondary: oklch(77.49% 0.049 254.33);
--color-secondary-50: oklch(95.88% 0.009 247.92);
--color-secondary-100: oklch(91.80% 0.017 250.85);
--color-secondary-200: oklch(83.27% 0.035 253.73);
--color-secondary-300: oklch(74.79% 0.055 252.87);
--color-secondary-400: oklch(66.02% 0.075 253.94);
--color-secondary-500: oklch(57.42% 0.096 253.86);
--color-secondary-600: oklch(48.91% 0.081 254.25);
--color-secondary-700: oklch(40.26% 0.064 253.43);
--color-secondary-800: oklch(30.86% 0.044 254.23);
--color-secondary-900: oklch(20.97% 0.024 251.59);
--color-secondary-950: oklch(15.30% 0.015 257.65);
--color-accent: oklch(62.74% 0.101 280.46);
--color-accent-50: oklch(95.09% 0.012 281.08);
--color-accent-100: oklch(90.22% 0.024 283.36);
--color-accent-200: oklch(80.23% 0.051 282.68);
--color-accent-300: oklch(69.81% 0.082 281.67);
--color-accent-400: oklch(59.46% 0.112 280.05);
--color-accent-500: oklch(49.09% 0.144 277.36);
--color-accent-600: oklch(42.01% 0.120 277.54);
--color-accent-700: oklch(34.62% 0.096 277.83);
--color-accent-800: oklch(27.07% 0.066 278.62);
--color-accent-900: oklch(18.71% 0.036 279.84);
--color-accent-950: oklch(14.04% 0.022 283.20);
}
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
--p-button-primary-background: var(--color-primary) !important;
--p-button-primary-border-color: var(--color-primary) !important;
--p-button-primary-hover-background: var(--color-primary-400) !important;
--p-button-primary-hover-border-color: var(--color-primary-400) !important;
--p-button-primary-active-background: var(--color-primary-300) !important;
--p-button-primary-active-border-color: var(--color-primary-300) !important;
--p-button-primary-color: var(--color-text) !important;
--p-button-primary-hover-color: var(--color-text) !important;
--p-button-primary-active-color: var(--color-text) !important;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
--p-button-secondary-background: var(--color-secondary) !important;
--p-button-secondary-border-color: var(--color-secondary) !important;
--p-button-secondary-hover-background: var(--color-secondary-400) !important;
--p-button-secondary-hover-border-color: var(--color-secondary-400) !important;
--p-button-secondary-active-background: var(--color-secondary-300) !important;
--p-button-secondary-active-border-color: var(--color-secondary-300) !important;
--p-button-secondary-color: var(--color-text) !important;
--p-button-secondary-hover-color: var(--color-text) !important;
--p-button-secondary-active-color: var(--color-text) !important;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
--p-toggleswitch-border-color: var(--color-secondary-700) !important;
--p-toggleswitch-background: var(--color-secondary-50) !important;
--p-toggleswitch-handle-background: var(--color-secondary-700) !important;
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
--p-toggleswitch-hover-border-color: var(--color-secondary-500) !important;
--p-toggleswitch-hover-background: var(--color-secondary-50) !important;
--p-toggleswitch-handle-hover-background: var(--color-secondary-500) !important;
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
--p-toggleswitch-checked-background: var(--color-primary-400) !important;
--p-toggleswitch-handle-checked-background: var(--color-primary-800) !important;
h1 {
font-size: 3.2em;
line-height: 1.1;
}
--p-toggleswitch-checked-hover-background: var(--color-primary-300) !important;
--p-toggleswitch-handle-checked-hover-background: var(--color-primary-700) !important;
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
--p-badge-primary-background: var(--color-primary) !important;
--p-badge-primary-color: var(--color-text) !important;
--p-badge-secondary-background: var(--color-secondary-400) !important;
--p-badge-secondary-color: var(--color-text) !important;
}
View File
+13
View File
@@ -0,0 +1,13 @@
import { isNil } from '../../utils/isNil';
import { RelayState, Relay, type RelayDto } from '../relay';
const relayStateToDomain = (dto: string | null): RelayState => {
if (isNil(dto) || dto.trim() === '') {
return RelayState.Off;
}
return dto.trim().toLowerCase() === 'on' ? RelayState.On : RelayState.Off;
};
export const relayDtoToDomain = (dto: RelayDto): Relay => {
return new Relay(dto.id, relayStateToDomain(dto.state), dto.label);
};
+20
View File
@@ -0,0 +1,20 @@
import type { components } from '../api/schema';
export type RelayDto = components['schemas']['RelayDto'];
export enum RelayState {
On = 'on',
Off = 'off',
}
export class Relay {
id: number;
state: RelayState;
label: string;
constructor(id: number, state: RelayState, label: string) {
this.id = id;
this.state = state;
this.label = label;
}
}
+2
View File
@@ -0,0 +1,2 @@
export const isNil = (value: unknown | null | undefined): value is null | undefined =>
value === null || value === undefined;
+3 -2
View File
@@ -8,9 +8,10 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
"noUncheckedIndexedAccess": true,
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+8 -3
View File
@@ -1,7 +1,12 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"exclude": [".direnv/**/*"]
}
+3 -2
View File
@@ -18,9 +18,10 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
"noUncheckedIndexedAccess": true,
},
"include": ["vite.config.ts"]
}
+13 -4
View File
@@ -1,7 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import * as path from 'path';
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
import vueDevTools from 'vite-plugin-vue-devtools';
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})
plugins: [vue(), vueDevTools(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});