Compare commits
10 Commits
f37e85a459
...
94105a040c
| Author | SHA1 | Date | |
|---|---|---|---|
|
94105a040c
|
|||
|
4c96815106
|
|||
|
3870eb644f
|
|||
|
238b310f84
|
|||
|
864d9dc0d0
|
|||
|
ec09713572
|
|||
|
970a38153e
|
|||
|
03e53aa389
|
|||
|
eecc2b354a
|
|||
|
543fbf575d
|
@@ -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,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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2177
-404
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 +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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
© {{ currentYear }} {{ appName }} ‐ 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>
|
||||
@@ -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 ‐ Smart Temperature & Appliance Control
|
||||
</span>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const isNil = (value: unknown | null | undefined): value is null | undefined =>
|
||||
value === null || value === undefined;
|
||||
+3
-2
@@ -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
@@ -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
@@ -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
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user