commit code
This commit is contained in:
commit
e2cf0a1be9
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
.env
|
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
]
|
||||||
|
}
|
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
|
||||||
|
}
|
18
README.md
Normal file
18
README.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||||
|
|
||||||
|
## Type Support For `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||||
|
|
||||||
|
1. Disable the built-in TypeScript Extension
|
||||||
|
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||||
|
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||||
|
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
171
example.json
Normal file
171
example.json
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"name": "Base",
|
||||||
|
"color": "#00ddff",
|
||||||
|
"contents": [
|
||||||
|
{
|
||||||
|
"name": "New Line",
|
||||||
|
"type": "line",
|
||||||
|
"visible": true,
|
||||||
|
"selected": false,
|
||||||
|
"closed": true,
|
||||||
|
"color": "#000",
|
||||||
|
"width": 16,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"start": [
|
||||||
|
64,
|
||||||
|
40
|
||||||
|
],
|
||||||
|
"end": [
|
||||||
|
976,
|
||||||
|
40
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"end": [
|
||||||
|
976,
|
||||||
|
672
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"end": [
|
||||||
|
64,
|
||||||
|
664
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"render": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "New Line",
|
||||||
|
"type": "line",
|
||||||
|
"visible": true,
|
||||||
|
"selected": false,
|
||||||
|
"closed": false,
|
||||||
|
"color": "#000",
|
||||||
|
"width": 16,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"start": [
|
||||||
|
320,
|
||||||
|
664
|
||||||
|
],
|
||||||
|
"end": [
|
||||||
|
320,
|
||||||
|
400
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"end": [
|
||||||
|
64,
|
||||||
|
400
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"render": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "New Line",
|
||||||
|
"type": "line",
|
||||||
|
"visible": true,
|
||||||
|
"selected": false,
|
||||||
|
"closed": false,
|
||||||
|
"color": "#000",
|
||||||
|
"width": 16,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"start": [
|
||||||
|
320,
|
||||||
|
400
|
||||||
|
],
|
||||||
|
"end": [
|
||||||
|
320,
|
||||||
|
40
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"render": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "New Line",
|
||||||
|
"type": "line",
|
||||||
|
"visible": true,
|
||||||
|
"selected": false,
|
||||||
|
"closed": false,
|
||||||
|
"color": "#000",
|
||||||
|
"width": 16,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"start": [
|
||||||
|
720,
|
||||||
|
40
|
||||||
|
],
|
||||||
|
"end": [
|
||||||
|
720,
|
||||||
|
128
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"end": [
|
||||||
|
424,
|
||||||
|
400
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"end": [
|
||||||
|
328,
|
||||||
|
400
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"render": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "New Line",
|
||||||
|
"type": "line",
|
||||||
|
"visible": true,
|
||||||
|
"selected": false,
|
||||||
|
"closed": false,
|
||||||
|
"color": "#000",
|
||||||
|
"width": 16,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"start": [
|
||||||
|
688,
|
||||||
|
672
|
||||||
|
],
|
||||||
|
"end": [
|
||||||
|
688,
|
||||||
|
584
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"end": [
|
||||||
|
880,
|
||||||
|
408
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"end": [
|
||||||
|
976,
|
||||||
|
408
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"render": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"visible": true,
|
||||||
|
"active": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"name": "Rooms",
|
||||||
|
"color": "#00ddff",
|
||||||
|
"contents": [],
|
||||||
|
"visible": true,
|
||||||
|
"active": false
|
||||||
|
}
|
||||||
|
]
|
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full">
|
||||||
|
<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>Home manager</title>
|
||||||
|
</head>
|
||||||
|
<body class="h-full">
|
||||||
|
<div id="app" class="h-full"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
3156
package-lock.json
generated
Normal file
3156
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "homemanager-fe",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/vue": "^1.7.7",
|
||||||
|
"@heroicons/vue": "^2.0.13",
|
||||||
|
"@vueuse/core": "^9.10.0",
|
||||||
|
"jwt-decode": "^3.1.2",
|
||||||
|
"pinia": "^2.0.28",
|
||||||
|
"sass": "^1.57.1",
|
||||||
|
"vue": "^3.2.45",
|
||||||
|
"vue-router": "^4.1.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
|
"@tailwindcss/line-clamp": "^0.4.2",
|
||||||
|
"@vitejs/plugin-vue": "^4.0.0",
|
||||||
|
"autoprefixer": "^10.4.13",
|
||||||
|
"postcss": "^8.4.21",
|
||||||
|
"prettier": "^2.8.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.2.1",
|
||||||
|
"tailwindcss": "^3.2.4",
|
||||||
|
"typescript": "^4.9.3",
|
||||||
|
"vite": "^4.0.0",
|
||||||
|
"vue-tsc": "^1.0.11"
|
||||||
|
}
|
||||||
|
}
|
6
postcss.config.cjs
Normal file
6
postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
18
src/App.vue
Normal file
18
src/App.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { useUserStore } from './store/user.store';
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (userStore.accessToken && !userStore.isLoggedIn) {
|
||||||
|
userStore.loginFromToken();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<router-view></router-view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 496 B |
54
src/components/Dropdown.vue
Normal file
54
src/components/Dropdown.vue
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative" ref="wrapper">
|
||||||
|
<slot name="trigger" :title="title" :open="open" :toggle="toggle">
|
||||||
|
<button type="button" @click="() => toggle()" :aria-expanded="open">
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
<ChevronDownIcon class="ml-2 h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
name="menu-transition"
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0 translate-y-1"
|
||||||
|
enter-to-class="opacity-100 translate-y-0"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100 translate-y-0"
|
||||||
|
leave-to-class="opacity-0 translate-y-1"
|
||||||
|
>
|
||||||
|
<slot :title="title" :open="open" :toggle="toggle" v-if="open"></slot>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronDownIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { onBeforeRouteLeave } from 'vue-router';
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
const wrapper = ref();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const toggle = (to?: boolean) => {
|
||||||
|
open.value = to ?? !open.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const event = (e: MouseEvent) => {
|
||||||
|
if (wrapper.value.contains(e.target as HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
open.value = false;
|
||||||
|
};
|
||||||
|
window.addEventListener('click', event);
|
||||||
|
return () => window.removeEventListener('click', event);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeRouteLeave(() => {
|
||||||
|
toggle(false);
|
||||||
|
});
|
||||||
|
</script>
|
19
src/components/StandardLayout.vue
Normal file
19
src/components/StandardLayout.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-white">
|
||||||
|
<header>
|
||||||
|
<slot name="header">
|
||||||
|
<Header />
|
||||||
|
</slot>
|
||||||
|
</header>
|
||||||
|
<main class="mx-auto min-h-full max-w-7xl p-6">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<slot name="footer" />
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Header from './header/Header.vue';
|
||||||
|
</script>
|
29
src/components/header/Building.vue
Normal file
29
src/components/header/Building.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'dashboard' }"
|
||||||
|
class="flex flex-row items-center space-x-2 px-5 py-3 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 flex-row items-center justify-center rounded-full bg-gray-100"
|
||||||
|
:style="{ backgroundColor: building.color }"
|
||||||
|
>
|
||||||
|
<HomeIcon class="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col">
|
||||||
|
<span class="text-md font-bold">{{ building.displayName }}</span>
|
||||||
|
<span class="text-sm font-light text-gray-800 line-clamp-1">{{
|
||||||
|
building.address
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRightIcon class="h-4 w-4" />
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { HomeIcon, ChevronRightIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { BuildingListItem } from '../../interfaces/building.interfaces';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
building: BuildingListItem;
|
||||||
|
}>();
|
||||||
|
</script>
|
41
src/components/header/Dropdown.vue
Normal file
41
src/components/header/Dropdown.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<Dropdown :title="title">
|
||||||
|
<template #trigger="{ title, open, toggle }">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="() => toggle()"
|
||||||
|
:aria-expanded="open"
|
||||||
|
:class="[
|
||||||
|
open ? 'text-gray-900' : 'text-gray-500',
|
||||||
|
'group inline-flex items-center rounded-md bg-white text-base font-medium hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
<ChevronDownIcon class="ml-2 h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default="{ title, open, toggle }">
|
||||||
|
<div
|
||||||
|
class="absolute z-10 -ml-4 mt-3 w-screen max-w-xs transform px-2 sm:px-0 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="overflow-hidden rounded-lg shadow-lg ring-1 ring-black ring-opacity-5"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronDownIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { onBeforeRouteLeave } from 'vue-router';
|
||||||
|
import Dropdown from '../Dropdown.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
71
src/components/header/Header.vue
Normal file
71
src/components/header/Header.vue
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative border-b-2 border-gray-100 bg-white">
|
||||||
|
<div class="mx-auto max-w-7xl px-5">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between py-5 md:justify-start md:space-x-10"
|
||||||
|
>
|
||||||
|
<div class="flex w-0 justify-start md:mr-4">
|
||||||
|
<router-link to="/"><HomeIcon class="h-8 w-8" /></router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="-my-2 -mr-2 md:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center rounded-md bg-white p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Open menu</span>
|
||||||
|
<Bars3Icon class="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="hidden space-x-10 md:flex">
|
||||||
|
<Dropdown title="My Buildings">
|
||||||
|
<div class="relative bg-white pt-4 pb-4 sm:gap-5">
|
||||||
|
<template v-for="building of buildings">
|
||||||
|
<Building :building="building" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'text-gray-500',
|
||||||
|
'inline-flex items-center rounded-md bg-white text-base font-medium hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span>Groups</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="hidden items-center justify-end md:flex md:flex-1 lg:w-0">
|
||||||
|
<UserPill :user="user" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
HomeIcon,
|
||||||
|
Bars3Icon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
UserIcon,
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useUserStore } from '../../store/user.store';
|
||||||
|
import UserPill from './UserPill.vue';
|
||||||
|
import Dropdown from './Dropdown.vue';
|
||||||
|
import Building from './Building.vue';
|
||||||
|
import { useBuildingStore } from '../../store/building.store';
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const buildingsStore = useBuildingStore();
|
||||||
|
const { user } = storeToRefs(userStore);
|
||||||
|
const { buildings } = storeToRefs(buildingsStore);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
buildingsStore.getBuildings();
|
||||||
|
});
|
||||||
|
</script>
|
24
src/components/header/UserPill.vue
Normal file
24
src/components/header/UserPill.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
class="flex flex-row items-center space-x-2 rounded-full px-1 py-1 pr-2 text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-gray-200"
|
||||||
|
>
|
||||||
|
<UserIcon class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span>{{ user.name }}</span>
|
||||||
|
<ChevronDownIcon class="ml-2 h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronDownIcon, UserIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { User } from '../../interfaces/user.interfaces';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
user: User;
|
||||||
|
}>();
|
||||||
|
</script>
|
140
src/components/house-planner/HousePlanner.vue
Normal file
140
src/components/house-planner/HousePlanner.vue
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative h-full w-full bg-white">
|
||||||
|
<canvas ref="canvas" class="border-none" />
|
||||||
|
<PlannerToolbar>
|
||||||
|
<PlannerTool
|
||||||
|
v-for="toolItem of toolbar"
|
||||||
|
:icon="toolItem.icon"
|
||||||
|
:multiple="!!toolItem.children?.length"
|
||||||
|
:selected="tool === toolItem.tool"
|
||||||
|
@click="selectTool(toolItem.tool, toolItem.subTool)"
|
||||||
|
>
|
||||||
|
<template v-if="toolItem.children?.length">
|
||||||
|
<PlannerTool
|
||||||
|
v-for="subItem of toolItem.children"
|
||||||
|
:icon="subItem.icon"
|
||||||
|
:selected="
|
||||||
|
tool === subItem.tool &&
|
||||||
|
(!subItem.subTool || subItem.subTool === subTool)
|
||||||
|
"
|
||||||
|
@click.stop="selectTool(subItem.tool, subItem.subTool)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</PlannerTool>
|
||||||
|
</PlannerToolbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
PencilSquareIcon,
|
||||||
|
PencilIcon,
|
||||||
|
HomeIcon,
|
||||||
|
ArrowsPointingOutIcon,
|
||||||
|
ArrowDownOnSquareIcon,
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { useSessionStorage } from '@vueuse/core';
|
||||||
|
import { onMounted, ref, shallowRef } from 'vue';
|
||||||
|
import { HousePlanner } from '../../modules/house-planner';
|
||||||
|
import { Layer, ToolEvent } from '../../modules/house-planner/interfaces';
|
||||||
|
import { SubToolType, ToolType } from '../../modules/house-planner/types';
|
||||||
|
import { ToolbarTool } from './interfaces/toolbar.interfaces';
|
||||||
|
import PlannerTool from './PlannerTool.vue';
|
||||||
|
import PlannerToolbar from './PlannerToolbar.vue';
|
||||||
|
|
||||||
|
const canvas = ref();
|
||||||
|
const module = shallowRef(new HousePlanner());
|
||||||
|
const tool = ref<ToolType>('line');
|
||||||
|
const subTool = ref<SubToolType>('line');
|
||||||
|
const serializedLayers = useSessionStorage<Layer[]>(
|
||||||
|
'roomData',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
name: 'Base',
|
||||||
|
color: '#00ddff',
|
||||||
|
contents: [],
|
||||||
|
visible: true,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
name: 'Rooms',
|
||||||
|
color: '#00ddff',
|
||||||
|
contents: [],
|
||||||
|
visible: true,
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ writeDefaults: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolbar: ToolbarTool[] = [
|
||||||
|
{
|
||||||
|
title: 'Move',
|
||||||
|
icon: ArrowsPointingOutIcon,
|
||||||
|
tool: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Draw',
|
||||||
|
icon: PencilSquareIcon,
|
||||||
|
tool: 'line',
|
||||||
|
subTool: 'line',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'Outlines',
|
||||||
|
icon: PencilIcon,
|
||||||
|
tool: 'line',
|
||||||
|
subTool: 'line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Rooms',
|
||||||
|
icon: HomeIcon,
|
||||||
|
tool: 'line',
|
||||||
|
subTool: 'room',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Curves',
|
||||||
|
icon: ArrowDownOnSquareIcon,
|
||||||
|
tool: 'line',
|
||||||
|
subTool: 'curve',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectTool = (newTool: ToolType, newSubTool?: SubToolType) => {
|
||||||
|
if (newTool === tool.value && !newSubTool) {
|
||||||
|
newTool = null;
|
||||||
|
}
|
||||||
|
module.value.manager?.tools.setTool(newTool, newSubTool);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const cleanUp = module.value.initialize(
|
||||||
|
canvas.value,
|
||||||
|
JSON.parse(JSON.stringify(serializedLayers.value))
|
||||||
|
);
|
||||||
|
|
||||||
|
const events: Record<string, (e: CustomEvent) => void> = {
|
||||||
|
'hpc:update': (e: CustomEvent) => {
|
||||||
|
serializedLayers.value = module.value.manager!.layers;
|
||||||
|
},
|
||||||
|
'hpc:tool': (e: CustomEvent<ToolEvent>) => {
|
||||||
|
tool.value = e.detail.primary;
|
||||||
|
subTool.value = e.detail.secondary;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(events).forEach((event) =>
|
||||||
|
canvas.value.addEventListener(event, events[event])
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Object.keys(events).forEach((event) =>
|
||||||
|
canvas.value.removeEventListener(event, events[event])
|
||||||
|
);
|
||||||
|
cleanUp();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
56
src/components/house-planner/PlannerTool.vue
Normal file
56
src/components/house-planner/PlannerTool.vue
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
selected && multiple
|
||||||
|
? [
|
||||||
|
'wrapper rounded-full px-1 py-1 shadow-md ring-1 ring-black ring-opacity-5',
|
||||||
|
]
|
||||||
|
: '',
|
||||||
|
'flex flex-row items-center',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
selected && !multiple ? ' bg-purple-400 hover:bg-purple-300' : '',
|
||||||
|
selected && multiple ? ' bg-purple-500 hover:bg-purple-400' : '',
|
||||||
|
!selected ? 'hover:bg-gray-100' : '',
|
||||||
|
'flex h-12 w-12 items-center justify-center rounded-full shadow-md ring-1 ring-black ring-opacity-5',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<component :is="icon" class="h-8 w-8" />
|
||||||
|
</button>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0 translate-y-1"
|
||||||
|
enter-to-class="opacity-100 translate-y-0"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100 translate-y-0"
|
||||||
|
leave-to-class="opacity-0 translate-y-1"
|
||||||
|
>
|
||||||
|
<div v-if="selected && multiple" class="flex flex-row space-x-1">
|
||||||
|
<div class="ml-1 mr-1 h-12 w-1 border-r-2 border-gray-100"></div>
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
icon: Component;
|
||||||
|
multiple?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.wrapper {
|
||||||
|
margin-top: -8px;
|
||||||
|
margin-bottom: -8px;
|
||||||
|
}
|
||||||
|
</style>
|
11
src/components/house-planner/PlannerToolbar.vue
Normal file
11
src/components/house-planner/PlannerToolbar.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 z-10 w-screen max-w-xs rounded-lg bg-white lg:left-1/2 lg:ml-0 lg:-translate-x-1/2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-1 flex-row items-center justify-center space-x-2 py-4 px-4 shadow-lg ring-1 ring-black ring-opacity-5"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -0,0 +1,10 @@
|
|||||||
|
import type { Component } from 'vue';
|
||||||
|
import { SubToolType, ToolType } from '../../../modules/house-planner/types';
|
||||||
|
|
||||||
|
export interface ToolbarTool {
|
||||||
|
title: string;
|
||||||
|
icon: Component;
|
||||||
|
tool: ToolType;
|
||||||
|
subTool?: SubToolType;
|
||||||
|
children?: ToolbarTool[];
|
||||||
|
}
|
14
src/composables/useAccessToken.ts
Normal file
14
src/composables/useAccessToken.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
export const useAccessToken = () => {
|
||||||
|
const accessToken = useLocalStorage<string>('accessToken', null, {
|
||||||
|
writeDefaults: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authHeader = computed(() => ({
|
||||||
|
Authorization: `Bearer ${accessToken.value}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { accessToken, authHeader };
|
||||||
|
};
|
4
src/constants/index.ts
Normal file
4
src/constants/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
const env = import.meta.env;
|
||||||
|
|
||||||
|
export const BACKEND_URL =
|
||||||
|
(env.BACKEND_URL as string) || 'http://localhost:3000';
|
8
src/interfaces/building.interfaces.ts
Normal file
8
src/interfaces/building.interfaces.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export interface BuildingListItem {
|
||||||
|
id: number;
|
||||||
|
displayName: string;
|
||||||
|
address: string;
|
||||||
|
color: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
7
src/interfaces/user.interfaces.ts
Normal file
7
src/interfaces/user.interfaces.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export interface User {
|
||||||
|
sub: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
picture?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
14
src/main.ts
Normal file
14
src/main.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import './style.scss';
|
||||||
|
import App from './App.vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import router from './router';
|
||||||
|
|
||||||
|
const pinia = createPinia();
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
app.use(pinia);
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
app.mount('#app');
|
223
src/modules/house-planner/canvas.ts
Normal file
223
src/modules/house-planner/canvas.ts
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
import { HousePlannerCanvasGrid } from './grid';
|
||||||
|
import { BezierSegment, Layer, Line, LineSegment, Vec2 } from './interfaces';
|
||||||
|
import { HousePlannerCanvasTools } from './tools';
|
||||||
|
import {
|
||||||
|
rad2deg,
|
||||||
|
vec2Add,
|
||||||
|
vec2AngleFromOrigin,
|
||||||
|
vec2Distance,
|
||||||
|
vec2DivideScalar,
|
||||||
|
vec2PointFromAngle,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
export class HousePlannerCanvas {
|
||||||
|
public ctx!: CanvasRenderingContext2D;
|
||||||
|
public layers: Layer[] = [];
|
||||||
|
public tools = new HousePlannerCanvasTools(this);
|
||||||
|
public grid = new HousePlannerCanvasGrid(this, 8);
|
||||||
|
|
||||||
|
constructor(public canvas: HTMLCanvasElement) {
|
||||||
|
this.ctx = this.canvas.getContext('2d')!;
|
||||||
|
this.setupEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
get width() {
|
||||||
|
return this.canvas.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
get height() {
|
||||||
|
return this.canvas.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
addLayer(layer: Layer) {
|
||||||
|
this.layers.push(layer);
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanUp() {
|
||||||
|
this.tools.cleanUp();
|
||||||
|
window.removeEventListener('keyup', this.boundKeyUpEvent);
|
||||||
|
window.removeEventListener('keydown', this.boundKeyDownEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
this.ctx.clearRect(0, 0, this.width, this.height);
|
||||||
|
this.grid.draw();
|
||||||
|
this.tools.drawHighlights();
|
||||||
|
for (const layer of this.layers) {
|
||||||
|
if (!layer.visible) continue;
|
||||||
|
if (!layer.contents?.length) continue;
|
||||||
|
this.drawLayer(layer);
|
||||||
|
}
|
||||||
|
this.tools.drawControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
makeBezier(segment: BezierSegment, path: Path2D) {
|
||||||
|
const bezier = segment as BezierSegment;
|
||||||
|
const [cp1x, cp1y] = bezier.startControl;
|
||||||
|
const [cp2x, cp2y] = bezier.endControl;
|
||||||
|
const [x, y] = bezier.end;
|
||||||
|
path.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
makeLinePath(line: Line) {
|
||||||
|
const path = new Path2D();
|
||||||
|
const [firstSegment, ...segments] = line.segments;
|
||||||
|
// first segment must have a starting point
|
||||||
|
if (!firstSegment.start) return path;
|
||||||
|
path.moveTo(...firstSegment.start);
|
||||||
|
|
||||||
|
if (line.type === 'curve') {
|
||||||
|
const lineLength = vec2Distance(firstSegment.start, firstSegment.end);
|
||||||
|
const lineAngle = vec2AngleFromOrigin(
|
||||||
|
firstSegment.end,
|
||||||
|
firstSegment.start!
|
||||||
|
);
|
||||||
|
const ninety = lineAngle + Math.PI / 2;
|
||||||
|
path.moveTo(...firstSegment.end);
|
||||||
|
path.arc(
|
||||||
|
firstSegment.start[0],
|
||||||
|
firstSegment.start[1],
|
||||||
|
lineLength,
|
||||||
|
lineAngle,
|
||||||
|
ninety
|
||||||
|
);
|
||||||
|
path.lineTo(...firstSegment.start);
|
||||||
|
} else if ((firstSegment as BezierSegment).startControl) {
|
||||||
|
this.makeBezier(firstSegment as BezierSegment, path);
|
||||||
|
} else {
|
||||||
|
path.lineTo(...firstSegment.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
if ((segment as BezierSegment).startControl) {
|
||||||
|
this.makeBezier(segment as BezierSegment, path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
path.lineTo(...segment.end);
|
||||||
|
}
|
||||||
|
if (line.closed && line.type !== 'curve') {
|
||||||
|
path.closePath();
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLine(line: Line, overBounds = 0, overrideColor?: string) {
|
||||||
|
if (line.lineDash) {
|
||||||
|
this.ctx.setLineDash(line.lineDash);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ctx.strokeStyle = overrideColor || line.color || '#000';
|
||||||
|
this.ctx.lineWidth = line.width + overBounds;
|
||||||
|
|
||||||
|
if (line.lineCap) {
|
||||||
|
this.ctx.lineCap = line.lineCap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isOnLine(line: Line, x: number, y: number, selectError = 16) {
|
||||||
|
let path = line.render || this.makeLinePath(line);
|
||||||
|
this.setupLine(line, selectError);
|
||||||
|
return this.ctx.isPointInStroke(path, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
isOnSegment(
|
||||||
|
line: Line,
|
||||||
|
segment: LineSegment,
|
||||||
|
lastPoint: Vec2,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
selectError = 16
|
||||||
|
) {
|
||||||
|
const fakePath = this.makeLinePath({
|
||||||
|
...line,
|
||||||
|
closed: false,
|
||||||
|
segments: [{ ...segment, start: lastPoint }],
|
||||||
|
});
|
||||||
|
this.setupLine(line, selectError);
|
||||||
|
return this.ctx.isPointInStroke(fakePath, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private keyDownEvent(e: KeyboardEvent) {
|
||||||
|
if (e.target !== document.body && e.target != null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
this.tools.onKeyDown(e);
|
||||||
|
}
|
||||||
|
private boundKeyDownEvent = this.keyDownEvent.bind(this);
|
||||||
|
private keyUpEvent(e: KeyboardEvent) {
|
||||||
|
if (e.target !== document.body && e.target != null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
this.tools.onKeyUp(e);
|
||||||
|
}
|
||||||
|
private boundKeyUpEvent = this.keyUpEvent.bind(this);
|
||||||
|
|
||||||
|
private setupEvents() {
|
||||||
|
window.addEventListener('keyup', this.boundKeyUpEvent);
|
||||||
|
window.addEventListener('keydown', this.boundKeyDownEvent);
|
||||||
|
|
||||||
|
this.canvas.addEventListener('mousemove', (e) => this.tools.onMouseMove(e));
|
||||||
|
this.canvas.addEventListener('mousedown', (e) => this.tools.onMouseDown(e));
|
||||||
|
this.canvas.addEventListener('mouseup', (e) => this.tools.onMouseUp(e));
|
||||||
|
|
||||||
|
this.canvas.addEventListener('touchmove', (e) => this.tools.onTouchMove(e));
|
||||||
|
this.canvas.addEventListener('touchstart', (e) =>
|
||||||
|
this.tools.onTouchStart(e)
|
||||||
|
);
|
||||||
|
this.canvas.addEventListener('touchend', (e) => this.tools.onTouchEnd(e));
|
||||||
|
this.canvas.addEventListener('wheel', (e) => this.tools.onMouseWheel(e));
|
||||||
|
|
||||||
|
this.canvas.addEventListener('pointerleave', () =>
|
||||||
|
this.tools.onPointerLeave()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawRoomText(line: Line) {
|
||||||
|
const centerPoint = vec2DivideScalar(
|
||||||
|
line.segments.reduce<Vec2 | null>((prev, curr) => {
|
||||||
|
if (!prev) {
|
||||||
|
if (curr.start) {
|
||||||
|
return vec2Add(curr.start, curr.end);
|
||||||
|
}
|
||||||
|
return curr.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
let preadd = vec2Add(prev, curr.end);
|
||||||
|
if (curr.start) {
|
||||||
|
preadd = vec2Add(curr.start, preadd);
|
||||||
|
}
|
||||||
|
return preadd;
|
||||||
|
}, null) as Vec2,
|
||||||
|
line.segments.length + 1
|
||||||
|
);
|
||||||
|
this.ctx.font = '16px Arial';
|
||||||
|
this.ctx.fillStyle = line.color;
|
||||||
|
const { width } = this.ctx.measureText(line.name);
|
||||||
|
this.ctx.fillText(
|
||||||
|
line.name,
|
||||||
|
centerPoint[0] - width / 2,
|
||||||
|
centerPoint[1] - 8
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawLine(line: Line) {
|
||||||
|
const path = this.makeLinePath(line);
|
||||||
|
line.render = path;
|
||||||
|
this.setupLine(line);
|
||||||
|
this.ctx.stroke(path);
|
||||||
|
if (line.type === 'room') {
|
||||||
|
this.drawRoomText(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawLayer(layer: Layer) {
|
||||||
|
for (const item of layer.contents) {
|
||||||
|
if (!item.visible) continue;
|
||||||
|
const line = item as Line;
|
||||||
|
if (line.segments) {
|
||||||
|
this.drawLine(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
36
src/modules/house-planner/grid.ts
Normal file
36
src/modules/house-planner/grid.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type { HousePlannerCanvas } from './canvas';
|
||||||
|
|
||||||
|
export class HousePlannerCanvasGrid {
|
||||||
|
constructor(
|
||||||
|
public manager: HousePlannerCanvas,
|
||||||
|
public gridSnap = 8,
|
||||||
|
public edgeOffset = 8 * 4
|
||||||
|
) {}
|
||||||
|
|
||||||
|
setGridSize(size: number) {
|
||||||
|
this.gridSnap = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
const cellsPerWidth = Math.round(
|
||||||
|
(this.manager.width - this.edgeOffset) / this.gridSnap
|
||||||
|
);
|
||||||
|
const cellsPerHeight = Math.round(
|
||||||
|
(this.manager.height - this.edgeOffset) / this.gridSnap
|
||||||
|
);
|
||||||
|
const path = new Path2D();
|
||||||
|
for (let x = 0; x < cellsPerWidth - 3; x++) {
|
||||||
|
const absX = this.edgeOffset + x * this.gridSnap;
|
||||||
|
path.moveTo(absX, this.edgeOffset);
|
||||||
|
path.lineTo(absX, cellsPerHeight * this.gridSnap);
|
||||||
|
}
|
||||||
|
for (let y = 0; y < cellsPerHeight - 3; y++) {
|
||||||
|
const absY = this.edgeOffset + y * this.gridSnap;
|
||||||
|
path.moveTo(this.edgeOffset, absY);
|
||||||
|
path.lineTo(cellsPerWidth * this.gridSnap, absY);
|
||||||
|
}
|
||||||
|
this.manager.ctx.strokeStyle = '#ddd';
|
||||||
|
this.manager.ctx.lineWidth = 1;
|
||||||
|
this.manager.ctx.stroke(path);
|
||||||
|
}
|
||||||
|
}
|
52
src/modules/house-planner/history.ts
Normal file
52
src/modules/house-planner/history.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { History } from './interfaces';
|
||||||
|
|
||||||
|
export class HousePlannerCanvasHistory {
|
||||||
|
private history: History<unknown>[][] = [];
|
||||||
|
private unhistory: History<unknown>[][] = [];
|
||||||
|
private lastCommandWasUndo = false;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
appendToHistory(changes: History<any>[]) {
|
||||||
|
this.history.push(changes);
|
||||||
|
this.unhistory.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
const lastChanges = this.history.pop();
|
||||||
|
if (!lastChanges) return;
|
||||||
|
if (!this.lastCommandWasUndo && this.unhistory.length) {
|
||||||
|
this.unhistory.length = 0;
|
||||||
|
}
|
||||||
|
this.lastCommandWasUndo = true;
|
||||||
|
|
||||||
|
let redo = [];
|
||||||
|
for (const change of lastChanges) {
|
||||||
|
if (!change.object) continue;
|
||||||
|
const preChange = (change.object as any)[change.property];
|
||||||
|
(change.object as any)[change.property] = change.value;
|
||||||
|
redo.push({
|
||||||
|
...change,
|
||||||
|
value: preChange as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.unhistory.push(redo);
|
||||||
|
}
|
||||||
|
|
||||||
|
redo() {
|
||||||
|
const lastChanges = this.unhistory.pop();
|
||||||
|
if (!lastChanges) return;
|
||||||
|
this.lastCommandWasUndo = false;
|
||||||
|
let undo = [];
|
||||||
|
for (const change of lastChanges) {
|
||||||
|
if (!change.object) continue;
|
||||||
|
const preChange = (change.object as any)[change.property];
|
||||||
|
(change.object as any)[change.property] = change.value;
|
||||||
|
undo.push({
|
||||||
|
...change,
|
||||||
|
value: preChange as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.history.push(undo);
|
||||||
|
}
|
||||||
|
}
|
37
src/modules/house-planner/index.ts
Normal file
37
src/modules/house-planner/index.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { HousePlannerCanvas } from './canvas';
|
||||||
|
import { Layer } from './interfaces';
|
||||||
|
|
||||||
|
export class HousePlanner {
|
||||||
|
public canvas!: HTMLCanvasElement;
|
||||||
|
public manager?: HousePlannerCanvas;
|
||||||
|
|
||||||
|
initialize(canvas: HTMLCanvasElement, initialData: Layer[]) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.resizeCanvas();
|
||||||
|
this.addResizeEvents();
|
||||||
|
this.manager = new HousePlannerCanvas(canvas);
|
||||||
|
this.manager.layers = initialData;
|
||||||
|
this.manager.tools.selectLayer(
|
||||||
|
initialData[initialData.findIndex((layer) => layer.active)]
|
||||||
|
);
|
||||||
|
this.manager.draw();
|
||||||
|
return () => this.cleanUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanUp() {
|
||||||
|
window.removeEventListener('resize', this.boundResizeEvent);
|
||||||
|
this.manager?.cleanUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
private addResizeEvents() {
|
||||||
|
window.addEventListener('resize', this.boundResizeEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeCanvas() {
|
||||||
|
this.canvas.width = this.canvas.parentElement!.clientWidth;
|
||||||
|
this.canvas.height = this.canvas.parentElement!.clientHeight;
|
||||||
|
this.manager?.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boundResizeEvent = this.resizeCanvas.bind(this);
|
||||||
|
}
|
54
src/modules/house-planner/interfaces.ts
Normal file
54
src/modules/house-planner/interfaces.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { SubToolType, ToolType } from './types';
|
||||||
|
|
||||||
|
export type Vec2 = [number, number];
|
||||||
|
export interface LineSegment {
|
||||||
|
start?: Vec2;
|
||||||
|
end: Vec2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BezierSegment extends LineSegment {
|
||||||
|
startControl: Vec2;
|
||||||
|
endControl: Vec2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayerObject {
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
type: 'line' | 'room' | 'curve' | 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Line extends LayerObject {
|
||||||
|
segments: LineSegment[];
|
||||||
|
width: number;
|
||||||
|
color: string;
|
||||||
|
render?: Path2D;
|
||||||
|
lineCap?: CanvasLineCap;
|
||||||
|
closed?: boolean;
|
||||||
|
lineDash?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Layer {
|
||||||
|
index: number;
|
||||||
|
contents: LayerObject[];
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
visible: boolean;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface History<T> {
|
||||||
|
object: T;
|
||||||
|
property: keyof T;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEvent {
|
||||||
|
event: string;
|
||||||
|
object?: LayerObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolEvent {
|
||||||
|
primary: ToolType;
|
||||||
|
secondary: SubToolType;
|
||||||
|
}
|
739
src/modules/house-planner/tools.ts
Normal file
739
src/modules/house-planner/tools.ts
Normal file
@ -0,0 +1,739 @@
|
|||||||
|
import type { HousePlannerCanvas } from './canvas';
|
||||||
|
import { HousePlannerCanvasHistory } from './history';
|
||||||
|
import {
|
||||||
|
BezierSegment,
|
||||||
|
History,
|
||||||
|
Layer,
|
||||||
|
LayerObject,
|
||||||
|
Line,
|
||||||
|
LineSegment,
|
||||||
|
ToolEvent,
|
||||||
|
Vec2,
|
||||||
|
} from './interfaces';
|
||||||
|
import { ToolType, SubToolType, BezierControl, LineControl } from './types';
|
||||||
|
import {
|
||||||
|
vec2Add,
|
||||||
|
vec2Equals,
|
||||||
|
vec2InCircle,
|
||||||
|
vec2Inverse,
|
||||||
|
vec2Snap,
|
||||||
|
vec2Sub,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
export class HousePlannerCanvasTools {
|
||||||
|
public selectedLayer?: Layer;
|
||||||
|
public selectedObjects: LayerObject[] = [];
|
||||||
|
public mousePosition: Vec2 = [0, 0];
|
||||||
|
public mousePositionSnapped: Vec2 = [0, 0];
|
||||||
|
public gridSnap = true;
|
||||||
|
public gridSnapScale = 8;
|
||||||
|
public tool: ToolType = 'line';
|
||||||
|
public subTool: SubToolType = 'line';
|
||||||
|
public history = new HousePlannerCanvasHistory();
|
||||||
|
public lastStrokeWidth = 16;
|
||||||
|
public lastColor = '#000';
|
||||||
|
private dragging = false;
|
||||||
|
private pinching = false;
|
||||||
|
private lastPinchLength = 0;
|
||||||
|
private moved = false;
|
||||||
|
private holdShift = false;
|
||||||
|
private selectError = 16;
|
||||||
|
private bezierControls: BezierSegment[] = [];
|
||||||
|
private lineControls: LineSegment[] = [];
|
||||||
|
private handlingBezier: BezierControl | null = null;
|
||||||
|
private handlingLine: LineControl | null = null;
|
||||||
|
private drawingLine: Line | null = null;
|
||||||
|
private clickedOn: LayerObject | null = null;
|
||||||
|
private movingObject = false;
|
||||||
|
|
||||||
|
constructor(public manager: HousePlannerCanvas) {}
|
||||||
|
|
||||||
|
get canvas() {
|
||||||
|
return this.manager.canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
get ctx() {
|
||||||
|
return this.manager.ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelected(object: LayerObject) {
|
||||||
|
return this.selectedObjects.indexOf(object) > -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectLayer(layer: Layer) {
|
||||||
|
if (this.selectedLayer) {
|
||||||
|
this.selectedLayer.active = false;
|
||||||
|
for (const item of this.selectedLayer.contents) {
|
||||||
|
item.selected = false;
|
||||||
|
}
|
||||||
|
this.selectedObjects.length = 0;
|
||||||
|
}
|
||||||
|
this.selectedLayer = layer;
|
||||||
|
this.selectedLayer.active = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectObject(object?: LayerObject | null, add = false) {
|
||||||
|
if (!object) {
|
||||||
|
if (!add) {
|
||||||
|
this.selectedObjects.forEach((object) => {
|
||||||
|
object.selected = false;
|
||||||
|
});
|
||||||
|
this.selectedObjects.length = 0;
|
||||||
|
}
|
||||||
|
this.manager.draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.selectedObjects.includes(object)) {
|
||||||
|
// Unselect in multi-select
|
||||||
|
if (add) {
|
||||||
|
const foundAt = this.selectedObjects.indexOf(object);
|
||||||
|
object.selected = false;
|
||||||
|
this.selectedObjects.splice(foundAt, 1);
|
||||||
|
this.manager.draw();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select in multi-select
|
||||||
|
if (add) {
|
||||||
|
object.selected = true;
|
||||||
|
this.selectedObjects.push(object);
|
||||||
|
this.manager.draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedObjects.forEach((obj) => {
|
||||||
|
obj.selected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
object.selected = true;
|
||||||
|
|
||||||
|
this.selectedObjects = [object];
|
||||||
|
this.manager.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
getMousedObject() {
|
||||||
|
if (!this.selectedLayer?.visible) return null;
|
||||||
|
const [x, y] = this.mousePosition;
|
||||||
|
for (const object of this.selectedLayer.contents) {
|
||||||
|
if ((object as Line).segments) {
|
||||||
|
const moused = this.manager.isOnLine(
|
||||||
|
object as Line,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
this.selectError
|
||||||
|
);
|
||||||
|
if (moused) return object;
|
||||||
|
}
|
||||||
|
// TODO: other kinds of objects
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMousedLineSegment(line: Line) {
|
||||||
|
if (!this.selectedLayer?.visible) return null;
|
||||||
|
const [x, y] = this.mousePosition;
|
||||||
|
let lastSegment = null;
|
||||||
|
for (const segment of line.segments) {
|
||||||
|
const lastPoint = lastSegment ? lastSegment.end : (segment.start as Vec2);
|
||||||
|
if (
|
||||||
|
this.manager.isOnSegment(
|
||||||
|
line,
|
||||||
|
segment,
|
||||||
|
lastPoint,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
this.selectError
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
lastSegment = segment;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawHighlights() {
|
||||||
|
for (const object of this.selectedObjects) {
|
||||||
|
const line = object as Line;
|
||||||
|
if (line.segments) {
|
||||||
|
const path = this.manager.makeLinePath(line);
|
||||||
|
this.manager.setupLine(line, this.selectError, '#00ddff55');
|
||||||
|
this.ctx.stroke(path);
|
||||||
|
}
|
||||||
|
// TODO: other kinds of objects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBezierControls(bezier: BezierSegment, previousEnd: Vec2) {
|
||||||
|
this.bezierControls.push(bezier);
|
||||||
|
const [cp1x, cp1y] = bezier.startControl;
|
||||||
|
const [cp2x, cp2y] = bezier.endControl;
|
||||||
|
const [endx, endy] = bezier.end;
|
||||||
|
const [prevx, prevy] = previousEnd;
|
||||||
|
this.ctx.fillStyle = '#00ddffaa';
|
||||||
|
this.ctx.strokeStyle = '#00ddffaa';
|
||||||
|
this.ctx.lineWidth = 2;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(cp1x, cp1y, this.selectError / 2, 0, 2 * Math.PI);
|
||||||
|
this.ctx.fill();
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(cp2x, cp2y, this.selectError / 2, 0, 2 * Math.PI);
|
||||||
|
this.ctx.fill();
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(cp1x, cp1y);
|
||||||
|
this.ctx.lineTo(prevx, prevy);
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(cp2x, cp2y);
|
||||||
|
this.ctx.lineTo(endx, endy);
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawLineControls(line: LineSegment, previousEnd: Vec2) {
|
||||||
|
this.lineControls.push(line);
|
||||||
|
const [endx, endy] = line.end;
|
||||||
|
this.ctx.fillStyle = '#00ddffaa';
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(endx, endy, this.selectError / 2, 0, 2 * Math.PI);
|
||||||
|
this.ctx.fill();
|
||||||
|
|
||||||
|
if (line.start) {
|
||||||
|
const [startx, starty] = line.start;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(startx, starty, this.selectError / 2, 0, 2 * Math.PI);
|
||||||
|
this.ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawControls() {
|
||||||
|
this.bezierControls.length = 0;
|
||||||
|
this.lineControls.length = 0;
|
||||||
|
for (const object of this.selectedObjects) {
|
||||||
|
const line = object as Line;
|
||||||
|
if (line.segments && line.render) {
|
||||||
|
let lastSegment = null;
|
||||||
|
for (const segment of line.segments) {
|
||||||
|
const bezier = segment as BezierSegment;
|
||||||
|
const previousPoint = lastSegment ? lastSegment.end : segment.start!;
|
||||||
|
if (bezier.startControl && bezier.endControl) {
|
||||||
|
this.drawBezierControls(bezier, previousPoint);
|
||||||
|
}
|
||||||
|
this.drawLineControls(segment, previousPoint);
|
||||||
|
lastSegment = segment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: other kinds of objects
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tool === 'line') {
|
||||||
|
if (this.selectedObjects.length) return;
|
||||||
|
const [mx, my] = this.mousePositionSnapped;
|
||||||
|
this.ctx.fillStyle = '#00ddffaa';
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(mx, my, this.selectError / 2, 0, 2 * Math.PI);
|
||||||
|
this.ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanUp() {}
|
||||||
|
|
||||||
|
setTool(tool: typeof this.tool, subTool?: typeof this.subTool) {
|
||||||
|
this.tool = tool;
|
||||||
|
if (subTool !== undefined) this.subTool = subTool;
|
||||||
|
this.canvas.dispatchEvent(
|
||||||
|
new CustomEvent<ToolEvent>('hpc:tool', {
|
||||||
|
detail: { primary: this.tool, secondary: this.subTool },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseMove(e: MouseEvent) {
|
||||||
|
this.dragEvent(e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
onMouseDown(e: MouseEvent) {
|
||||||
|
this.mousePosition = [e.clientX, e.clientY];
|
||||||
|
this.mousePositionSnapped = this.gridSnap
|
||||||
|
? vec2Snap(this.mousePosition, this.gridSnapScale)
|
||||||
|
: this.mousePosition;
|
||||||
|
this.dragging = true;
|
||||||
|
this.moved = false;
|
||||||
|
|
||||||
|
this.pointerDown();
|
||||||
|
}
|
||||||
|
onMouseUp(e: MouseEvent) {
|
||||||
|
this.dragging = false;
|
||||||
|
this.pointerUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchMove(ev: TouchEvent) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
if (ev.touches.length === 2 && this.pinching) {
|
||||||
|
const pinchLength = Math.hypot(
|
||||||
|
ev.touches[0].pageX - ev.touches[1].pageX,
|
||||||
|
ev.touches[0].pageY - ev.touches[1].pageY
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: zoom
|
||||||
|
// if (this.lastPinchLength) {
|
||||||
|
// const delta = pinchLength / this.lastPinchLength;
|
||||||
|
// const scaleX = (ev.touches[0].clientX - this._posx) / this._zoom;
|
||||||
|
// const scaleY = (ev.touches[0].clientY - this._posy) / this._zoom;
|
||||||
|
|
||||||
|
// delta > 0 ? (this._zoom *= delta) : (this._zoom /= delta);
|
||||||
|
// this._zoom = clamp(this._zoom, 1, 100);
|
||||||
|
|
||||||
|
// this._posx = ev.touches[0].clientX - scaleX * this._zoom;
|
||||||
|
// this._posy = ev.touches[0].clientY - scaleY * this._zoom;
|
||||||
|
// }
|
||||||
|
// this.lastPinchLength = pinchLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dragEvent(ev.touches[0].clientX, ev.touches[0].clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchStart(e: TouchEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0] || e.changedTouches[0];
|
||||||
|
this.mousePosition = [touch.pageX, touch.pageY];
|
||||||
|
this.mousePositionSnapped = this.gridSnap
|
||||||
|
? vec2Snap(this.mousePosition, this.gridSnapScale)
|
||||||
|
: this.mousePosition;
|
||||||
|
this.dragging = true;
|
||||||
|
this.moved = false;
|
||||||
|
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
this.pinching = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pointerDown();
|
||||||
|
}
|
||||||
|
onTouchEnd(e: TouchEvent) {
|
||||||
|
this.pinching = false;
|
||||||
|
this.lastPinchLength = 0;
|
||||||
|
|
||||||
|
if (!e.touches?.length) {
|
||||||
|
this.dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pointerUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseWheel(e: WheelEvent) {
|
||||||
|
// TODO: zoom
|
||||||
|
// ev.preventDefault();
|
||||||
|
// this._mousex = ev.clientX;
|
||||||
|
// this._mousey = ev.clientY;
|
||||||
|
// const scaleX = (ev.clientX - this._posx) / this._zoom;
|
||||||
|
// const scaleY = (ev.clientY - this._posy) / this._zoom;
|
||||||
|
// ev.deltaY < 0 ? (this._zoom *= 1.2) : (this._zoom /= 1.2);
|
||||||
|
// this._zoom = clamp(this._zoom, this._minZoom, this._maxZoom);
|
||||||
|
// this._posx = ev.clientX - scaleX * this._zoom;
|
||||||
|
// this._posy = ev.clientY - scaleY * this._zoom;
|
||||||
|
// const realSize = this._zoom * this._size;
|
||||||
|
// this._posx = clamp(this._posx, this._cursorx - realSize, this._cursorx);
|
||||||
|
// this._posy = clamp(this._posy, this._cursory - realSize, this._cursory);
|
||||||
|
}
|
||||||
|
onPointerLeave() {
|
||||||
|
this.dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'z' && e.ctrlKey) {
|
||||||
|
this.history.undo();
|
||||||
|
this.manager.draw();
|
||||||
|
this.canvas.dispatchEvent(
|
||||||
|
new CustomEvent('hpc:undo', {
|
||||||
|
detail: 'keyboard',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.canvas.dispatchEvent(
|
||||||
|
new CustomEvent('hpc:update', {
|
||||||
|
detail: {
|
||||||
|
event: 'undo',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'y' && e.ctrlKey) {
|
||||||
|
this.history.redo();
|
||||||
|
this.manager.draw();
|
||||||
|
this.canvas.dispatchEvent(
|
||||||
|
new CustomEvent('hpc:redo', {
|
||||||
|
detail: 'keyboard',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.canvas.dispatchEvent(
|
||||||
|
new CustomEvent('hpc:update', {
|
||||||
|
detail: { event: 'redo' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Shift') {
|
||||||
|
this.holdShift = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyUp(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
if (this.drawingLine && this.selectedLayer) {
|
||||||
|
if (this.subTool === 'room') {
|
||||||
|
this.drawingLine.closed = true;
|
||||||
|
}
|
||||||
|
this.drawingLine.segments.splice(
|
||||||
|
this.drawingLine.segments.length - 1,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
this.history.appendToHistory([
|
||||||
|
{
|
||||||
|
object: this.selectedLayer,
|
||||||
|
property: 'contents',
|
||||||
|
value: [...this.selectedLayer.contents].filter(
|
||||||
|
(item) => item !== this.drawingLine
|
||||||
|
),
|
||||||
|
} as History<typeof this.selectedLayer>,
|
||||||
|
{
|
||||||
|
object: this,
|
||||||
|
property: 'selectedObjects',
|
||||||
|
value: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
this.canvas.dispatchEvent(
|
||||||
|
new CustomEvent('hpc:newobject', {
|
||||||
|
detail: this.drawingLine,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.canvas.dispatchEvent(
|
||||||
|
new CustomEvent('hpc:update', {
|
||||||
|
detail: { event: 'newobject', object: this.drawingLine },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.drawingLine = null;
|
||||||
|
this.manager.draw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (this.selectedObjects.length === 0 && this.tool && !this.drawingLine) {
|
||||||
|
this.setTool(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectObject(null);
|
||||||
|
|
||||||
|
if (this.drawingLine && this.selectedLayer) {
|
||||||
|
const indexOf = this.selectedLayer.contents.indexOf(this.drawingLine);
|
||||||
|
if (indexOf > -1) {
|
||||||
|
this.selectedLayer.contents.splice(indexOf, 1);
|
||||||
|
}
|
||||||
|
this.drawingLine = null;
|
||||||
|
this.manager.draw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Shift') {
|
||||||
|
this.holdShift = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'l') {
|
||||||
|
this.setTool('line');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Delete' || e.key === 'x') {
|
||||||
|
this.deleteSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSelection() {
|
||||||
|
if (!this.selectedObjects.length || !this.selectedLayer) return;
|
||||||
|
this.history.appendToHistory([
|
||||||
|
{
|
||||||
|
object: this.selectedLayer,
|
||||||
|
property: 'contents',
|
||||||
|
value: [...this.selectedLayer.contents],
|
||||||
|
} as History<typeof this.selectedLayer>,
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.selectedLayer.contents = this.selectedLayer.contents.filter(
|
||||||
|
(item) => this.selectedObjects.indexOf(item) === -1
|
||||||
|
);
|
||||||
|
|
||||||
|
this.selectedObjects.length = 0;
|
||||||
|
this.manager.draw();
|
||||||
|
this.canvas.dispatchEvent(
|
||||||
|
new CustomEvent('hpc:update', {
|
||||||
|
detail: { event: 'delete' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private grabbedBezierControl() {
|
||||||
|
let clickedOnControl: BezierControl | null = null;
|
||||||
|
for (const bezier of this.bezierControls) {
|
||||||
|
for (const control of ['startControl', 'endControl']) {
|
||||||
|
const asType = control as 'startControl' | 'endControl';
|
||||||
|
if (
|
||||||
|
vec2InCircle(bezier[asType], this.mousePosition, this.selectError / 2)
|
||||||
|
) {
|
||||||
|
clickedOnControl = [bezier, asType, bezier[asType]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clickedOnControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private grabbedLineControl() {
|
||||||
|
let clickedOnControl: LineControl | null = null;
|
||||||
|
for (const line of this.lineControls) {
|
||||||
|
for (const control of ['start', 'end']) {
|
||||||
|
const asType = control as 'start' | 'end';
|
||||||
|
if (
|
||||||
|
line[asType] &&
|
||||||
|
vec2InCircle(
|
||||||
|
line[asType] as Vec2,
|
||||||
|
this.mousePosition,
|
||||||
|
this.selectError / 2
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
clickedOnControl = [line, asType, line[asType] as Vec2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clickedOnControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private translate(offset: Vec2) {
|
||||||
|
for (const object of this.selectedObjects) {
|
||||||
|
if ((object as Line).segments) {
|
||||||
|
for (const segment of (object as Line).segments) {
|
||||||
|
if ((segment as BezierSegment).startControl) {
|
||||||
|
const bezier = segment as BezierSegment;
|
||||||
|
bezier.startControl = vec2Add(bezier.startControl, offset);
|
||||||
|
bezier.endControl = vec2Add(bezier.endControl, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment.start) {
|
||||||
|
segment.start = vec2Add(segment.start, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
segment.end = vec2Add(segment.end, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.manager.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private pointerDown() {
|
||||||
|
if (this.drawingLine) return;
|
||||||
|
this.clickedOn = this.getMousedObject();
|
||||||
|
const bezierControl = this.grabbedBezierControl();
|
||||||
|
const lineControl = this.grabbedLineControl();
|
||||||
|
if (bezierControl) {
|
||||||
|
this.handlingBezier = bezierControl;
|
||||||
|
} else if (lineControl) {
|
||||||
|
this.handlingLine = lineControl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pointerUp() {
|
||||||
|
if (!this.moved && !this.handlingBezier && !this.handlingLine) {
|
||||||
|
if (this.tool === 'line') {
|
||||||
|
this.startLine();
|
||||||
|
} else if (!this.drawingLine) {
|
||||||
|
this.pick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.handlingBezier) {
|
||||||
|
this.history.appendToHistory([
|
||||||
|
{
|
||||||
|
object: this.handlingBezier[0],
|
||||||
|
property: this.handlingBezier[1],
|
||||||
|
value: this.handlingBezier[2],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.handlingLine) {
|
||||||
|
this.history.appendToHistory([
|
||||||
|
{
|
||||||
|
object: this.handlingLine[0],
|
||||||
|
property: this.handlingLine[1],
|
||||||
|
value: this.handlingLine[2],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.movingObject || this.handlingLine || this.handlingBezier) {
|
||||||
|
this.canvas.dispatchEvent(
|
||||||
|
new CustomEvent('hpc:update', {
|
||||||
|
detail: {
|
||||||
|
event: 'line-move',
|
||||||
|
object: this.handlingLine || this.handlingBezier || this.clickedOn,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handlingBezier = null;
|
||||||
|
this.handlingLine = null;
|
||||||
|
this.clickedOn = null;
|
||||||
|
this.movingObject = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startLine() {
|
||||||
|
if (!this.selectedLayer?.visible) return;
|
||||||
|
if (this.drawingLine) {
|
||||||
|
if (
|
||||||
|
vec2Equals(
|
||||||
|
this.mousePositionSnapped,
|
||||||
|
this.drawingLine.segments[0].start as Vec2
|
||||||
|
) ||
|
||||||
|
this.drawingLine.type === 'curve'
|
||||||
|
) {
|
||||||
|
if (this.drawingLine.type !== 'curve') {
|
||||||
|
this.drawingLine.segments.splice(
|
||||||
|
this.drawingLine.segments.length - 1,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.history.appendToHistory([
|
||||||
|
{
|
||||||
|
object: this.selectedLayer,
|
||||||
|
property: 'contents',
|
||||||
|
value: [...this.selectedLayer.contents].filter(
|
||||||
|
(item) => item !== this.drawingLine
|
||||||
|
),
|
||||||
|
} as History<typeof this.selectedLayer>,
|
||||||
|
{
|
||||||
|
object: this,
|
||||||
|
property: 'selectedObjects',
|
||||||
|
value: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
this.drawingLine.closed = true;
|
||||||
|
this.canvas.dispatchEvent(
|
||||||
|
new CustomEvent('hpc:newobject', {
|
||||||
|
detail: this.drawingLine,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.canvas.dispatchEvent(
|
||||||
|
new CustomEvent('hpc:update', {
|
||||||
|
detail: { event: 'newobject', object: this.drawingLine },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.drawingLine = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drawingLine.segments.push({
|
||||||
|
end: [...this.mousePositionSnapped],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLineObject: Line = {
|
||||||
|
name: 'New Line',
|
||||||
|
type: this.subTool!,
|
||||||
|
visible: true,
|
||||||
|
selected: false,
|
||||||
|
closed: false,
|
||||||
|
color: this.lastColor,
|
||||||
|
width: this.subTool === 'curve' ? 2 : this.lastStrokeWidth,
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
start: [...this.mousePositionSnapped],
|
||||||
|
end: [...this.mousePositionSnapped],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
this.drawingLine = newLineObject;
|
||||||
|
this.selectedLayer.contents.unshift(newLineObject);
|
||||||
|
this.selectObject(this.drawingLine);
|
||||||
|
this.canvas.dispatchEvent(
|
||||||
|
new CustomEvent('hpc:startdrawing', {
|
||||||
|
detail: newLineObject,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pick() {
|
||||||
|
if (this.clickedOn && this.isSelected(this.clickedOn)) {
|
||||||
|
// Pick line segment
|
||||||
|
if ((this.clickedOn as Line).segments) {
|
||||||
|
const segment = this.getMousedLineSegment(this.clickedOn as Line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.selectObject(this.clickedOn, this.holdShift);
|
||||||
|
}
|
||||||
|
|
||||||
|
private dragEvent(x: number, y: number) {
|
||||||
|
this.moved = true;
|
||||||
|
|
||||||
|
const currentPos = this.gridSnap
|
||||||
|
? vec2Snap(this.mousePosition, this.gridSnapScale)
|
||||||
|
: this.mousePosition;
|
||||||
|
|
||||||
|
const rect = this.manager.canvas.getBoundingClientRect();
|
||||||
|
this.mousePosition = [x - rect.left, y - rect.top];
|
||||||
|
this.mousePositionSnapped = this.gridSnap
|
||||||
|
? vec2Snap(this.mousePosition, this.gridSnapScale)
|
||||||
|
: this.mousePosition;
|
||||||
|
|
||||||
|
let offset = vec2Sub(currentPos, this.mousePositionSnapped);
|
||||||
|
|
||||||
|
if (!this.selectedLayer) return;
|
||||||
|
|
||||||
|
if (this.tool) {
|
||||||
|
if (this.drawingLine) {
|
||||||
|
const lastSegment = this.drawingLine.segments.at(-1);
|
||||||
|
if (lastSegment) {
|
||||||
|
lastSegment.end = [...this.mousePositionSnapped];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.manager.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.drawingLine) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.handlingBezier) {
|
||||||
|
const [segment, property] = this.handlingBezier;
|
||||||
|
segment[property] = [...this.mousePositionSnapped];
|
||||||
|
this.manager.draw();
|
||||||
|
} else if (this.handlingLine) {
|
||||||
|
const [segment, property] = this.handlingLine;
|
||||||
|
segment[property] = [...this.mousePositionSnapped];
|
||||||
|
this.manager.draw();
|
||||||
|
} else if (this.clickedOn) {
|
||||||
|
if (!this.movingObject) {
|
||||||
|
// TODO: optimize history storage
|
||||||
|
this.history.appendToHistory([
|
||||||
|
{
|
||||||
|
object: this.selectedLayer,
|
||||||
|
property: 'contents',
|
||||||
|
value: JSON.parse(JSON.stringify(this.selectedLayer['contents'])),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
this.movingObject = true;
|
||||||
|
}
|
||||||
|
this.translate(vec2Inverse(offset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectedEvent() {
|
||||||
|
this.canvas.dispatchEvent(
|
||||||
|
new CustomEvent('hpc:selectionchange', {
|
||||||
|
detail: this.selectedObjects,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
11
src/modules/house-planner/types.ts
Normal file
11
src/modules/house-planner/types.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { BezierSegment, LineSegment, Vec2 } from './interfaces';
|
||||||
|
|
||||||
|
export type ToolType = 'line' | null;
|
||||||
|
export type SubToolType = 'line' | 'curve' | 'room';
|
||||||
|
|
||||||
|
export type BezierControl = [
|
||||||
|
BezierSegment,
|
||||||
|
'startControl' | 'endControl',
|
||||||
|
Vec2
|
||||||
|
];
|
||||||
|
export type LineControl = [LineSegment, 'start' | 'end', Vec2];
|
56
src/modules/house-planner/utils.ts
Normal file
56
src/modules/house-planner/utils.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Vec2 } from './interfaces';
|
||||||
|
|
||||||
|
export const vec2Length = ([x, y]: Vec2) => Math.abs(Math.sqrt(x * x + y * y));
|
||||||
|
|
||||||
|
export const vec2Add = ([x1, y1]: Vec2, [x2, y2]: Vec2): Vec2 => [
|
||||||
|
x1 + x2,
|
||||||
|
y1 + y2,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const vec2Sub = ([x1, y1]: Vec2, [x2, y2]: Vec2): Vec2 => [
|
||||||
|
x1 - x2,
|
||||||
|
y1 - y2,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const vec2MultiplyScalar = ([x, y]: Vec2, scalar: number): Vec2 => [
|
||||||
|
x * scalar,
|
||||||
|
y * scalar,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const vec2DivideScalar = ([x, y]: Vec2, scalar: number): Vec2 => [
|
||||||
|
x / scalar,
|
||||||
|
y / scalar,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const vec2Normalize = (vec: Vec2): Vec2 =>
|
||||||
|
vec2DivideScalar(vec, vec2Length(vec));
|
||||||
|
|
||||||
|
export const vec2Distance = (vec1: Vec2, vec2: Vec2) =>
|
||||||
|
vec2Length(vec2Sub(vec2, vec1));
|
||||||
|
|
||||||
|
export const vec2InCircle = (circle: Vec2, point: Vec2, radius: number) =>
|
||||||
|
vec2Distance(circle, point) <= radius;
|
||||||
|
|
||||||
|
export const vec2Inverse = (vec: Vec2): Vec2 => vec2MultiplyScalar(vec, -1);
|
||||||
|
|
||||||
|
export const vec2Snap = ([x, y]: Vec2, snapScale: number): Vec2 =>
|
||||||
|
vec2MultiplyScalar(
|
||||||
|
[Math.round(x / snapScale), Math.round(y / snapScale)],
|
||||||
|
snapScale
|
||||||
|
);
|
||||||
|
|
||||||
|
export const vec2Equals = ([x1, y1]: Vec2, [x2, y2]: Vec2) =>
|
||||||
|
x1 === x2 && y1 === y2;
|
||||||
|
|
||||||
|
export const vec2AngleFromOrigin = (
|
||||||
|
[x, y]: Vec2,
|
||||||
|
[ox, oy]: Vec2 = [0, 0]
|
||||||
|
): number => Math.atan2(y - oy, x - ox);
|
||||||
|
|
||||||
|
export const vec2PointFromAngle = (
|
||||||
|
angle: number,
|
||||||
|
[ox, oy]: Vec2 = [0, 0]
|
||||||
|
): Vec2 => [Math.cos(angle) + ox, Math.sin(angle) + oy];
|
||||||
|
|
||||||
|
export const deg2rad = (deg: number) => deg * (Math.PI / 180);
|
||||||
|
export const rad2deg = (rad: number) => rad * (180 / Math.PI);
|
48
src/router/index.ts
Normal file
48
src/router/index.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { NavigationGuardWithThis, RouteRecordRaw } from 'vue-router';
|
||||||
|
import Dashboard from '../views/Dashboard.vue';
|
||||||
|
import Login from '../views/Login.vue';
|
||||||
|
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||||
|
import { useUserStore } from '../store/user.store';
|
||||||
|
import HousePlanner from '../components/house-planner/HousePlanner.vue';
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
name: 'dashboard',
|
||||||
|
path: '/',
|
||||||
|
component: Dashboard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'login',
|
||||||
|
path: '/login',
|
||||||
|
component: Login,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'planner',
|
||||||
|
path: '/planner',
|
||||||
|
component: HousePlanner,
|
||||||
|
meta: {
|
||||||
|
authenticated: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach(async (to, from, next) => {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
if (
|
||||||
|
to.name !== 'login' &&
|
||||||
|
to.meta?.authenticated !== false &&
|
||||||
|
!userStore.isLoggedIn
|
||||||
|
) {
|
||||||
|
return next({ name: 'login' });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
22
src/store/building.store.ts
Normal file
22
src/store/building.store.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { useAccessToken } from '../composables/useAccessToken';
|
||||||
|
import { BACKEND_URL } from '../constants';
|
||||||
|
import { BuildingListItem } from '../interfaces/building.interfaces';
|
||||||
|
import jfetch from '../utils/jfetch';
|
||||||
|
|
||||||
|
const { authHeader } = useAccessToken();
|
||||||
|
export const useBuildingStore = defineStore('building', {
|
||||||
|
state: () => {
|
||||||
|
return {
|
||||||
|
buildings: [] as BuildingListItem[],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
async getBuildings() {
|
||||||
|
const { data: buildings } = await jfetch(`${BACKEND_URL}/buildings`, {
|
||||||
|
headers: authHeader.value,
|
||||||
|
});
|
||||||
|
this.buildings = buildings;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
60
src/store/user.store.ts
Normal file
60
src/store/user.store.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { User } from '../interfaces/user.interfaces';
|
||||||
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
import jwtDecode from 'jwt-decode';
|
||||||
|
import { BACKEND_URL } from '../constants';
|
||||||
|
import jfetch from '../utils/jfetch';
|
||||||
|
|
||||||
|
export const useUserStore = defineStore('user', {
|
||||||
|
state: () => {
|
||||||
|
return {
|
||||||
|
currentUser: null as User | null,
|
||||||
|
accessToken: useLocalStorage<string>('accessToken', null, {
|
||||||
|
writeDefaults: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
user: (state) => state.currentUser!,
|
||||||
|
isLoggedIn: (state) => !!state.currentUser,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
loginFromToken() {
|
||||||
|
const token = this.accessToken;
|
||||||
|
try {
|
||||||
|
const decoded = jwtDecode(token) as Record<string, unknown>;
|
||||||
|
if (!decoded.sub) {
|
||||||
|
throw new Error('Invalid token');
|
||||||
|
}
|
||||||
|
this.currentUser = {
|
||||||
|
sub: decoded.sub as string,
|
||||||
|
name: decoded.name as string,
|
||||||
|
email: decoded.email as string,
|
||||||
|
picture: decoded.picture as string,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async login({ email, password }: { email: string; password: string }) {
|
||||||
|
const header = btoa(`${email}:${password}`);
|
||||||
|
const { data: tokenResponse } = await jfetch(
|
||||||
|
`${BACKEND_URL}/user/login`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: new Headers({
|
||||||
|
Authorization: `Basic ${header}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.accessToken = tokenResponse.access_token;
|
||||||
|
this.loginFromToken();
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
this.accessToken = '';
|
||||||
|
this.currentUser = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
4
src/style.scss
Normal file
4
src/style.scss
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
46
src/utils/jfetch.ts
Normal file
46
src/utils/jfetch.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export class JFetchResponse<T> {
|
||||||
|
constructor(
|
||||||
|
public statusCode: number,
|
||||||
|
public headers: Headers,
|
||||||
|
public data: T
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JFetchError {
|
||||||
|
constructor(
|
||||||
|
public statusCode: number,
|
||||||
|
public headers: Headers,
|
||||||
|
public data: any
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function jfetch<T = any>(
|
||||||
|
url: string,
|
||||||
|
opts: Record<string, unknown> & {
|
||||||
|
returnType?: 'blob' | 'json' | 'arraybuffer';
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { returnType } = opts;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...(opts as Record<string, unknown>),
|
||||||
|
});
|
||||||
|
|
||||||
|
let data!: T;
|
||||||
|
if (!returnType || returnType === 'json') {
|
||||||
|
data = await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (returnType === 'arraybuffer') {
|
||||||
|
data = (await response.arrayBuffer()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (returnType === 'blob') {
|
||||||
|
data = (await response.blob()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status < 200 || response.status > 299) {
|
||||||
|
throw new JFetchError(response.status, response.headers, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JFetchResponse<T>(response.status, response.headers, data);
|
||||||
|
}
|
15
src/views/Dashboard.vue
Normal file
15
src/views/Dashboard.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<StandardLayout>
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<p>Hello, {{ user.name }}</p>
|
||||||
|
</StandardLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useUserStore } from '../store/user.store';
|
||||||
|
import StandardLayout from '../components/StandardLayout.vue';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const user = ref(userStore.user);
|
||||||
|
</script>
|
108
src/views/Login.vue
Normal file
108
src/views/Login.vue
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex min-h-full items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-md space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900"
|
||||||
|
>
|
||||||
|
Sign in to your account
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-height ease-out duration-500 origin-bottom overflow-hidden"
|
||||||
|
enter-from-class="h-0"
|
||||||
|
enter-to-class="h-10"
|
||||||
|
>
|
||||||
|
<div class="mb-4 rounded" v-if="errorMessage">
|
||||||
|
<div
|
||||||
|
class="rounded bg-red-100 px-4 py-2 text-center font-bold text-red-700"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
<form
|
||||||
|
@submit.prevent="doLogin"
|
||||||
|
class="mt-8 overflow-hidden shadow sm:rounded-md"
|
||||||
|
>
|
||||||
|
<div class="bg-white px-4 py-5 sm:p-6">
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-700"
|
||||||
|
>Email address</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
id="email"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
autocomplete="email"
|
||||||
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||||
|
v-model="loginForm.email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label
|
||||||
|
for="password"
|
||||||
|
class="block text-sm font-medium text-gray-700"
|
||||||
|
>Password</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
id="password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||||
|
v-model="loginForm.password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-4 py-3 text-right sm:px-6">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex justify-center rounded-md border border-transparent bg-green-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useUserStore } from '../store/user.store';
|
||||||
|
import { JFetchError } from '../utils/jfetch';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const errorMessage = ref<string>('');
|
||||||
|
const loginForm = ref<{
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}>({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const doLogin = async () => {
|
||||||
|
errorMessage.value = '';
|
||||||
|
try {
|
||||||
|
const { email, password } = loginForm.value;
|
||||||
|
await userStore.login({ email, password });
|
||||||
|
router.replace({ name: 'dashboard' });
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as JFetchError;
|
||||||
|
errorMessage.value = error?.data?.message || (e as Error).message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
8
tailwind.config.cjs
Normal file
8
tailwind.config.cjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [require('@tailwindcss/forms'), require('@tailwindcss/line-clamp')],
|
||||||
|
};
|
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
9
tsconfig.node.json
Normal file
9
tsconfig.node.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user