Redundant commit: only to switch branches. Can be removed.

This commit is contained in:
Jesse Malotaux 2025-04-04 11:52:48 +02:00
commit 59dd711ab3
102 changed files with 34954 additions and 0 deletions

35
fe/README.md Normal file
View file

@ -0,0 +1,35 @@
# fe
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

4
fe/assets/main.css Normal file
View file

@ -0,0 +1,4 @@
html,
body {
background-color: white;
}

19
fe/eslint.config.js Normal file
View file

@ -0,0 +1,19 @@
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
skipFormatting,
]

18
fe/index.html Normal file
View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="src/assets/Macrame-Logo-gradient.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.bunny.net" />
<link
href="https://fonts.bunny.net/css?family=fira-code:300,500,700|roboto:100,300,700"
rel="stylesheet"
/>
<title>Vite + Vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
fe/jsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

5454
fe/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

36
fe/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "fe",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --profile",
"build": "vite build --emptyOutDir",
"preview": "vite preview",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"@tabler/icons-vue": "^3.30.0",
"@tailwindcss/vite": "^4.0.9",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@basitcodeenv/vue3-device-detect": "^1.0.3",
"@eslint/js": "^9.20.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-prettier": "^10.2.0",
"axios": "^1.8.3",
"crypto-js": "^4.2.0",
"eslint": "^9.20.1",
"eslint-plugin-vue": "^9.32.0",
"pinia": "^3.0.1",
"prettier": "^3.5.1",
"sass-embedded": "^1.85.1",
"tailwindcss": "^4.0.9",
"uuid": "^11.1.0",
"vite": "^6.1.0",
"vite-plugin-vue-devtools": "^7.7.2"
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
:root{--vt-c-white: #ffffff;--vt-c-white-soft: #f8f8f8;--vt-c-white-mute: #f2f2f2;--vt-c-black: #181818;--vt-c-black-soft: #222222;--vt-c-black-mute: #282828;--vt-c-indigo: #2c3e50;--vt-c-divider-light-1: rgba(60, 60, 60, .29);--vt-c-divider-light-2: rgba(60, 60, 60, .12);--vt-c-divider-dark-1: rgba(84, 84, 84, .65);--vt-c-divider-dark-2: rgba(84, 84, 84, .48);--vt-c-text-light-1: var(--vt-c-indigo);--vt-c-text-light-2: rgba(60, 60, 60, .66);--vt-c-text-dark-1: var(--vt-c-white);--vt-c-text-dark-2: rgba(235, 235, 235, .64)}:root{--color-background: var(--vt-c-white);--color-background-soft: var(--vt-c-white-soft);--color-background-mute: var(--vt-c-white-mute);--color-border: var(--vt-c-divider-light-2);--color-border-hover: var(--vt-c-divider-light-1);--color-heading: var(--vt-c-text-light-1);--color-text: var(--vt-c-text-light-1);--section-gap: 160px}@media (prefers-color-scheme: dark){:root{--color-background: var(--vt-c-black);--color-background-soft: var(--vt-c-black-soft);--color-background-mute: var(--vt-c-black-mute);--color-border: var(--vt-c-divider-dark-2);--color-border-hover: var(--vt-c-divider-dark-1);--color-heading: var(--vt-c-text-dark-1);--color-text: var(--vt-c-text-dark-2)}}*,*:before,*:after{box-sizing:border-box;margin:0;font-weight:400}body{min-height:100vh;color:var(--color-text);background:var(--color-background);transition:color .5s,background-color .5s;line-height:1.6;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;font-size:15px;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#app{max-width:1280px;margin:0 auto;padding:2rem;font-weight:400}a,.green{text-decoration:none;color:#00bd7e;transition:.4s;padding:3px}@media (hover: hover){a:hover{background-color:#00bd7e33}}@media (min-width: 1024px){body{display:flex;place-items:center}#app{display:grid;grid-template-columns:1fr 1fr;padding:0 2rem}}

14
fe/public/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
<script type="module" crossorigin src="/assets/index-CNkZ911J.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-zqIqfzzx.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

53
fe/src/App.vue Normal file
View file

@ -0,0 +1,53 @@
<template>
<div class="app-background">
<img src="./assets/img/bg-gradient.svg" aria-hidden="true" />
<img src="@/assets/img/Macrame-Logo-white.svg" class="logo" aria-hidden="true" />
</div>
<MainMenu />
<RouterView />
</template>
<script setup>
import MainMenu from '@/components/base/MainMenu.vue'
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useDeviceStore } from './stores/device'
const device = useDeviceStore()
onMounted(() => {
// Setting device uuid from localstorage
// If not present in LocalStorage a new uuidV4 will be generated
device.uuid()
})
</script>
<style scoped>
@reference "@/assets/main.css";
.app-background {
@apply fixed
inset-0
size-full
overflow-hidden
pointer-events-none
opacity-40
z-[-1];
img {
@apply absolute
size-full
object-cover;
}
.logo {
@apply absolute
top-[10%]
left-[10%]
scale-[1.8]
p-28
opacity-35
mix-blend-overlay;
}
}
</style>

View file

@ -0,0 +1,34 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 140 80"
width="140"
height="80"
>
<g>
<path style="fill:#FFB900;" d="M95.5,18.3l-0.2-0.1C95.2,18.1,95,18,94.8,18c-0.3,0-0.5,0.1-0.7,0.3L82.8,29.6l8.5,8.5l12-12
L95.5,18.3z"/>
<path style="fill:#00BCFF;" d="M46,18.3c-0.2-0.2-0.5-0.3-0.7-0.3c-0.2,0-0.4,0-0.5,0.1l-0.2,0.1l-7.8,7.8h0l12,12l8.5-8.5L46,18.3
z"/>
<path style="fill:#00BCFF;" d="M94.8,67.1L94.7,67l-14-14l-8.5,8.5l11.3,11.3c1,1,2.1,1.8,3.2,2.5c2.5,1.5,5.3,2.2,8.1,2.2
c2.8,0,5.6-0.7,8.1-2.2L94.8,67.1z"/>
<path style="fill:#00BCFF;" d="M127.4,28.9l-13.4-13.4l-7.8-7.8c-0.2-0.2-0.5-0.5-0.7-0.7c-5.3-4.6-12.8-5.2-18.7-1.8
c-1.1,0.7-2.2,1.5-3.2,2.5L72.2,19l2.6,2.6l5.9,5.9L92,16.2c0.8-0.8,1.8-1.1,2.8-1.1c0.7,0,1.4,0.2,2,0.5l0.1-0.1l8.5,8.5
l13.4,13.4c0.8,0.8,1.1,1.8,1.1,2.8c0,1-0.4,2.1-1.1,2.8l-11.3,11.3l5,5l3.5,3.5l11.3-11.3v0c3.1-3.1,4.7-7.2,4.7-11.3
C132,36.1,130.5,32,127.4,28.9z"/>
<g>
<path style="fill:#FFB900;" d="M110.4,61.5l-5-5l0,0l-3.5-3.5l-4.5-4.5L81.2,32.2l-8.5-8.5l-2.6-2.6L56.6,7.7
c-1-1-2.1-1.8-3.2-2.5C47.6,1.8,40,2.4,34.8,7c-0.3,0.2-0.5,0.4-0.7,0.7l-7.8,7.8L12.8,28.9v0C9.7,32,8.1,36.1,8.1,40.2
c0,4.1,1.6,8.2,4.7,11.3l11.3,11.3l3.5-3.5l5-5L21.3,43c-0.8-0.8-1.1-1.8-1.1-2.8c0-1,0.4-2.1,1.1-2.8l13.4-13.4l8.5-8.5l0.1,0.1
c1.5-0.9,3.6-0.7,4.8,0.6l11.3,11.3l0,0l2.1,2.1l8.5,8.5l2.1,2.1l0,0l8.5,8.5l16.2,16.2l0,0L97,65l8.4,8.4
c0.3-0.2,0.5-0.4,0.7-0.7l7.8-7.8L110.4,61.5z"/>
</g>
<g>
<path style="fill:#FFB900;" d="M70.1,42.3L70.1,42.3l-8.5,8.5L45.4,67l-0.1,0.1L40.4,72l-3.2,3.2c2.5,1.5,5.3,2.2,8.1,2.2
c2.8,0,5.6-0.8,8.1-2.2c1.1-0.7,2.2-1.5,3.2-2.5l13.4-13.4l8.5-8.5L70.1,42.3z"/>
</g>
<g>
<path style="fill:#00BCFF;" d="M59.5,31.7L51,40.2L38.2,53l-3.5,3.5l0,0l-5,5L26.2,65l7.8,7.8c0.2,0.2,0.5,0.5,0.7,0.7l3.5-3.5
l4.9-4.9l0.1-0.1l16.2-16.2l8.5-8.5L59.5,31.7z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,60 @@
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px" y="0px"
viewBox="0 0 140 80"
style="enable-background:new 0 0 140 80;"
xml:space="preserve">
<style type="text/css">
.st0{fill:url(#SVGID_1_);}
.st1{fill:url(#SVGID_2_);}
.st2{fill:url(#SVGID_3_);}
.st3{fill:url(#SVGID_4_);}
.st4{fill:url(#SVGID_5_);}
.st5{fill:url(#SVGID_6_);}
.st6{fill:url(#SVGID_7_);}
</style>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-9.094947e-13" y1="28.05" x2="140" y2="28.05">
<stop offset="0" style="stop-color:#FFB900"/>
<stop offset="1" style="stop-color:#00BCFF"/>
</linearGradient>
<path class="st0" d="M95.5,18.3l-0.2-0.1C95.2,18.1,95,18,94.8,18c-0.3,0-0.5,0.1-0.7,0.3L82.8,29.6l8.5,8.5l12-12L95.5,18.3z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="-9.094947e-13" y1="28" x2="140" y2="28">
<stop offset="0" style="stop-color:#FFB900"/>
<stop offset="1" style="stop-color:#00BCFF"/>
</linearGradient>
<path class="st1" d="M57.3,29.5L46,18.3c-0.2-0.2-0.5-0.3-0.7-0.3s-0.4,0-0.5,0.1l-0.2,0.1L36.8,26l12,12L57.3,29.5z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="-9.094947e-13" y1="65.25" x2="140" y2="65.25">
<stop offset="0" style="stop-color:#FFB900"/>
<stop offset="1" style="stop-color:#00BCFF"/>
</linearGradient>
<path class="st2" d="M94.7,67l-14-14l-8.5,8.5l11.3,11.3c1,1,2.1,1.8,3.2,2.5c2.5,1.5,5.3,2.2,8.1,2.2s5.6-0.7,8.1-2.2L94.7,67
L94.7,67z"/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="-9.094947e-13" y1="32.9162" x2="140" y2="32.9162">
<stop offset="0" style="stop-color:#FFB900"/>
<stop offset="1" style="stop-color:#00BCFF"/>
</linearGradient>
<path class="st3" d="M114,15.5l-7.8-7.8c-0.2-0.2-0.5-0.5-0.7-0.7c-5.3-4.6-12.8-5.2-18.7-1.8c-1.1,0.7-2.2,1.5-3.2,2.5L72.2,19
l2.6,2.6l5.9,5.9L92,16.2c0.8-0.8,1.8-1.1,2.8-1.1c0.7,0,1.4,0.2,2,0.5l0.1-0.1l8.5,8.5l13.4,13.4c0.8,0.8,1.1,1.8,1.1,2.8
s-0.4,2.1-1.1,2.8l-11.3,11.3l5,5l3.5,3.5l11.3-11.3c3.1-3.1,4.7-7.2,4.7-11.3c0-4.1-1.5-8.2-4.6-11.3L114,15.5z"/>
<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="-9.094947e-13" y1="38.2163" x2="140" y2="38.2163">
<stop offset="0" style="stop-color:#FFB900"/>
<stop offset="1" style="stop-color:#00BCFF"/>
</linearGradient>
<path class="st4" d="M105.4,56.5l-3.5-3.5l-4.5-4.5L81.2,32.2l-8.5-8.5l-2.6-2.6L56.6,7.7c-1-1-2.1-1.8-3.2-2.5
C47.6,1.8,40,2.4,34.8,7c-0.3,0.2-0.5,0.4-0.7,0.7l-7.8,7.8L12.8,28.9C9.7,32,8.1,36.1,8.1,40.2c0,4.1,1.6,8.2,4.7,11.3l11.3,11.3
l3.5-3.5l5-5L21.3,43c-0.8-0.8-1.1-1.8-1.1-2.8s0.4-2.1,1.1-2.8L34.7,24l8.5-8.5l0.1,0.1c1.5-0.9,3.6-0.7,4.8,0.6l11.3,11.3l2.1,2.1
l8.5,8.5l2.1,2.1l8.5,8.5l16.2,16.2L97,65l8.4,8.4c0.3-0.2,0.5-0.4,0.7-0.7l7.8-7.8l-3.5-3.4L105.4,56.5z"/>
<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-9.094947e-13" y1="59.85" x2="140" y2="59.85">
<stop offset="0" style="stop-color:#FFB900"/>
<stop offset="1" style="stop-color:#00BCFF"/>
</linearGradient>
<path class="st5" d="M70.1,42.3l-8.5,8.5L45.4,67l-0.1,0.1L40.4,72l-3.2,3.2c2.5,1.5,5.3,2.2,8.1,2.2s5.6-0.8,8.1-2.2
c1.1-0.7,2.2-1.5,3.2-2.5L70,59.3l8.5-8.5L70.1,42.3z"/>
<linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="-9.094947e-13" y1="52.6" x2="140" y2="52.6">
<stop offset="0" style="stop-color:#FFB900"/>
<stop offset="1" style="stop-color:#00BCFF"/>
</linearGradient>
<path class="st6" d="M43.1,65.1l0.1-0.1l16.2-16.2l8.5-8.5l-8.4-8.6L51,40.2L38.2,53l-3.5,3.5l-5,5L26.2,65l7.8,7.8
c0.2,0.2,0.5,0.5,0.7,0.7l3.5-3.5L43.1,65.1z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -0,0 +1,23 @@
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px" y="0px"
viewBox="0 0 140 80"
style="enable-background:new 0 0 140 80;"
xml:space="preserve">
<path style="fill:#fff" d="M95.5,18.3l-0.2-0.1C95.2,18.1,95,18,94.8,18c-0.3,0-0.5,0.1-0.7,0.3L82.8,29.6l8.5,8.5l12-12L95.5,18.3z"/>
<path style="fill:#fff" d="M57.3,29.5L46,18.3c-0.2-0.2-0.5-0.3-0.7-0.3s-0.4,0-0.5,0.1l-0.2,0.1L36.8,26l12,12L57.3,29.5z"/>
<path style="fill:#fff" d="M94.7,67l-14-14l-8.5,8.5l11.3,11.3c1,1,2.1,1.8,3.2,2.5c2.5,1.5,5.3,2.2,8.1,2.2s5.6-0.7,8.1-2.2L94.7,67
L94.7,67z"/>
<path style="fill:#fff" d="M114,15.5l-7.8-7.8c-0.2-0.2-0.5-0.5-0.7-0.7c-5.3-4.6-12.8-5.2-18.7-1.8c-1.1,0.7-2.2,1.5-3.2,2.5L72.2,19
l2.6,2.6l5.9,5.9L92,16.2c0.8-0.8,1.8-1.1,2.8-1.1c0.7,0,1.4,0.2,2,0.5l0.1-0.1l8.5,8.5l13.4,13.4c0.8,0.8,1.1,1.8,1.1,2.8
s-0.4,2.1-1.1,2.8l-11.3,11.3l5,5l3.5,3.5l11.3-11.3c3.1-3.1,4.7-7.2,4.7-11.3c0-4.1-1.5-8.2-4.6-11.3L114,15.5z"/>
<path style="fill:#fff" d="M105.4,56.5l-3.5-3.5l-4.5-4.5L81.2,32.2l-8.5-8.5l-2.6-2.6L56.6,7.7c-1-1-2.1-1.8-3.2-2.5
C47.6,1.8,40,2.4,34.8,7c-0.3,0.2-0.5,0.4-0.7,0.7l-7.8,7.8L12.8,28.9C9.7,32,8.1,36.1,8.1,40.2c0,4.1,1.6,8.2,4.7,11.3l11.3,11.3
l3.5-3.5l5-5L21.3,43c-0.8-0.8-1.1-1.8-1.1-2.8s0.4-2.1,1.1-2.8L34.7,24l8.5-8.5l0.1,0.1c1.5-0.9,3.6-0.7,4.8,0.6l11.3,11.3l2.1,2.1
l8.5,8.5l2.1,2.1l8.5,8.5l16.2,16.2L97,65l8.4,8.4c0.3-0.2,0.5-0.4,0.7-0.7l7.8-7.8l-3.5-3.4L105.4,56.5z"/>
<path style="fill:#fff" d="M70.1,42.3l-8.5,8.5L45.4,67l-0.1,0.1L40.4,72l-3.2,3.2c2.5,1.5,5.3,2.2,8.1,2.2s5.6-0.8,8.1-2.2
c1.1-0.7,2.2-1.5,3.2-2.5L70,59.3l8.5-8.5L70.1,42.3z"/>
<path style="fill:#fff" d="M43.1,65.1l0.1-0.1l16.2-16.2l8.5-8.5l-8.4-8.6L51,40.2L38.2,53l-3.5,3.5l-5,5L26.2,65l7.8,7.8
c0.2,0.2,0.5,0.5,0.7,0.7l3.5-3.5L43.1,65.1z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 2560 1440" style="enable-background:new 0 0 2560 1440;" xml:space="preserve">
<rect style="fill:#020618;" width="2560" height="1440"/>
<radialGradient id="SVGID_1_" cx="1280" cy="720" r="507.7116" fx="1274.7371" fy="1155.8185" gradientTransform="matrix(1 0 0 2.2985 0 -934.9553)" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#00BCFF;stop-opacity:0.5"/>
<stop offset="1" style="stop-color:#00BCFF;stop-opacity:0"/>
</radialGradient>
<rect style="opacity:0.55;fill:url(#SVGID_1_);" width="2560" height="1440"/>
<radialGradient id="SVGID_2_" cx="1352.0476" cy="1354.1904" r="1334.0841" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#00BCFF;stop-opacity:0.5"/>
<stop offset="1" style="stop-color:#00BCFF;stop-opacity:0"/>
</radialGradient>
<rect style="opacity:0.55;fill:url(#SVGID_2_);" width="2560" height="1440"/>
<radialGradient id="SVGID_3_" cx="1292.0344" cy="1255.0016" r="2246.7517" gradientTransform="matrix(-0.7144 -0.6998 0.1899 -0.1939 1976.6873 2402.437)" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#00BCFF;stop-opacity:0.5"/>
<stop offset="1" style="stop-color:#00BCFF;stop-opacity:0"/>
</radialGradient>
<polygon style="opacity:0.55;fill:url(#SVGID_3_);" points="2560,1440 0,1440 0,-7 2560,0 "/>
<radialGradient id="SVGID_4_" cx="1292.0344" cy="1255.8966" r="2246.5256" fx="334.4712" fy="1265.3895" gradientTransform="matrix(0.7144 -0.6998 -0.1899 -0.1939 583.4827 2403.5054)" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#00BCFF;stop-opacity:0.5"/>
<stop offset="1" style="stop-color:#00BCFF;stop-opacity:0"/>
</radialGradient>
<polygon style="opacity:0.55;fill:url(#SVGID_4_);" points="0,1440 2560,1440 2560,0 0,0 "/>
<radialGradient id="SVGID_5_" cx="1239.8966" cy="1737.5518" r="877.3733" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#FFB900"/>
<stop offset="1" style="stop-color:#FFB900;stop-opacity:0"/>
</radialGradient>
<rect style="fill:url(#SVGID_5_);" width="2560" height="1440"/>
<radialGradient id="SVGID_6_" cx="1287.069" cy="950.5172" r="845.7465" fx="1276.8361" fy="325.8423" gradientTransform="matrix(-1 3.730347e-03 -1.479320e-03 -0.3966 2575.5352 1322.6541)" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#FFB900;stop-opacity:0.3"/>
<stop offset="1" style="stop-color:#FFB900;stop-opacity:0"/>
</radialGradient>
<rect style="fill:url(#SVGID_6_);" width="2560" height="1440"/>
<radialGradient id="SVGID_7_" cx="1316.8621" cy="1417.2759" r="1888.6272" gradientTransform="matrix(0.6652 -0.7467 0.1801 0.1604 185.7137 2173.2124)" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#FFB900;stop-opacity:0.38"/>
<stop offset="1" style="stop-color:#FFB900;stop-opacity:0"/>
</radialGradient>
<rect style="fill:url(#SVGID_7_);" width="2560" height="1440"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

89
fe/src/assets/main.css Normal file
View file

@ -0,0 +1,89 @@
@import "./style/_macro.css";
@import "./style/_mcrm-block.css";
@import "./style/_panel.css";
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: "Roboto", sans-serif;
--font-mono: "Fira Code", monospace;
}
body {
@apply font-sans
font-light
tracking-wide
bg-slate-900
text-slate-50;
}
h1,
h2 {
@apply font-mono
font-bold;
}
h3,
h4,
h5,
h6 {
@apply font-semibold;
}
h1 {
@apply text-4xl;
}
h2 {
@apply text-3xl;
}
h3 {
@apply text-2xl;
}
h4 {
@apply text-xl;
}
h5 {
@apply text-lg;
}
input {
@apply w-full
px-2 py-1
border
border-slate-400
text-white
rounded-md
bg-black/20;
}
:has(> input + span) {
@apply flex;
input {
@apply rounded-r-none;
}
span {
@apply flex
items-center
px-2
rounded-r-md
text-white
bg-slate-400;
}
}
ul {
@apply list-disc
list-inside;
}
strong {
@apply font-bold;
}

View file

@ -0,0 +1,28 @@
/* @reference "main"; */
hr.spacer {
@apply relative
w-6
border
border-gray-300
opacity-80
overflow-visible;
&::before,
&::after {
@apply content-['']
absolute
top-1/2
-translate-y-1/2
size-2
bg-gray-300
rounded-full;
}
&::before {
@apply -left-1;
}
&::after {
@apply -right-1;
}
}

View file

@ -0,0 +1,107 @@
.mcrm-block {
@apply relative
p-6
gap-x-6
gap-y-2
backdrop-blur-lg
rounded-2xl
overflow-hidden;
&::before {
@apply content-['']
absolute
inset-0
p-px
rounded-2xl
size-full
bg-gradient-to-br
to-transparent
z-[-1];
mask:
linear-gradient(#000 0 0) exclude,
linear-gradient(#000 0 0) content-box;
}
&.block__light {
@apply bg-white/20;
&::before {
@apply from-white/20;
}
}
&.block__dark {
@apply bg-slate-900/70;
&::before {
@apply from-slate-400/40;
}
}
&.block__primary {
@apply bg-sky-300/40;
&::before {
@apply from-sky-100/40;
}
}
&.block__secondary {
@apply bg-amber-300/40;
&::before {
@apply from-amber-100/40;
}
}
&.block__success {
@apply bg-emerald-300/40;
&::before {
@apply from-emerald-100/40;
}
}
&.block__warning {
@apply bg-orange-300/40;
&::before {
@apply from-orange-100/40;
}
}
&.block__danger {
@apply bg-rose-300/40;
&::before {
@apply from-rose-100/40;
}
}
&.block-spacing__sm,
&.block-size__sm {
@apply p-4 gap-x-4 gap-y-2;
}
&.block-size__sm {
@apply rounded-lg;
&::before {
@apply rounded-lg;
}
}
&.block-spacing__lg,
&.block-size__lg {
@apply p-8 gap-x-8 gap-y-4;
}
&.block-size__lg {
@apply rounded-3xl;
&::before {
@apply rounded-3xl;
}
}
}

View file

@ -0,0 +1,42 @@
.panel {
@apply grid
grid-rows-[auto_1fr]
fixed
top-2
left-4 sm:left-16
right-4 sm:right-16
bottom-2
overflow-hidden;
> .panel__header,
> .panel__title {
@apply px-4 py-2;
/* &:first-child {
@apply pt-4;
}
&:last-child {
@apply pb-4;
} */
}
.panel__title {
@apply bg-gradient-to-r
w-fit
from-amber-300
to-white/50
pt-3
pl-16 sm:pl-4
bg-clip-text
text-transparent;
}
.panel__content {
@apply grid
h-full
pt-4 sm:pt-0
pl-0 sm:pl-4
overflow-auto;
}
}

View file

@ -0,0 +1,39 @@
<template>
<div class="accordion">
<header>
<slot name="title" />
</header>
<section :class="`accordion__content ${open ? 'open' : ''}`">
<div>
<slot name="content" />
</div>
</section>
</div>
</template>
<script setup>
defineProps({
open: Boolean,
})
</script>
<style scoped>
@reference "@/assets/main.css";
.accordion {
@apply grid;
.accordion__content {
@apply grid
grid-rows-[0fr]
overflow-hidden
duration-300
ease-in-out;
div {
@apply grid
grid-rows-[0fr];
}
}
}
</style>

View file

@ -0,0 +1,55 @@
<template>
<div :class="`alert alert__${type}`">
<IconInfoCircle v-if="type === 'info'" />
<IconCheck v-if="type === 'success'" />
<IconExclamationCircle v-if="type === 'warning'" />
<IconAlertTriangle v-if="type === 'error'" />
<slot />
</div>
</template>
<script setup>
import {
IconAlertTriangle,
IconCheck,
IconExclamationCircle,
IconInfoCircle,
} from '@tabler/icons-vue'
defineProps({
type: String, // info, success, warning, error
})
</script>
<style scoped>
@reference "@/assets/main.css";
.alert {
@apply grid
grid-cols-[1rem_1fr]
items-start
gap-4
p-4
border
border-white/10
bg-white/10
rounded-md
backdrop-blur-md;
&.alert__info {
@apply text-sky-100 bg-sky-400/40;
}
&.alert__success {
@apply text-lime-400 bg-lime-400/10;
}
&.alert__warning {
@apply text-amber-400 bg-amber-400/10;
}
&.alert__error {
@apply text-rose-400 bg-rose-400/10;
}
}
</style>

View file

@ -0,0 +1,157 @@
<template>
<template v-if="href">
<a :href="href" :class="classString">
<slot />
</a>
</template>
<template v-else>
<button :class="classString">
<slot />
</button>
</template>
</template>
<script setup>
import { computed, onMounted } from 'vue'
const props = defineProps({
href: String,
variant: String,
size: String,
})
const classString = computed(() => {
let classes = 'btn'
if (props.variant) classes += ` btn__${props.variant}`
if (props.size) classes += ` btn__${props.size}`
return classes
})
</script>
<style>
@reference "@/assets/main.css";
button,
.btn {
@apply flex
items-center
gap-3
h-fit
px-4 py-2
border
border-solid
rounded-lg
tracking-wide
font-normal
transition-all
cursor-pointer;
transition:
border-color 0.1s ease-in-out,
background-color 0.2s ease;
&:not(.button__subtle, .button__ghost):hover {
@apply shadow-black;
}
&[disabled],
&.disabled {
@apply opacity-50 pointer-events-none cursor-not-allowed;
}
svg {
@apply size-5 transition-[stroke] duration-400 ease-in-out;
}
&.btn__sm svg {
@apply size-4;
}
&.btn__lg svg {
@apply size-6;
}
&:hover {
@apply !text-white;
svg {
@apply !stroke-white;
}
}
&.btn__primary {
@apply bg-sky-100/10 border-sky-100 text-sky-100;
svg {
@apply stroke-sky-200;
}
&:hover {
@apply bg-sky-400/40 border-sky-300;
}
}
&.btn__secondary {
@apply bg-amber-100/10 border-amber-100 text-amber-100;
svg {
@apply stroke-amber-300;
}
&:hover {
@apply bg-amber-400/40 border-amber-400;
}
}
&.btn__danger {
@apply bg-rose-200/20 border-rose-100 text-rose-200;
svg {
@apply stroke-rose-400;
}
&:hover {
@apply bg-rose-400/40 border-rose-500 text-white;
}
}
&.btn__dark {
/* @apply bg-slate-700/80 hover:bg-slate-700 text-white border-slate-600; */
@apply bg-slate-200/10 border-slate-400 text-slate-100;
svg {
@apply stroke-slate-300;
}
&:hover {
@apply bg-slate-400/40 border-slate-200 text-white;
}
}
&.btn__success {
/* @apply bg-lime-500/80 hover:bg-lime-500 text-white border-lime-600; */
@apply bg-lime-200/10 border-lime-100 text-lime-100;
svg {
@apply stroke-lime-400;
}
&:hover {
@apply bg-lime-400/40 border-lime-500 text-white;
}
}
&.btn__subtle {
@apply bg-transparent hover:bg-white/10 text-white border-transparent;
&:hover {
@apply bg-white/20 to-white/30 border-white/40;
}
}
&.btn__ghost {
@apply bg-transparent text-white/80 border-transparent hover:text-white;
}
}
</style>

View file

@ -0,0 +1,15 @@
<template>
<div class="button-group">
<slot />
</div>
</template>
<script setup>
defineProps({
variant: String,
})
</script>
<style scoped>
@reference "@/assets/main.css";
</style>

View file

@ -0,0 +1,84 @@
<template>
<div class="context-menu">
<div class="context-menu__trigger" @click="toggle">
<slot name="trigger" />
</div>
<div :class="`context-menu__content ${menuOpen ? 'open' : ''}`">
<slot name="content" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUpdated } from 'vue'
defineExpose({ toggle })
const props = defineProps({
open: Boolean,
})
const menuOpen = ref(false)
onMounted(() => {
menuOpen.value = props.open
})
function toggle() {
console.log('toggle')
menuOpen.value = !menuOpen.value
}
</script>
<style>
@reference "@/assets/main.css";
.context-menu {
@apply relative;
.context-menu__content {
@apply absolute
top-full
-translate-y-full
opacity-0
pointer-events-none
mt-2
min-w-full
grid
border
border-white/50
bg-slate-100/60
backdrop-blur-3xl
text-slate-800
rounded-md
z-50
transition-all;
&.open {
@apply translate-y-0
opacity-100
pointer-events-auto;
}
}
}
.context-menu ul {
@apply text-slate-800
divide-y
divide-slate-300;
li {
@apply flex
gap-2
items-center
p-2
hover:bg-black/10
cursor-pointer;
svg {
@apply size-5;
}
}
}
</style>

View file

@ -0,0 +1,111 @@
<template>
<div class="dialog-container">
<div class="trigger" @click="toggleDialog(true)">
<slot name="trigger" />
</div>
<dialog ref="dialog" class="mcrm-block block__dark">
<ButtonComp
class="dialog__close p-0"
variant="ghost"
size="sm"
tabindex="-1"
@click="toggleDialog(false)"
>
<IconX />
</ButtonComp>
<slot name="content" />
</dialog>
</div>
</template>
<script setup>
import ButtonComp from './ButtonComp.vue'
import { IconX } from '@tabler/icons-vue'
import { onMounted, onUpdated, ref } from 'vue'
const dialog = ref(null)
const openDialog = ref()
const emit = defineEmits(['onOpen', 'onClose', 'onToggle'])
defineExpose({ toggleDialog })
const props = defineProps({
open: Boolean,
})
onMounted(() => {
if (props.open === true) toggleDialog(props.open)
})
onUpdated(() => {
if (props.open === true) toggleDialog(props.open)
})
function toggleDialog(openToggle) {
if (openToggle) {
dialog.value.showModal()
emit('onOpen')
} else {
dialog.value.close()
emit('onClose')
}
openDialog.value = openToggle
emit('onToggle')
}
onMounted(() => {
openDialog.value = props.open
if (dialog.value.innerHTML.includes('form')) {
dialog.value.querySelector('form').addEventListener('submit', () => {
toggleDialog()
})
}
})
</script>
<style>
@reference "@/assets/main.css";
.dialog-container {
@apply relative;
dialog {
@apply fixed
top-1/2 left-1/2
-translate-x-1/2 -translate-y-1/2
max-w-[calc(100vw-2rem)]
text-slate-200
/* shadow-md */
/* shadow-black */
z-50
pointer-events-none;
&[open] {
@apply pointer-events-auto;
}
&::backdrop {
@apply bg-black/50 backdrop-blur-xs transition;
}
.dialog__close {
@apply absolute
top-4 right-4
p-0
text-white;
svg {
@apply size-5;
}
}
}
}
.dialog__content {
> *:first-child {
@apply pr-8;
}
}
</style>

View file

@ -0,0 +1,142 @@
<template>
<nav id="main-menu">
<button
id="menu-toggle"
:class="menuOpen ? 'open' : ''"
@click="menuOpen = !menuOpen"
>
<img
class="logo p-1"
:class="{ 'opacity-0': menuOpen }"
src="@/assets/img/Macrame-Logo-gradient.svg"
aria-hidden="true"
/>
<IconX :class="{ 'opacity-0': !menuOpen }" />
</button>
<ul :class="menuOpen ? 'open' : ''">
<li>
<RouterLink @click="menuOpen = false" to="/">
<IconHome />Dashboard
</RouterLink>
</li>
<li>
<RouterLink @click="menuOpen = false" to="/panels">
<IconLayoutGrid />Panels
</RouterLink>
</li>
<li>
<RouterLink @click="menuOpen = false" to="/macros">
<IconKeyboard />Macros
</RouterLink>
</li>
<li>
<RouterLink @click="menuOpen = false" to="/devices">
<IconDevices />Device
</RouterLink>
</li>
<li>
<RouterLink @click="menuOpen = false" to="/settings">
<IconSettings />Settings
</RouterLink>
</li>
</ul>
</nav>
</template>
<script setup>
import { RouterLink } from "vue-router";
import {
IconDevices,
IconHome,
IconKeyboard,
IconLayoutGrid,
IconSettings,
IconX,
} from "@tabler/icons-vue";
import { ref } from "vue";
const menuOpen = ref(false);
</script>
<style>
@reference "@/assets/main.css";
nav {
@apply relative flex z-50;
button {
@apply absolute
top-4 left-4
size-12
rounded-full
aspect-square
bg-white/20 hover:bg-white/40
border-0
cursor-pointer
transition-colors
backdrop-blur-md;
.logo,
svg {
@apply absolute
inset-1/2
-translate-1/2
transition-opacity
duration-400
ease-in-out;
}
.logo {
@apply w-full;
}
}
ul {
@apply absolute
top-20 left-0
-translate-x-full
grid
list-none
rounded-xl
overflow-hidden
bg-white/10
backdrop-blur-md
divide-y
divide-slate-600
transition-transform
duration-300
ease-in-out;
&.open {
@apply left-4 translate-x-0;
}
li {
a {
@apply flex
items-center
gap-2
px-4 py-2
border-transparent
transition-colors;
svg {
@apply text-white/40 transition-colors;
}
&:hover {
@apply bg-white/20;
svg {
@apply text-white;
}
}
&.router-link-active {
@apply text-sky-300
bg-sky-200/20;
}
}
}
}
}
</style>

View file

@ -0,0 +1,196 @@
<template>
<div class="server-overview">
<AlertComp type="info">
<div class="grid">
<strong>This is a remote device.</strong>
<em>UUID: {{ device.uuid() }} </em>
</div>
</AlertComp>
<div class="mcrm-block block__light grid gap-4">
<h4 class="text-lg flex gap-4 items-center justify-between">
<span class="flex gap-4"><IconServer />Server</span>
<ButtonComp variant="primary" @click="checkServerStatus()"><IconReload /></ButtonComp>
</h4>
<p>
Connected to: <strong>{{ server.host }}</strong>
</p>
<!-- Alerts -->
<AlertComp v-if="server.status === 'authorized'" type="success">Authorized</AlertComp>
<AlertComp v-if="server.status === 'unlinked'" type="warning">Not linked</AlertComp>
<AlertComp v-if="server.status === 'unauthorized'" type="info">
<div class="grid gap-2">
<strong>Access requested</strong>
<p>
Navigate to <em class="font-semibold">http://localhost:6970/devices</em> on your pc to
authorize.
</p>
<template v-if="server.link == 'checking'">
<div class="grid grid-cols-[2rem_1fr] gap-2">
<IconReload class="animate-spin" />
Checking server for link...
</div>
</template>
<template v-if="server.link === false">
<ButtonComp variant="subtle" @click="pingLink()" class="w-fit">
<IconReload />Check for server link
</ButtonComp>
</template>
</div>
</AlertComp>
<ButtonComp
v-if="server.status === 'unauthorized'"
variant="primary"
@click="requestAccess()"
>
<IconKey />
Request access
</ButtonComp>
<ButtonComp
variant="danger"
v-if="server.status === 'authorized'"
@click="disonnectFromServer()"
>
<IconPlugConnectedX />
Disconnect
</ButtonComp>
</div>
<DialogComp ref="linkPinDialog">
<template #content>
<div class="grid gap-4 w-64">
<h3>Server link pin:</h3>
<form class="grid gap-4" @submit.prevent="decryptKey()">
<input
class="input"
id="input-pin"
type="text"
pattern="[0-9]{4}"
v-model="server.inputPin"
/>
<ButtonComp variant="primary">Enter</ButtonComp>
</form>
</div>
</template>
</DialogComp>
</div>
</template>
<script setup>
// TODO
// - [Delete local key button]
// - if not local key
// - - if !checkAccess -> requestAccess -> put settings.json (go)
// - - if checkAccess -> pingLink -> check for device.tmp (go)
// - - if [devicePin] -> handshake -> save key local, close dialog, update server status
import { IconKey, IconPlugConnectedX, IconReload, IconServer } from '@tabler/icons-vue'
import AlertComp from '../base/AlertComp.vue'
import ButtonComp from '../base/ButtonComp.vue'
import { onMounted, onUpdated, reactive, ref } from 'vue'
import { useDeviceStore } from '@/stores/device'
import { deviceType, deviceModel, deviceVendor } from '@basitcodeenv/vue3-device-detect'
import DialogComp from '../base/DialogComp.vue'
import { AuthCall, decryptAES } from '@/services/EncryptService'
import axios from 'axios'
import { appUrl } from '@/services/ApiService'
const device = useDeviceStore()
const linkPinDialog = ref()
const server = reactive({
host: '',
status: false,
link: false,
inputPin: '',
encryptedKey: '',
key: '',
})
onMounted(async () => {
server.host = window.location.host
})
onUpdated(() => {
if (!server.status) checkServerStatus()
})
async function checkServerStatus(request = true) {
const status = await device.remoteCheckServerAccess()
server.status = status
if (status === 'unlinked' || status === 'unauthorized') {
if (request) requestAccess()
return true
}
if (!device.key()) {
server.status = 'unauthorized'
return true
}
const handshake = await device.remoteHandshake(device.key())
if (handshake) server.key = device.key()
else {
device.removeDeviceKey()
server.status = 'unlinked'
if (request) requestAccess()
}
return true
}
function requestAccess() {
let deviceName = `${deviceVendor() ? deviceVendor() : 'Unknown'} ${deviceVendor() ? deviceModel() : deviceType()}`
device.remoteRequestServerAccess(deviceName, deviceType()).then((data) => {
if (data.data) (server.status = data.data), pingLink()
})
}
function pingLink() {
server.link = 'checking'
device.remotePingLink((encryptedKey) => {
server.link = true
server.encryptedKey = encryptedKey
linkPinDialog.value.toggleDialog(true)
})
}
async function decryptKey() {
const decryptedKey = decryptAES(server.inputPin, server.encryptedKey)
const handshake = await device.remoteHandshake(decryptedKey)
if (handshake) {
device.setDeviceKey(decryptedKey)
server.key = decryptedKey
linkPinDialog.value.toggleDialog(false)
server.status = 'authorized'
}
}
function disonnectFromServer() {
axios.post(appUrl() + '/device/link/remove', AuthCall({ uuid: device.uuid() })).then((data) => {
if (data.data) checkServerStatus(false)
})
}
</script>
<style scoped>
@reference "@/assets/main.css";
.server-overview {
@apply grid
gap-4
content-start;
}
#input-pin {
}
</style>

View file

@ -0,0 +1,151 @@
<template>
<div class="device-overview">
<AlertComp type="info">
<div class="grid">
<strong>This is a server!</strong>
<em>UUID: {{ device.uuid() }} </em>
</div>
</AlertComp>
<div class="mcrm-block block__light flex flex-wrap items-start gap-4">
<h4 class="w-full flex gap-4 items-center justify-between mb-4">
<span class="flex gap-4"> <IconDevices />Remote devices </span>
<ButtonComp variant="primary" @click="device.serverGetRemotes()"><IconReload /></ButtonComp>
</h4>
<!-- {{ Object.keys(remote.devices).length }} -->
<template v-if="Object.keys(remote.devices).length > 0">
<div
class="mcrm-block block__dark block-size__sm w-64 grid !gap-4 content-start"
v-for="(remoteDevice, id) in remote.devices"
:key="id"
>
<div class="grid gap-2">
<h5 class="grid grid-cols-[auto_1fr] gap-2">
<IconDeviceUnknown v-if="remoteDevice.settings.type == 'unknown'" />
<IconDeviceMobile v-if="remoteDevice.settings.type == 'mobile'" />
<IconDeviceTablet v-if="remoteDevice.settings.type == 'tablet'" />
<IconDeviceDesktop v-if="remoteDevice.settings.type == 'desktop'" />
<span class="w-full truncate">
{{ remoteDevice.settings.name }}
</span>
</h5>
<em>{{ id }}</em>
</div>
<template v-if="remoteDevice.key">
<AlertComp type="success">Authorized</AlertComp>
<ButtonComp variant="danger" @click="unlinkDevice(id)">
<IconLinkOff />Unlink device
</ButtonComp>
</template>
<template v-else>
<AlertComp type="warning">Unauthorized</AlertComp>
<ButtonComp variant="primary" @click="startLink(id)">
<IconLink />Link device
</ButtonComp>
</template>
<template v-if="remote.pinlink.uuid == id">
<AlertComp type="info">One time pin: {{ remote.pinlink.pin }}</AlertComp>
</template>
</div>
</template>
<template v-else>
<div class="grid w-full gap-4">
<em class="text-slate-300">No remote devices</em>
</div>
</template>
<DialogComp ref="pinDialog">
<template #content>
<div class="grid gap-4">
<h3>Pin code</h3>
<span class="text-4xl font-mono tracking-wide">{{ remote.pinlink.pin }}</span>
</div>
</template>
</DialogComp>
</div>
</div>
</template>
<script setup>
// TODO
// - startLink -> responsePin also in device block
// - startLink -> poll removal of pin file, if removed close dialog, update device list
// - Make unlink work
import { onMounted, reactive, ref } from 'vue'
import AlertComp from '../base/AlertComp.vue'
import { useDeviceStore } from '@/stores/device'
import {
IconDevices,
IconDeviceDesktop,
IconDeviceMobile,
IconDeviceTablet,
IconDeviceUnknown,
IconLink,
IconLinkOff,
IconReload,
} from '@tabler/icons-vue'
import ButtonComp from '../base/ButtonComp.vue'
import DialogComp from '../base/DialogComp.vue'
import axios from 'axios'
import { appUrl } from '@/services/ApiService'
const device = useDeviceStore()
const pinDialog = ref()
const remote = reactive({ devices: [], pinlink: false })
onMounted(() => {
device.serverGetRemotes()
device.$subscribe((mutation, state) => {
if (Object.keys(state.remote).length) remote.devices = device.remote
})
})
async function startLink(deviceUuid) {
const pin = await device.serverStartLink(deviceUuid)
remote.pinlink = { uuid: deviceUuid, pin: pin }
pinDialog.value.toggleDialog(true)
pollLink()
setTimeout(() => {
resetPinLink()
}, 60000)
}
function pollLink() {
const pollInterval = setInterval(() => {
axios.post(appUrl() + '/device/link/poll', { uuid: remote.pinlink.uuid }).then((data) => {
if (!data.data) {
clearInterval(pollInterval)
resetPinLink()
device.serverGetRemotes()
}
})
}, 1000)
}
function resetPinLink() {
remote.pinlink = false
if (pinDialog.value) pinDialog.value.toggleDialog(false)
}
function unlinkDevice(id) {
axios.post(appUrl() + '/device/link/remove', { uuid: id }).then((data) => {
if (data.data) device.serverGetRemotes()
})
}
</script>
<style scoped>
@reference "@/assets/main.css";
.device-overview {
@apply grid
gap-4
content-start;
}
</style>

View file

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View file

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View file

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View file

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View file

@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

View file

@ -0,0 +1,73 @@
<template>
<div class="macro-overview mcrm-block block__dark">
<h4 class="border-b-2 border-transparent">Saved Macros</h4>
<div class="macro-overview__list">
<div class="macro-item" v-for="(macro, i) in macros.list" :key="i">
<ButtonComp variant="dark" class="w-full" size="sm" @click.prevent="runMacro(macro)">
<IconKeyboard /> {{ macro }}
</ButtonComp>
</div>
</div>
</div>
</template>
<script setup>
import { IconKeyboard } from '@tabler/icons-vue'
import ButtonComp from '../base/ButtonComp.vue'
import { onMounted, reactive } from 'vue'
import axios from 'axios'
import { appUrl, isLocal } from '@/services/ApiService'
import { AuthCall } from '@/services/EncryptService'
const macros = reactive({
list: [],
})
onMounted(() => {
axios.post(appUrl() + '/macro/list').then((data) => {
if (data.data.length > 0) macros.list = data.data
})
})
function runMacro(macro) {
const data = isLocal() ? { macro: macro } : AuthCall({ macro: macro })
axios.post(appUrl() + '/macro/play', data).then((data) => {
console.log(data)
})
}
</script>
<style scoped>
@reference "@/assets/main.css";
.macro-overview {
@apply relative
grid
grid-rows-[auto_1fr];
&::after {
@apply content-['']
absolute
top-0
left-full
h-full
w-px
bg-slate-600;
}
.macro-overview__list {
@apply grid
gap-1
content-start;
}
.macro-item {
@apply flex items-center;
button {
@apply w-full;
}
}
}
</style>

View file

@ -0,0 +1,85 @@
<template>
<div class="macro-recorder mcrm-block block__light">
<div class="recorder-interface">
<!-- Recorder buttons -->
<RecorderHeader />
<!-- Recorder interface container -->
<div
:class="`recorder-interface__container ${macroRecorder.state.record && 'record'} ${macroRecorder.state.edit && 'edit'}`"
>
<!-- Shows the macro steps as kbd elements with delay and spacers-->
<RecorderOutput />
<!-- Input for recording macro steps -->
<RecorderInput />
</div>
<RecorderFooter />
</div>
</div>
</template>
<script setup>
import RecorderOutput from './parts/RecorderOutput.vue'
import RecorderInput from './parts/RecorderInput.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import RecorderHeader from './parts/RecorderHeader.vue'
import RecorderFooter from './parts/RecorderFooter.vue'
const macroRecorder = useMacroRecorderStore()
</script>
<style>
@reference "@/assets/main.css";
.macro-recorder {
@apply h-full;
}
.recorder-interface {
@apply grid
grid-rows-[auto_1fr_auto]
gap-4
h-full
transition-[grid-template-rows];
}
.recorder-interface__container {
@apply relative
w-full
rounded-lg
bg-slate-950/50
border
border-slate-600
overflow-auto
transition-colors;
&.record {
@apply border-rose-300 bg-rose-400/10;
}
&.edit {
@apply border-sky-300 bg-sky-900/10;
}
}
#macro-name {
@apply w-full
bg-transparent
py-0
outline-0
border-transparent
border-b-slate-300
focus:border-transparent
focus:border-b-sky-400
focus:bg-sky-400/10
transition-colors
text-lg
rounded-none;
}
.disabled {
@apply opacity-50 pointer-events-none cursor-not-allowed;
}
</style>

View file

@ -0,0 +1,57 @@
<template>
<span :class="`delay ${active ? 'active' : ''} ${preset ? 'preset' : ''}`">
<template v-if="value < 10000"> {{ value }} <i>ms</i> </template>
<template v-else> >10 <i>s</i> </template>
</span>
</template>
<script setup>
import { IconTimeDuration10 } from '@tabler/icons-vue'
defineProps({
value: Number,
active: Boolean,
preset: Boolean,
})
</script>
<style scoped>
@reference "@/assets/main.css";
span.delay {
@apply flex
items-center
px-2 py-1
bg-slate-500
border
border-slate-400
text-slate-950
font-sans
font-semibold
rounded-sm
text-sm
cursor-default;
&.preset {
@apply text-amber-400
border-amber-300/80
bg-amber-100/60;
}
i {
@apply pl-1
font-normal
not-italic
opacity-80;
}
}
.edit span.delay {
@apply cursor-pointer;
&:hover,
&.active {
@apply bg-lime-700 border-lime-500 text-lime-200;
}
}
</style>

View file

@ -0,0 +1,38 @@
<template>
<div id="delete-key-dialog" class="dialog__content">
<h4 class="text-slate-50 mb-4">Delete key</h4>
<div class="flex justify-center w-full mb-4">
<MacroKey v-if="keyObj" :key-obj="keyObj" />
</div>
<p class="text-sm text-slate-300">Are you sure you want to delete this key?</p>
<div class="flex justify-end gap-2 mt-6">
<ButtonComp variant="danger" size="sm" @click="macroRecorder.deleteEditKey()">
Delete key
</ButtonComp>
</div>
</div>
</template>
<script setup>
import ButtonComp from '@/components/base/ButtonComp.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import { onMounted, ref } from 'vue'
import MacroKey from './MacroKey.vue'
import { filterKey } from '@/services/MacroRecordService'
const macroRecorder = useMacroRecorderStore()
const keyObj = ref(null)
onMounted(() => {
keyObj.value = filterKey(macroRecorder.getEditKey())
// console.log(macroRecorder.getEditKey());
// console.log(keyObj.value);
// console.log('---------');
})
</script>
<style scoped>
@reference "@/assets/main.css";
</style>
'

View file

@ -0,0 +1,57 @@
<template>
<div id="edit-delay-dialog" class="dialog__content">
<h4 class="text-slate-50 mb-4">Edit delay</h4>
<div v-if="editable.delay.value" class="flex justify-center">
<DelaySpan class="!text-lg" :value="editable.delay.value" />
</div>
<form class="grid gap-4 mt-6" submit.prevent>
<div v-if="editable.newDelay.value">
<input
type="number"
min="0"
max="3600000"
step="10"
v-model="editable.newDelay.value"
autofocus
/>
<span>ms</span>
</div>
<div class="flex justify-end">
<ButtonComp variant="primary" size="sm" @click.prevent="changeDelay()">
Change delay
</ButtonComp>
</div>
</form>
</div>
</template>
<script setup>
import ButtonComp from '@/components/base/ButtonComp.vue'
import { reactive, onMounted } from 'vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import DelaySpan from './DelaySpan.vue'
const macroRecorder = useMacroRecorderStore()
const editable = reactive({
delay: {},
newDelay: { value: 0 },
})
onMounted(() => {
editable.delay = macroRecorder.getEditDelay()
editable.newDelay.value = editable.delay.value
console.log(editable)
})
const changeDelay = () => {
if (!editable.newDelay.value) return
macroRecorder.recordStep(editable.newDelay.value, false, macroRecorder.state.editDelay)
macroRecorder.state.editDelay = false
}
</script>
<style scoped>
@reference "@/assets/main.css";
</style>

View file

@ -0,0 +1,103 @@
<template>
<div id="edit-key-dialog" class="dialog__content">
<h4 class="text-slate-50 mb-4">Press a key</h4>
<div class="flex justify-center" @click="$refs.newKeyInput.focus()">
<MacroKey
v-if="editable.key.keyObj"
:key-obj="editable.key.keyObj"
:direction="editable.key.direction"
/>
<template v-if="typeof editable.newKey.keyObj === 'object'">
<span class="px-4 flex items-center text-white"> >>> </span>
<MacroKey :key-obj="editable.newKey.keyObj" :direction="editable.newKey.direction" />
</template>
</div>
<form class="grid gap-4" submit.prevent>
<input
class="size-0 opacity-0"
type="text"
min="0"
max="1"
ref="newKeyInput"
placeholder="New key"
autofocus
@keydown.prevent="handleNewKey($event)"
/>
<div class="flex gap-2 justify-center">
<ButtonComp
variant="secondary"
:class="editable.newKey.direction === 'down' ? 'selected' : ''"
size="sm"
@click.prevent="handleNewDirection('down')"
>
&darr; Down
</ButtonComp>
<ButtonComp
variant="secondary"
:class="editable.newKey.direction === 'up' ? 'selected' : ''"
size="sm"
@click.prevent="handleNewDirection('up')"
>
&uarr; Up
</ButtonComp>
</div>
<div class="flex justify-end">
<ButtonComp variant="primary" size="sm" @click.prevent="changeKey()">
Change key
</ButtonComp>
</div>
</form>
</div>
</template>
<script setup>
import MacroKey from './MacroKey.vue'
import ButtonComp from '@/components/base/ButtonComp.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import { filterKey } from '@/services/MacroRecordService'
import { reactive, ref, onMounted } from 'vue'
const editable = reactive({
key: {},
newKey: {},
})
const macroRecorder = useMacroRecorderStore()
const newKeyInput = ref(null)
onMounted(() => {
editable.key = macroRecorder.getEditKey()
editable.newKey.direction = editable.key.direction
})
const handleNewKey = (e) => {
editable.newKey.e = e
editable.newKey.keyObj = filterKey(e)
}
const handleNewDirection = (direction) => {
editable.newKey.direction = direction
editable.newKey.keyObj = filterKey(editable.key)
}
const changeKey = () => {
macroRecorder.recordStep(
editable.newKey.e,
editable.newKey.direction,
macroRecorder.state.editKey,
)
macroRecorder.state.editKey = false
}
</script>
<style scoped>
@reference "@/assets/main.css";
button.selected {
@apply ring-2 ring-offset-1 ring-sky-500 bg-sky-500;
}
</style>

View file

@ -0,0 +1,69 @@
<template>
<ContextMenu ref="ctxtMenu">
<template #trigger>
<ButtonComp variant="secondary" size="sm"> <IconTimeDuration15 />Fixed delay </ButtonComp>
</template>
<template #content>
<ul>
<li @click="changeDelay(0)">0ms</li>
<li @click="changeDelay(15)">15ms</li>
<li @click="changeDelay(50)">50ms</li>
<li @click="changeDelay(100)">100ms</li>
<li>
<DialogComp>
<template #trigger>
<span>Custom delay</span>
</template>
<template #content>
<h4 class="text-slate-50 mb-4">Custom delay</h4>
<form
class="grid gap-4 w-44"
@submit.prevent="changeDelay(parseInt($refs.customDelayInput.value))"
>
<div>
<input
type="number"
step="10"
min="0"
max="3600000"
ref="customDelayInput"
placeholder="100"
/>
<span>ms</span>
</div>
<div class="flex justify-end">
<ButtonComp variant="primary" size="sm">Set custom delay</ButtonComp>
</div>
</form>
</template>
</DialogComp>
</li>
</ul>
</template>
</ContextMenu>
</template>
<script setup>
import ContextMenu from '@/components/base/ContextMenu.vue'
import { IconTimeDuration15 } from '@tabler/icons-vue'
import ButtonComp from '@/components/base/ButtonComp.vue'
import DialogComp from '@/components/base/DialogComp.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import { ref } from 'vue'
const macroRecorder = useMacroRecorderStore()
const ctxtMenu = ref()
function changeDelay(num) {
macroRecorder.changeDelay(num)
ctxtMenu.value.toggle()
}
</script>
<style scoped>
@reference "@/assets/main.css";
</style>

View file

@ -0,0 +1,134 @@
<template>
<div id="insert-key-dialog" class="dialog__content w-96">
<h4 class="text-slate-50 mb-4">Insert key {{ position }}</h4>
<p v-if="inputFocus" class="text-center">[Press a key]</p>
<input
class="size-0 opacity-0"
type="text"
min="0"
max="1"
ref="insertKeyInput"
placeholder="New key"
@focusin="inputFocus = true"
@focusout="inputFocus = false"
@keydown.prevent="handleInsertKey($event)"
autofocus
/>
<div class="insert-output" :class="position == 'before' ? 'flex-row-reverse' : ''">
<MacroKey v-if="keyObjs.selected" :key-obj="keyObjs.selected" />
<hr class="spacer" />
<DelaySpan :preset="true" :value="10" />
<hr class="spacer" />
<MacroKey
v-if="keyObjs.insert"
class="insert"
:key-obj="keyObjs.insert"
:direction="keyObjs.insertDirection"
@click="insertKeyInput.focus()"
/>
<MacroKey v-if="!keyObjs.insert" :empty="true" @click="insertKeyInput.focus()" />
<template v-if="keyObjs.adjacentDelay">
<hr class="spacer" />
<DelaySpan :value="keyObjs.adjacentDelay.value" />
</template>
<template v-if="keyObjs.adjacent">
<hr class="spacer" />
<MacroKey :key-obj="keyObjs.adjacent" />
</template>
</div>
<div class="insert-key__direction">
<ButtonComp
variant="secondary"
:class="keyObjs.insertDirection === 'down' ? 'selected' : ''"
size="sm"
@click.prevent="keyObjs.insertDirection = 'down'"
>
&darr; Down
</ButtonComp>
<ButtonComp
variant="secondary"
:class="keyObjs.insertDirection === 'up' ? 'selected' : ''"
size="sm"
@click.prevent="keyObjs.insertDirection = 'up'"
>
&uarr; Up
</ButtonComp>
</div>
<div class="flex justify-end">
<ButtonComp variant="primary" size="sm" @click="insertKey()">Insert key</ButtonComp>
</div>
</div>
</template>
<script setup>
import MacroKey from './MacroKey.vue'
import DelaySpan from './DelaySpan.vue'
import ButtonComp from '@/components/base/ButtonComp.vue'
import { onMounted, reactive, ref } from 'vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import { filterKey } from '@/services/MacroRecordService'
const props = defineProps({
position: String,
})
const macroRecorder = useMacroRecorderStore()
const keyObjs = reactive({
selected: null,
insert: null,
insertEvent: null,
insertDirection: 'down',
adjacent: null,
adjacentDelay: null,
adjacentDelayIndex: null,
})
const insertKeyInput = ref(null)
const inputFocus = ref(false)
onMounted(() => {
keyObjs.selected = filterKey(macroRecorder.getEditKey())
const adjacentKey = macroRecorder.getAdjacentKey(props.position, true)
if (adjacentKey) keyObjs.adjacent = filterKey(adjacentKey.key)
if (adjacentKey.delay) {
keyObjs.adjacentDelay = adjacentKey.delay
keyObjs.adjacentDelayIndex = adjacentKey.delayIndex
}
})
const handleInsertKey = (e) => {
keyObjs.insert = filterKey(e)
keyObjs.insertEvent = e
}
const insertKey = () => {
macroRecorder.insertKey(keyObjs.insertEvent, keyObjs.insertDirection, keyObjs.adjacentDelayIndex)
}
</script>
<style scoped>
@reference "@/assets/main.css";
.insert-output {
@apply flex
justify-center
items-center
w-full
mb-4;
}
.insert-key__direction {
@apply flex
justify-center
gap-2
mt-6;
}
button.selected {
@apply bg-sky-500
ring-2
ring-offset-1
ring-sky-500;
}
</style>

View file

@ -0,0 +1,109 @@
<template>
<kbd :class="`${active ? 'active' : ''} ${empty ? 'empty' : ''}`">
<template v-if="keyObj">
<sup v-if="keyObj.loc">
{{ keyObj.loc }}
</sup>
<span :innerHTML="keyObj.str" />
<span class="dir">{{ dir.value === 'down' ? '&darr;' : '&uarr;' }}</span>
</template>
<template v-else-if="empty">
<span>[ ]</span>
</template>
</kbd>
</template>
<script setup>
import { onMounted, onUpdated, reactive } from 'vue'
const props = defineProps({
keyObj: Object,
direction: String,
active: Boolean,
empty: Boolean,
})
const dir = reactive({
value: false,
})
onMounted(() => {
if (props.empty) return
setDirection()
})
onUpdated(() => {
setDirection()
})
const setDirection = () => {
if (props.direction) dir.value = props.direction
else dir.value = props.keyObj.direction
}
</script>
<style>
@reference "@/assets/main.css";
kbd {
@apply flex
items-center
gap-2
pl-4 pr-2 py-1
h-9
bg-slate-700
font-mono
font-bold
text-lg
text-white
whitespace-nowrap
uppercase
rounded-md
border
border-slate-500
transition-all
shadow-slate-500;
box-shadow: 0 0.2rem 0 0.2rem var(--tw-shadow-color);
&:has(sup) {
@apply pl-2;
}
sup {
@apply text-slate-200 text-xs font-light mt-1;
}
span.dir {
@apply text-slate-200 pl-1;
}
&.empty {
@apply pl-3 pr-3
bg-sky-400/50
border-sky-300
shadow-sky-600
tracking-widest
cursor-pointer;
}
&.insert {
@apply bg-yellow-500/50
border-yellow-300
shadow-yellow-600
cursor-pointer;
}
}
:has(kdb):not(.edit) kbd {
@apply pointer-events-none cursor-default;
}
.edit kbd {
@apply cursor-pointer pointer-events-auto;
&:hover,
&.active {
@apply bg-sky-900 border-sky-400 shadow-sky-700;
}
}
</style>

View file

@ -0,0 +1,60 @@
<template>
<div id="validation-error__dialog" class="dialog__content">
<h4 class="text-slate-50 mb-4">There's an error in your macro</h4>
<div class="grid gap-4" v-if="(errors && errors.up.length > 0) || errors.down.length > 0">
<div v-if="errors.down.length > 0">
<p>
The following keys have been <strong>pressed</strong> down, but
<strong>not released</strong>.
</p>
<ul>
<li v-for="key in errors.down" :key="key">{{ key.toUpperCase() }}</li>
</ul>
</div>
<div v-if="errors.up.length > 0">
<p>
The following keys have been <strong>released</strong>, but
<strong>not pressed</strong> down.
</p>
<ul>
<li v-for="key in errors.up" :key="key">{{ key.toUpperCase() }}</li>
</ul>
</div>
</div>
<div class="flex justify-end mt-4">
<ButtonComp size="sm" variant="danger" @click="macroRecorder.state.validationErrors = false">
Close
</ButtonComp>
</div>
</div>
</template>
<script setup>
import ButtonComp from '@/components/base/ButtonComp.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import { onMounted, reactive } from 'vue'
const macroRecorder = useMacroRecorderStore()
const errors = reactive({
up: [],
down: [],
})
onMounted(() => {
macroRecorder.$subscribe((mutation) => {
if (mutation.events && mutation.events.key == 'validationErrors') {
errors.up = mutation.events.newValue !== false ? macroRecorder.state.validationErrors.up : []
errors.down =
mutation.events.newValue !== false ? macroRecorder.state.validationErrors.down : []
}
console.log(mutation)
})
})
</script>
<style scoped>
@reference "@/assets/main.css";
</style>

View file

@ -0,0 +1,121 @@
<template>
<div class="macro-edit__dialogs" v-if="macroRecorder.state.edit !== false">
<div
class="flex gap-2"
v-if="macroRecorder.state.editKey !== false && typeof macroRecorder.getEditKey() === 'object'"
>
<ContextMenu ref="ctxtMenu">
<template #trigger>
<ButtonComp variant="dark" size="sm"> <IconPlus /> Insert </ButtonComp>
</template>
<template #content>
<ul>
<li @click="insert.position = 'before'"><IconArrowLeftCircle /> Before</li>
<li @click="insert.position = 'after'"><IconArrowRightCircle /> After</li>
</ul>
</template>
</ContextMenu>
<DialogComp
v-if="insert.position !== null"
:open="insert.position !== null"
@on-open="onOpenDialog"
@on-close="onCloseDialog"
>
<template #content>
<InsertKeyDialog :position="insert.position" />
</template>
</DialogComp>
<DialogComp
:id="`edit-key-${macroRecorder.state.editKey}`"
@on-open="onOpenDialog"
@on-close="onCloseDialog"
>
<template #trigger>
<ButtonComp variant="secondary" size="sm"> <IconPencil />Edit </ButtonComp>
</template>
<template #content>
<EditKeyDialog />
</template>
</DialogComp>
<DialogComp @on-open="onOpenDialog" @on-close="onCloseDialog">
<template #trigger>
<ButtonComp size="sm" variant="danger"> <IconTrash />Delete </ButtonComp>
</template>
<template #content>
<DeleteKeyDialog />
</template>
</DialogComp>
</div>
<DialogComp
v-if="
macroRecorder.state.editDelay !== false && typeof macroRecorder.getEditDelay() === 'object'
"
@on-open="onOpenDialog"
@on-close="onCloseDialog"
>
<template #trigger>
<ButtonComp variant="secondary" size="sm"> <IconAlarm />Edit </ButtonComp>
</template>
<template #content>
<EditDelayDialog />
</template>
</DialogComp>
</div>
</template>
<script setup>
import {
IconAlarm,
IconArrowLeftCircle,
IconArrowRightCircle,
IconPencil,
IconPlus,
IconTrash,
} from '@tabler/icons-vue'
import DialogComp from '@/components/base/DialogComp.vue'
import ButtonComp from '@/components/base/ButtonComp.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import EditKeyDialog from '../components/EditKeyDialog.vue'
import EditDelayDialog from '../components/EditDelayDialog.vue'
import DeleteKeyDialog from '../components/DeleteKeyDialog.vue'
import ContextMenu from '@/components/base/ContextMenu.vue'
import InsertKeyDialog from '../components/InsertKeyDialog.vue'
import { onMounted, reactive, ref } from 'vue'
const macroRecorder = useMacroRecorderStore()
const insert = reactive({ position: null })
const ctxtMenu = ref()
onMounted(() => {
macroRecorder.$subscribe((mutation) => {
if (mutation.events && mutation.events.key == 'editKey' && mutation.events.newValue === false) {
insert.position = null
}
})
})
function onOpenDialog() {
if (insert.position !== null) ctxtMenu.value.toggle()
}
function onCloseDialog() {
macroRecorder.state.editKey = false
macroRecorder.state.editDelay = false
insert.position = null
}
</script>
<style scoped>
@reference "@/assets/main.css";
.macro-edit__dialogs {
@apply flex
flex-grow
justify-end;
}
</style>

View file

@ -0,0 +1,65 @@
<template>
<div class="macro-recorder__footer">
<ButtonComp
v-if="macroRecorder.steps.length > 0"
variant="danger"
size="sm"
@click="macroRecorder.reset()"
>
<IconRestore /> Reset
</ButtonComp>
<DialogComp ref="errorDialog">
<template #content>
<ValidationErrorDialog />
</template>
</DialogComp>
<ButtonComp
v-if="macroRecorder.steps.length > 0"
:disabled="macroRecorder.state.record || macroRecorder.state.edit"
variant="success"
size="sm"
@click="toggleSave()"
>
<IconDeviceFloppy />
Save
</ButtonComp>
</div>
</template>
<script setup>
import ButtonComp from '@/components/base/ButtonComp.vue'
import { IconDeviceFloppy, IconRestore } from '@tabler/icons-vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import DialogComp from '@/components/base/DialogComp.vue'
import ValidationErrorDialog from '../components/ValidationErrorDialog.vue'
import { onMounted, ref } from 'vue'
const macroRecorder = useMacroRecorderStore()
const errorDialog = ref()
onMounted(() => {
macroRecorder.$subscribe((mutation) => {
if (mutation.events && mutation.events.key == 'validationErrors') {
errorDialog.value.toggleDialog(mutation.events.newValue !== false)
}
})
})
const toggleSave = () => {
if (!macroRecorder.save()) errorDialog.value.toggleDialog(true)
}
</script>
<style scoped>
@reference "@/assets/main.css";
.macro-recorder__footer {
@apply flex
justify-between
gap-2;
}
</style>

View file

@ -0,0 +1,100 @@
<template>
<div class="macro-recorder__header">
<div class="w-full grid grid-cols-[auto_1fr_auto] gap-2">
<h4 class="">Name:</h4>
<input
id="macro-name"
type="text"
@input.prevent="changeName($event.target.value)"
placeholder="New macro"
/>
<div :class="`recording__buttons ${!nameSet || macroRecorder.state.edit ? 'disabled' : ''}`">
{{ macroRecorder.name }}
<ButtonComp
v-if="!macroRecorder.state.record"
variant="primary"
size="sm"
@click="macroRecorder.state.record = true"
>
<IconPlayerRecordFilled class="text-red-500" />Record
</ButtonComp>
<ButtonComp
v-if="macroRecorder.state.record"
variant="danger"
size="sm"
@click="macroRecorder.state.record = false"
>
<IconPlayerStopFilled class="text-white" />Stop
</ButtonComp>
</div>
</div>
<div
v-if="macroRecorder.steps.length > 0"
:class="`edit__buttons ${macroRecorder.state.record ? 'disabled' : ''}`"
>
<div>
<ButtonComp
v-if="!macroRecorder.state.edit"
variant="secondary"
size="sm"
@click="macroRecorder.state.edit = true"
>
<IconPencil />Edit
</ButtonComp>
<ButtonComp
v-if="macroRecorder.state.edit"
variant="danger"
size="sm"
@click="macroRecorder.resetEdit()"
>
<IconPlayerStopFilled />Stop
</ButtonComp>
</div>
<FixedDelayMenu v-if="macroRecorder.state.edit" />
<EditDialogs />
</div>
</div>
</template>
<script setup>
import { IconPencil, IconPlayerRecordFilled, IconPlayerStopFilled } from '@tabler/icons-vue'
import ButtonComp from '@/components/base/ButtonComp.vue'
import FixedDelayMenu from '../components/FixedDelayMenu.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import EditDialogs from './EditDialogs.vue'
import { computed, onMounted, onUpdated, ref } from 'vue'
const macroRecorder = useMacroRecorderStore()
const nameSet = ref(false)
function changeName(name) {
macroRecorder.changeName(name)
nameSet.value = name.length > 0
}
</script>
<style scoped>
@reference "@/assets/main.css";
.macro-recorder__header {
@apply grid
gap-4
w-full;
.edit__buttons {
@apply flex
justify-between
gap-2
w-full;
}
> div {
@apply flex gap-2 items-end;
}
}
</style>

View file

@ -0,0 +1,46 @@
<template>
<div :class="`recorder-input__container ${macroRecorder.state.record && 'record'}`">
<input
v-if="macroRecorder.state.record"
:class="`macro-recorder__input ${macroRecorder.state.record && 'record'}`"
type="text"
ref="macroInput"
@focus="console.log('focus')"
@keydown.prevent="macroRecorder.recordStep($event, 'down')"
@keyup.prevent="macroRecorder.recordStep($event, 'up')"
/>
</div>
</template>
<script setup>
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import { ref, onUpdated } from 'vue'
const macroInput = ref(null)
const macroRecorder = useMacroRecorderStore()
onUpdated(() => {
if (macroRecorder.state.record) {
macroInput.value.focus()
if (macroRecorder.delay.start !== 0) macroRecorder.restartDelay()
}
})
</script>
<style scoped>
@reference "@/assets/main.css";
.recorder-input__container,
.macro-recorder__input {
@apply absolute
inset-0
size-full
opacity-0
hidden;
&.record {
@apply block;
}
}
</style>

View file

@ -0,0 +1,58 @@
<template>
<div
:class="`macro-recorder__output ${macroRecorder.state.record && 'record'} ${macroRecorder.state.edit && 'edit'}`"
>
<template v-for="(step, key) in macroRecorder.steps">
<!-- Key element -->
<template v-if="step.type === 'key'">
<MacroKey
:key="key"
:key-obj="step.keyObj"
:direction="step.direction"
:active="macroRecorder.state.editKey === key"
@click="macroRecorder.state.edit ? macroRecorder.toggleEdit('key', key) : false"
/>
</template>
<!-- Delay element -->
<template v-else-if="step.type === 'delay'">
<DelaySpan
:key="key"
:value="step.value"
:active="macroRecorder.state.editDelay === key"
@click="macroRecorder.toggleEdit('delay', key)"
/>
</template>
<!-- Spacer element -->
<hr class="spacer" />
</template>
</div>
</template>
<script setup>
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import MacroKey from '../components/MacroKey.vue'
import DelaySpan from '../components/DelaySpan.vue'
const macroRecorder = useMacroRecorderStore()
</script>
<style scoped>
@reference "@/assets/main.css";
.macro-recorder__output {
@apply flex
flex-wrap
items-center
gap-y-4
p-4
absolute
top-0 left-0
h-fit;
}
hr.spacer:last-of-type {
@apply hidden;
}
</style>

15
fe/src/main.js Normal file
View file

@ -0,0 +1,15 @@
// import './assets/jemx.scss'
import "@/assets/main.css";
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "@/App.vue";
import router from "@/router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

43
fe/src/router/index.js Normal file
View file

@ -0,0 +1,43 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/panels',
name: 'panels',
component: () => import('../views/PanelsView.vue'),
},
{
path: '/macros',
name: 'macros',
component: () => import('../views/MacrosView.vue'),
},
{
path: '/devices',
name: 'devices',
component: () => import('../views/DevicesView.vue'),
},
{
path: '/settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
},
// {
// path: '/about',
// name: 'about',
// // route level code-splitting
// // this generates a separate chunk (About.[hash].js) for this route
// // which is lazy-loaded when the route is visited.
// component: () => import('../views/AboutView.vue'),
// },
],
})
export default router

View file

@ -0,0 +1,19 @@
import CryptoJS from 'crypto-js'
export const appUrl = () => {
return window.location.port !== 6970 ? `http://${window.location.hostname}:6970` : ''
}
export const isLocal = () => {
return window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost'
}
export const encrypt = (data, key = false) => {
const pk = !key ? localStorage.getItem('Macrame__pk') : key
if (pk) {
return CryptoJS.RSA.encrypt(JSON.stringify(data), pk).toString()
} else {
return false
}
}

View file

@ -0,0 +1,51 @@
import { useDeviceStore } from '@/stores/device'
import { AES, enc, pad } from 'crypto-js'
export const encryptAES = (key, str) => {
key = keyPad(key)
let iv = enc.Utf8.parse(import.meta.env.VITE_MCRM__IV)
let encrypted = AES.encrypt(str, key, {
iv: iv,
padding: pad.Pkcs7,
})
return encrypted.toString()
}
export const decryptAES = (key, str) => {
key = keyPad(key)
let iv = enc.Utf8.parse(import.meta.env.VITE_MCRM__IV)
let encrypted = AES.decrypt(str.toString(), key, {
iv: iv,
padding: pad.Pkcs7,
})
return encrypted.toString(enc.Utf8)
}
export const AuthCall = (data) => {
const device = useDeviceStore()
return {
uuid: device.uuid(),
d: encryptAES(device.key(), JSON.stringify(data)),
}
}
function keyPad(key) {
let returnKey = key
if (key.length == 4) {
returnKey = key + import.meta.env.VITE_MCRM__SALT
}
return enc.Utf8.parse(returnKey)
}
export const getDateStr = () => {
const date = new Date()
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}${month}${day}`
}

View file

@ -0,0 +1,127 @@
const keyMap = {
// Modifier keys
Control: 'Ctrl',
Shift: 'Shift',
Alt: 'Alt',
Meta: 'Win',
CapsLock: 'Caps',
// Special keys
PageUp: 'PgUp',
PageDown: 'PgDn',
ScrollLock: 'Scr Lk',
Insert: 'Ins',
Delete: 'Del',
Escape: 'Esc',
Space: 'Space',
// Symbol keys
Backquote: '`',
Backslash: '\\',
BracketLeft: '[',
BracketRight: ']',
Comma: ',',
Equal: '=',
Minus: '-',
Period: '.',
Quote: "'",
Semicolon: ';',
Slash: '/',
// Arrow keys
ArrowUp: '&#9650;',
ArrowRight: '&#9654;',
ArrowDown: '&#9660;',
ArrowLeft: '&#9664;',
// Media keys
MediaPlayPause: 'Play',
MediaStop: 'Stop',
MediaTrackNext: 'Next',
MediaTrackPrevious: 'Prev',
MediaVolumeDown: 'Down',
MediaVolumeUp: 'Up',
AudioVolumeMute: 'Mute',
AudioVolumeDown: 'Down',
AudioVolumeUp: 'Up',
}
/**
* Filters a keyboard event and returns an object with two properties:
* loc (optional) and str.
* loc is the location of the key (either 'left', 'right', or 'num').
* str is the string representation of the key (e.g. 'a', 'A', 'Enter', etc.).
* If the key is a modifier key, it is represented by its name (e.g. 'Ctrl', 'Shift', etc.).
* If the key is not a modifier key, it is represented by its character (e.g. 'a', 'A', etc.).
* If the key is not a character key, it is represented by its symbol (e.g. ',', '.', etc.).
* @param {KeyboardEvent} e - The keyboard event to filter.
* @return {Object} An object with two properties: loc (optional) and str.
*/
export const filterKey = (e) => {
const k = {} // Object k (key)
// If location is set, set loc (location)
if (e.location === 1) k.loc = 'left'
if (e.location === 2) k.loc = 'right'
if (e.location === 3) k.loc = 'num'
if (e.key.includes('Media') || e.key.includes('Audio')) k.loc = mediaPrefix(e)
// If code is in keyMap, set str by code
if (keyMap[e.code] || keyMap[e.key]) {
k.str = keyMap[e.code] || keyMap[e.key]
} else {
// If code is not in keyMap, set str by e.key
k.str = e.key.toLowerCase()
}
// return k object
return k
}
/**
* Returns a string prefix for the given media key.
* @param {KeyboardEvent} e - The keyboard event to get the prefix for.
* @return {string} The prefix for the key (either 'Media' or 'Volume').
*/
const mediaPrefix = (e) => {
switch (e.key) {
case 'MediaPlayPause':
case 'MediaStop':
case 'MediaTrackNext':
case 'MediaTrackPrevious':
return 'Media'
case 'MediaVolumeDown':
case 'MediaVolumeUp':
case 'AudioVolumeDown':
case 'AudioVolumeUp':
case 'AudioVolumeMute':
return 'Volume'
}
}
export const isRepeat = (lastStep, e, direction) => {
return (
lastStep &&
lastStep.type === 'key' &&
lastStep.code === e.code &&
lastStep.direction === direction
)
}
export const invalidMacro = (steps) => {
const downKeys = []
const upKeys = []
Object.keys(steps).forEach((stepKey) => {
const step = steps[stepKey]
if (step.type !== 'key') return
if (step.direction == 'down') downKeys.push(step.key)
if (step.direction == 'up') {
if (!downKeys.includes(step.key)) upKeys.push(step.key)
else downKeys.splice(downKeys.indexOf(step.key), 1)
}
})
if (upKeys.length === 0 && downKeys.length === 0) return false
return { down: downKeys, up: upKeys }
}

View file

@ -0,0 +1,117 @@
"A-Z a-z 0-9"
"backspace"
"delete"
"enter"
"tab"
"esc"
"escape"
"up" Up arrow key
"down" Down arrow key
"right" Right arrow key
"left" Left arrow key
"home"
"end"
"pageup"
"pagedown"
"f1"
"f2"
"f3"
"f4"
"f5"
"f6"
"f7"
"f8"
"f9"
"f10"
"f11"
"f12"
"f13"
"f14"
"f15"
"f16"
"f17"
"f18"
"f19"
"f20"
"f21"
"f22"
"f23"
"f24"
"cmd" is the "win" key for windows
"lcmd" left command
"rcmd" right command
// "command"
"alt"
"lalt" left alt
"ralt" right alt
"ctrl"
"lctrl" left ctrl
"rctrl" right ctrl
"control"
"shift"
"lshift" left shift
"rshift" right shift
// "right_shift"
"capslock"
"space"
"print"
"printscreen" // No Mac support
"insert"
"menu" Windows only
"audio_mute" Mute the volume
"audio_vol_down" Lower the volume
"audio_vol_up" Increase the volume
"audio_play"
"audio_stop"
"audio_pause"
"audio_prev" Previous Track
"audio_next" Next Track
"audio_rewind" Linux only
"audio_forward" Linux only
"audio_repeat" Linux only
"audio_random" Linux only
"num0"
"num1"
"num2"
"num3"
"num4"
"num5"
"num6"
"num7"
"num8"
"num9"
"num_lock"
"num."
"num+"
"num-"
"num*"
"num/"
"num_clear"
"num_enter"
"num_equal"
// // "numpad_0" No Linux support
// "numpad_0"
// "numpad_1"
// "numpad_2"
// "numpad_3"
// "numpad_4"
// "numpad_5"
// "numpad_6"
// "numpad_7"
// "numpad_8"
// "numpad_9"
// "numpad_lock"
"lights_mon_up" Turn up monitor brightness No Windows support
"lights_mon_down" Turn down monitor brightness No Windows support
"lights_kbd_toggle" Toggle keyboard backlight on/off No Windows support
"lights_kbd_up" Turn up keyboard backlight brightness No Windows support
"lights_kbd_down" Turn down keyboard backlight brightness No Windows support

12
fe/src/stores/counter.js Normal file
View file

@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

118
fe/src/stores/device.js Normal file
View file

@ -0,0 +1,118 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import axios from 'axios'
import { appUrl, encrypt } from '@/services/ApiService'
import { v4 as uuidv4 } from 'uuid'
import { encryptAES, getDateStr } from '@/services/EncryptService'
export const useDeviceStore = defineStore('device', () => {
// Properties - State values
const current = ref({
uuid: false,
key: false,
})
const remote = ref([])
const server = ref({
status: false,
})
// Current device
const uuid = () => {
if (!current.value.uuid && localStorage.getItem('deviceId')) {
current.value.uuid = localStorage.getItem('deviceId')
} else if (!current.value.uuid) {
current.value.uuid = setDeviceId()
}
return current.value.uuid
}
const setDeviceId = () => {
const uuid = uuidv4()
localStorage.setItem('deviceId', uuid)
return uuid
}
const key = () => {
if (!current.value.key && localStorage.getItem('deviceKey')) {
current.value.key = localStorage.getItem('deviceKey')
}
return current.value.key
}
const setDeviceKey = (key) => {
current.value.key = key
localStorage.setItem('deviceKey', key)
}
const removeDeviceKey = () => {
current.value.key = false
localStorage.removeItem('deviceKey')
}
// Server application
const serverGetRemotes = async (remoteUuid) => {
axios.post(appUrl() + '/device/list', { uuid: remoteUuid }).then((data) => {
if (data.data.devices) remote.value = data.data.devices
})
}
const serverStartLink = async (deviceUuid) => {
const request = await axios.post(appUrl() + '/device/link/start', { uuid: deviceUuid })
return request.data
}
// Remote application
const remoteCheckServerAccess = async () => {
const check = await axios.post(appUrl() + '/device/access/check', { uuid: uuid() })
server.value.access = check.data
return check.data
}
const remoteRequestServerAccess = async (deviceName, deviceType) => {
const request = await axios.post(appUrl() + '/device/access/request', {
uuid: uuid(),
name: deviceName,
type: deviceType,
})
return request
}
const remotePingLink = async (cb) => {
// const linkRequest = await axios.post(appUrl() + '/device/link/ping', { uuid: deviceUuid })
// if (linkRequest.data)
const pingInterval = setInterval(() => {
axios.post(appUrl() + '/device/link/ping', { uuid: uuid() }).then((data) => {
if (data.data) {
clearInterval(pingInterval)
cb(data.data)
}
})
}, 1000)
}
const remoteHandshake = async (key) => {
const handshake = await axios.post(appUrl() + '/device/handshake', {
uuid: uuid(),
shake: encryptAES(key, getDateStr()),
})
console.log(handshake)
return handshake.data
}
return {
remote,
server,
uuid,
setDeviceId,
key,
setDeviceKey,
removeDeviceKey,
serverGetRemotes,
serverStartLink,
remoteCheckServerAccess,
remoteRequestServerAccess,
remotePingLink,
remoteHandshake,
}
})

View file

@ -0,0 +1,206 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { filterKey, isRepeat, invalidMacro } from '../services/MacroRecordService'
import axios from 'axios'
import { appUrl } from '@/services/ApiService'
export const useMacroRecorderStore = defineStore('macrorecorder', () => {
// Properties - State values
const state = ref({
record: false,
edit: false,
editKey: false,
editDelay: false,
validationErrors: false,
})
const macroName = ref('')
const steps = ref([])
const delay = ref({
start: 0,
fixed: false,
})
// Getters - Computed values
const getEditKey = () => steps.value[state.value.editKey]
const getAdjacentKey = (pos, includeDelay = false) => {
let returnVal = false
const mod = pos == 'before' ? -1 : 1
const keyIndex = state.value.editKey + 2 * mod
const delayIndex = includeDelay ? state.value.editKey + 1 * mod : false
if (steps.value[keyIndex]) returnVal = steps.value[keyIndex]
if (delayIndex && steps.value[delayIndex])
returnVal = {
delay: steps.value[delayIndex],
key: steps.value[keyIndex],
delayIndex: delayIndex,
}
return returnVal
}
const getEditDelay = () => steps.value[state.value.editDelay]
// Setters - Actions
const recordStep = (e, direction = false, key = false) => {
const lastStep = steps.value[steps.value.length - 1]
let stepVal = {}
if (typeof e === 'object' && !isRepeat(lastStep, e, direction)) {
if (key === false) recordDelay()
stepVal = {
type: 'key',
key: e.key,
code: e.code,
location: e.location,
direction: direction,
keyObj: filterKey(e),
}
} else if (direction && key !== false) {
stepVal = steps.value[key]
stepVal.direction = direction
} else if (typeof e === 'number') {
stepVal = { type: 'delay', value: parseFloat(e.toFixed()) }
}
if (key !== false) steps.value[key] = stepVal
else steps.value.push(stepVal)
}
const recordDelay = () => {
if (delay.value.fixed !== false)
recordStep(delay.value.fixed) // Record fixed delay
else if (delay.value.start == 0)
delay.value.start = performance.now() // Record start of delay
else {
recordStep(performance.now() - delay.value.start) // Record end of delay
delay.value.start = performance.now() // Reset start
}
}
const insertKey = (e, direction, adjacentDelayIndex) => {
let min, max, newKeyIndex, newDelayIndex
if (adjacentDelayIndex === null) {
min = state.value.editKey == 0 ? 0 : state.value.editKey
max = state.value.editKey == 0 ? 1 : false
newKeyIndex = max === false ? min + 2 : min
newDelayIndex = min + 1
} else if (state.value.editKey < adjacentDelayIndex) {
min = state.value.editKey
max = adjacentDelayIndex
newKeyIndex = min + 2
newDelayIndex = min + 1
} else {
min = adjacentDelayIndex
max = state.value.editKey
newKeyIndex = min + 1
newDelayIndex = min + 2
}
if (max !== false) {
for (let i = Object.keys(steps.value).length - 1; i >= max; i--) {
steps.value[i + 2] = steps.value[i]
}
}
recordStep(e, direction, newKeyIndex)
recordStep(10, false, newDelayIndex)
state.value.editKey = false
}
const deleteEditKey = () => {
if (state.value.editKey === 0) steps.value.splice(state.value.editKey, 2)
else steps.value.splice(state.value.editKey - 1, 2)
state.value.editKey = false
}
const restartDelay = () => {
delay.value.start = performance.now()
}
const changeName = (name) => {
macroName.value = name
console.log(macroName.value)
}
const changeDelay = (fixed) => {
delay.value.fixed = fixed
formatDelays()
}
const formatDelays = () => {
steps.value = steps.value.map((step) => {
if (step.type === 'delay' && delay.value.fixed !== false) step.value = delay.value.fixed
return step
})
}
const toggleEdit = (type, key) => {
if (type === 'key') {
state.value.editKey = key
state.value.editDelay = false
}
if (type === 'delay') {
state.value.editKey = false
state.value.editDelay = key
}
}
const resetEdit = () => {
state.value.edit = false
state.value.editKey = false
state.value.editDelay = false
}
const reset = () => {
state.value.record = false
delay.value.start = 0
steps.value = []
if (state.value.edit) resetEdit()
}
const save = () => {
state.value.validationErrors = invalidMacro(steps.value)
if (state.value.validationErrors) return false
axios
.post(appUrl() + '/macro/record', { name: macroName.value, steps: steps.value })
.then((data) => {
console.log(data)
})
return true
}
return {
state,
steps,
delay,
getEditKey,
getAdjacentKey,
getEditDelay,
recordStep,
insertKey,
deleteEditKey,
restartDelay,
changeName,
changeDelay,
toggleEdit,
resetEdit,
reset,
save,
}
})

View file

@ -0,0 +1,21 @@
<template>
<div id="devices-view" class="panel">
<h1 class="panel__title">
Devices <span class="text-sm">{{ isLocal() ? 'remote' : 'servers' }}</span>
</h1>
<div class="panel__content grid gap-8">
<ServerView v-if="isLocal()" />
<RemoteView v-else />
</div>
</div>
</template>
<script setup>
import ServerView from '@/components/devices/ServerView.vue'
import RemoteView from '@/components/devices/RemoteView.vue'
import { isLocal } from '@/services/ApiService'
</script>
<style scoped>
@reference "@/assets/main.css";
</style>

View file

@ -0,0 +1,7 @@
<template>
<div id="dashboard"></div>
</template>
<script setup></script>
<style lang="scss" scoped></style>

View file

@ -0,0 +1,40 @@
<template>
<div id="macros" class="panel">
<h1 class="panel__title">Macros</h1>
<div class="panel__content !p-0">
<div class="macro-panel__content">
<MacroOverview />
<MacroRecorder />
</div>
</div>
</div>
</template>
<script setup>
import MacroOverview from '@/components/macros/MacroOverview.vue'
import MacroRecorder from '../components/macros/MacroRecorder.vue'
import { onMounted, ref } from 'vue'
const recordMacro = ref(false)
const macroInput = ref(null)
onMounted(() => {
// macroInput.value.focus()
})
const keyDown = (e) => {
console.log(e)
}
</script>
<style scoped>
@reference "@/assets/main.css";
.macro-panel__content {
@apply grid
grid-cols-[25ch_1fr]
gap-6
pt-2;
}
</style>

View file

@ -0,0 +1,7 @@
<template>
<div></div>
</template>
<script setup></script>
<style lang="scss" scoped></style>

View file

@ -0,0 +1,7 @@
<template>
<div></div>
</template>
<script setup></script>
<style lang="scss" scoped></style>

31
fe/vite.config.js Normal file
View file

@ -0,0 +1,31 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
server: {
host: 'localhost',
port: 5173,
watch: {
usePolling: true,
},
},
plugins: [vue(), vueDevTools(), tailwindcss()],
envDir: '../',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
base: '/',
// publicDir: "../public",
build: {
outDir: '../public',
sourcemap: false,
minify: false,
},
})