Started working on editable dashboards
This commit is contained in:
parent
6f85f4a45b
commit
1d46147979
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { DashboardContent } from '@/utils/parseDashboard'
|
||||||
|
import { request } from './request'
|
||||||
|
|
||||||
|
export type DashboardInfo = {
|
||||||
|
id: string
|
||||||
|
contents: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDashboards = () => request<DashboardInfo[]>('/api/dashboards')
|
||||||
|
|
||||||
|
export const getDashboard = (id: string) =>
|
||||||
|
request<DashboardInfo>(`/api/dashboards/${encodeURI(id)}`)
|
||||||
|
|
||||||
|
export const createDashboard = (body: {
|
||||||
|
id: string
|
||||||
|
contents: DashboardContent
|
||||||
|
}) =>
|
||||||
|
request<DashboardInfo>(`/api/dashboards`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...body,
|
||||||
|
contents: JSON.stringify(body.contents),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateDashboard = ({
|
||||||
|
id,
|
||||||
|
...body
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
contents: DashboardContent
|
||||||
|
}) =>
|
||||||
|
request<DashboardInfo>(`/api/dashboards/${encodeURI(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...body,
|
||||||
|
contents: JSON.stringify(body.contents),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
@ -194,3 +194,59 @@ form.horizontal .input label {
|
||||||
.checkbox-label input[type=checkbox] {
|
.checkbox-label input[type=checkbox] {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid-sensors {
|
||||||
|
position: relative;
|
||||||
|
margin: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-sensors .grid-box {
|
||||||
|
position: absolute;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 0.25rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-sensors .grid-box.dragging {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-sensors .grid-box .box {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-sensors .grid-box .box .resize-h {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 10px;
|
||||||
|
cursor: e-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-sensors .grid-box .box .resize-v {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 10px;
|
||||||
|
cursor: s-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-sensors .grid-box .box .resize {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;;
|
||||||
|
bottom: 0;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
cursor: se-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-sensors .box-preview {
|
||||||
|
position: absolute;
|
||||||
|
background-color: #3988FF;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: all 0.2s;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useAppContext } from '@/contexts/AppContext'
|
import { useAppContext } from '@/contexts/AppContext'
|
||||||
import { DashboardPage } from './dashboard/DashboardPage'
|
import { NewDashboardPage } from './dashboard/NewDashboardPage'
|
||||||
import { LoginPage } from './login/LoginPage'
|
import { LoginPage } from './login/LoginPage'
|
||||||
|
|
||||||
export const Router = () => {
|
export const Router = () => {
|
||||||
|
|
@ -7,7 +7,7 @@ export const Router = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{loggedIn && <DashboardPage />}
|
{loggedIn && <NewDashboardPage />}
|
||||||
{!loggedIn && <LoginPage />}
|
{!loggedIn && <LoginPage />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { createDashboard, getDashboards } from '@/api/dashboards'
|
||||||
|
import { createDashboardContent } from '@/utils/createDashboardContent'
|
||||||
|
import { parseDashboard } from '@/utils/parseDashboard'
|
||||||
|
import { useEffect, useMemo, useState } from 'preact/hooks'
|
||||||
|
import { useQuery } from 'react-query'
|
||||||
|
import { EditableBox } from './components/EditableBox'
|
||||||
|
import { normalizeBoxes } from './utils/normalizeBoxes'
|
||||||
|
|
||||||
|
export const NewDashboardPage = () => {
|
||||||
|
const dashboards = useQuery(['/dashboards'], getDashboards)
|
||||||
|
const dashboard = dashboards.data?.find((i) => i.id === 'default')
|
||||||
|
|
||||||
|
const dashboardContent = useMemo(
|
||||||
|
() => (dashboard ? parseDashboard(dashboard.contents) : undefined),
|
||||||
|
[dashboard]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Terrible code - ensure there's default dashboard
|
||||||
|
useEffect(() => {
|
||||||
|
if (dashboards.data && !dashboard) {
|
||||||
|
createDashboard({
|
||||||
|
id: 'default',
|
||||||
|
contents: createDashboardContent(),
|
||||||
|
}).then(() => {
|
||||||
|
dashboards.refetch()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [dashboards.data, dashboard])
|
||||||
|
|
||||||
|
const [boxes, setBoxes] = useState(dashboardContent?.boxes ?? [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid-sensors">
|
||||||
|
{boxes.map((b) => (
|
||||||
|
<EditableBox
|
||||||
|
box={b}
|
||||||
|
key={b.id}
|
||||||
|
boxes={boxes}
|
||||||
|
onPosition={(p) => {
|
||||||
|
setBoxes((previous) => {
|
||||||
|
b.x = p.x
|
||||||
|
b.y = p.y
|
||||||
|
|
||||||
|
return normalizeBoxes([...previous])
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onResize={(s) => {
|
||||||
|
setBoxes((previous) => {
|
||||||
|
b.w = s.w
|
||||||
|
b.h = s.h
|
||||||
|
|
||||||
|
return normalizeBoxes([...previous])
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { getElementPosition } from '@/utils/getElementPosition'
|
||||||
|
import { useWindowEvent } from '@/utils/hooks/useWindowEvent'
|
||||||
|
import { useRef } from 'preact/hooks'
|
||||||
|
import { GRID_WIDTH } from '../constants'
|
||||||
|
import { ResizingMode, useResize } from '../hooks/useResize'
|
||||||
|
import { useDragging } from '../hooks/useDragging'
|
||||||
|
import { BoxDefinition } from '../types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
box: BoxDefinition
|
||||||
|
boxes: BoxDefinition[]
|
||||||
|
onPosition: (p: { x: number; y: number }) => void
|
||||||
|
onResize: (p: { w: number; h: number }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => {
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const outerWidth = boxRef.current?.parentElement?.offsetWidth ?? 100
|
||||||
|
const cellWidth = outerWidth / GRID_WIDTH
|
||||||
|
|
||||||
|
const { dragging, setDragging, draggingPosition } = useDragging({
|
||||||
|
cellWidth,
|
||||||
|
box,
|
||||||
|
boxes,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { resizing, setResizing, resizingSize } = useResize({
|
||||||
|
box,
|
||||||
|
boxes,
|
||||||
|
cellWidth,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!dragging) {
|
||||||
|
const pos = getElementPosition(e.target as HTMLDivElement)
|
||||||
|
|
||||||
|
setDragging({
|
||||||
|
active: true,
|
||||||
|
x: pos.left,
|
||||||
|
y: pos.top,
|
||||||
|
offsetX: e.clientX - pos.left,
|
||||||
|
offsetY: e.clientY - pos.top,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResizeDown = (target: ResizingMode) => (e: MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (!resizing) {
|
||||||
|
setResizing({
|
||||||
|
mode: target,
|
||||||
|
w: (box.w / GRID_WIDTH) * outerWidth,
|
||||||
|
h: box.h,
|
||||||
|
offsetX: e.clientX,
|
||||||
|
offsetY: e.clientY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useWindowEvent('mouseup', () => {
|
||||||
|
if (dragging) {
|
||||||
|
onPosition(draggingPosition)
|
||||||
|
setDragging({ ...dragging, active: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resizing.mode !== ResizingMode.NONE) {
|
||||||
|
onResize({ w: resizingSize.w, h: resizingSize.h })
|
||||||
|
setResizing({ ...resizing, mode: ResizingMode.NONE })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={boxRef}
|
||||||
|
className={`grid-box${dragging ? ' dragging' : ''}`}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
style={{
|
||||||
|
left: (box.x / GRID_WIDTH) * 100 + '%',
|
||||||
|
top: box.y + 'px',
|
||||||
|
width: (box.w / GRID_WIDTH) * 100 + '%',
|
||||||
|
height: box.h + 'px',
|
||||||
|
...(dragging.active && {
|
||||||
|
left: dragging.x,
|
||||||
|
top: dragging.y,
|
||||||
|
zIndex: 2,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="box" style={{ height: '100%' }}>
|
||||||
|
<div
|
||||||
|
className="resize-h"
|
||||||
|
onMouseDown={handleResizeDown(ResizingMode.WIDTH)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="resize-v"
|
||||||
|
onMouseDown={handleResizeDown(ResizingMode.HEIGHT)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="resize"
|
||||||
|
onMouseDown={handleResizeDown(ResizingMode.ALL)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{resizing.mode !== ResizingMode.NONE && (
|
||||||
|
<div
|
||||||
|
className={'box-preview'}
|
||||||
|
style={{
|
||||||
|
left: (box.x / GRID_WIDTH) * 100 + '%',
|
||||||
|
top: box.y + 'px',
|
||||||
|
width: (resizingSize.w / GRID_WIDTH) * 100 + '%',
|
||||||
|
height: resizingSize.h + 'px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dragging.active && (
|
||||||
|
<div
|
||||||
|
className={'box-preview'}
|
||||||
|
style={{
|
||||||
|
left: (draggingPosition.x / GRID_WIDTH) * 100 + '%',
|
||||||
|
top: draggingPosition.y,
|
||||||
|
width: (box.w / GRID_WIDTH) * 100 + '%',
|
||||||
|
height: box.h + 'px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export const GRID_WIDTH = 20
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { useWindowEvent } from '@/utils/hooks/useWindowEvent'
|
||||||
|
import { useState } from 'preact/hooks'
|
||||||
|
import { BoxDefinition } from '../types'
|
||||||
|
import { GRID_WIDTH } from '../constants'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cellWidth: number
|
||||||
|
boxes: BoxDefinition[]
|
||||||
|
box: BoxDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: This is not optimized at all
|
||||||
|
export const useDragging = ({ cellWidth, boxes, box }: Props) => {
|
||||||
|
const [state, setState] = useState({
|
||||||
|
active: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const actualX = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(GRID_WIDTH - box.w, Math.round(state.x / cellWidth))
|
||||||
|
)
|
||||||
|
|
||||||
|
const dragY = Math.round(state.y / 16) * 16
|
||||||
|
|
||||||
|
const gridHeights = Array(GRID_WIDTH)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, index) => {
|
||||||
|
return boxes
|
||||||
|
.filter(
|
||||||
|
(b) =>
|
||||||
|
b.id !== box.id &&
|
||||||
|
b.x <= index &&
|
||||||
|
b.x + b.w > index &&
|
||||||
|
((b.y < dragY + box.h && dragY < b.y + b.h) || b.y < dragY)
|
||||||
|
)
|
||||||
|
.reduce(
|
||||||
|
(acc, item) => (item.y + item.h > acc ? item.y + item.h : acc),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
useWindowEvent('mousemove', (e) => {
|
||||||
|
if (state.active) {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
x: e.clientX - state.offsetX,
|
||||||
|
y: e.clientY - state.offsetY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
dragging: state,
|
||||||
|
setDragging: setState,
|
||||||
|
draggingPosition: {
|
||||||
|
x: actualX,
|
||||||
|
y: Math.max(
|
||||||
|
...Array(box.w)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, x) => gridHeights[actualX + x])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { useWindowEvent } from '@/utils/hooks/useWindowEvent'
|
||||||
|
import { useState } from 'preact/hooks'
|
||||||
|
import { BoxDefinition } from '../types'
|
||||||
|
import { GRID_WIDTH } from '../constants'
|
||||||
|
|
||||||
|
export enum ResizingMode {
|
||||||
|
NONE,
|
||||||
|
WIDTH,
|
||||||
|
HEIGHT,
|
||||||
|
ALL,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cellWidth: number
|
||||||
|
boxes: BoxDefinition[]
|
||||||
|
box: BoxDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: This is not optimized at all
|
||||||
|
export const useResize = ({ cellWidth, box, boxes }: Props) => {
|
||||||
|
const [state, setState] = useState({
|
||||||
|
mode: ResizingMode.NONE,
|
||||||
|
w: 0,
|
||||||
|
h: 0,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isResizing = state.mode !== ResizingMode.NONE
|
||||||
|
|
||||||
|
const maxHeights = isResizing
|
||||||
|
? Array(GRID_WIDTH)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, index) => {
|
||||||
|
return boxes
|
||||||
|
.filter(
|
||||||
|
(b) =>
|
||||||
|
b.id !== box.id &&
|
||||||
|
b.x <= index &&
|
||||||
|
b.x + b.w > index &&
|
||||||
|
b.y > box.y
|
||||||
|
)
|
||||||
|
.reduce(
|
||||||
|
(acc, item) => (item.y - box.y > acc ? item.y - box.y : acc),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
const actualHeight = isResizing
|
||||||
|
? Math.min(
|
||||||
|
...Array(box.w)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, x) => maxHeights[box.x + x])
|
||||||
|
.filter((x) => x > 0),
|
||||||
|
Math.round(state.h / 16) * 16
|
||||||
|
)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const maxWidth = isResizing
|
||||||
|
? boxes
|
||||||
|
.filter(
|
||||||
|
(b) =>
|
||||||
|
b.id !== box.id &&
|
||||||
|
b.x > box.x &&
|
||||||
|
b.y < box.y + box.h &&
|
||||||
|
box.y < b.y + b.h
|
||||||
|
)
|
||||||
|
.map((b) => b.x - box.x)
|
||||||
|
.reduce((acc, item) => (item < acc ? item : acc), GRID_WIDTH)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const actualWidth = Math.min(maxWidth, Math.round(state.w / cellWidth))
|
||||||
|
|
||||||
|
useWindowEvent('mousemove', (e) => {
|
||||||
|
if (isResizing) {
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
state.mode === ResizingMode.ALL ||
|
||||||
|
state.mode === ResizingMode.HEIGHT
|
||||||
|
) {
|
||||||
|
newState.h = box.h + e.clientY - state.offsetY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
state.mode === ResizingMode.ALL ||
|
||||||
|
state.mode === ResizingMode.WIDTH
|
||||||
|
) {
|
||||||
|
newState.w =
|
||||||
|
(box.w / GRID_WIDTH) * outerWidth + e.clientX - state.offsetX
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(newState)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
setResizing: setState,
|
||||||
|
resizingSize: { w: actualWidth, h: actualHeight },
|
||||||
|
resizing: state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { DashboardContentBox } from '@/utils/parseDashboard'
|
||||||
|
|
||||||
|
export type BoxDefinition = DashboardContentBox
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { BoxDefinition } from '../types'
|
||||||
|
|
||||||
|
export function normalizeBoxes(boxes: BoxDefinition[]) {
|
||||||
|
// TODO: This is not optimized at all
|
||||||
|
for (let i = 0; i < boxes.length; i++) {
|
||||||
|
const box = boxes[i]
|
||||||
|
|
||||||
|
if (box.y > 0) {
|
||||||
|
const above = boxes
|
||||||
|
.filter(
|
||||||
|
(b) =>
|
||||||
|
b.id !== box.id &&
|
||||||
|
b.x < box.x + box.w &&
|
||||||
|
box.x < b.x + b.w &&
|
||||||
|
b.y < box.y
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.y - a.y)
|
||||||
|
|
||||||
|
if (above.length === 0) {
|
||||||
|
box.y = 0
|
||||||
|
i = -1
|
||||||
|
} else {
|
||||||
|
const newY = above[0].h + above[0].y
|
||||||
|
|
||||||
|
if (box.y !== newY) {
|
||||||
|
box.y = newY
|
||||||
|
i = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return boxes
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { DashboardContent } from './parseDashboard'
|
||||||
|
|
||||||
|
export const createDashboardContent = (): DashboardContent => ({
|
||||||
|
version: '1.0',
|
||||||
|
boxes: [],
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
export interface ElementPositionResult {
|
||||||
|
left: number
|
||||||
|
top: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElementPosition(
|
||||||
|
el: HTMLElement,
|
||||||
|
global = true
|
||||||
|
): ElementPositionResult {
|
||||||
|
if (!el) {
|
||||||
|
return {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bb = el.getBoundingClientRect()
|
||||||
|
|
||||||
|
if (global) {
|
||||||
|
return {
|
||||||
|
left: bb.left,
|
||||||
|
top: bb.top,
|
||||||
|
width: bb.width,
|
||||||
|
height: bb.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let offsetLeft = el.offsetLeft
|
||||||
|
let offsetTop = el.offsetTop
|
||||||
|
let current: HTMLElement | null = el.offsetParent as HTMLElement
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
offsetLeft += current.offsetLeft
|
||||||
|
offsetTop += current.offsetTop
|
||||||
|
current = current.offsetParent as HTMLElement
|
||||||
|
|
||||||
|
if (!global && current) {
|
||||||
|
const position = getComputedStyle(current).getPropertyValue('position')
|
||||||
|
|
||||||
|
if (position === 'relative' || position === 'absolute') {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: offsetLeft,
|
||||||
|
top: offsetTop,
|
||||||
|
width: bb.width,
|
||||||
|
height: bb.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { useCallback, useEffect, useRef } from 'preact/hooks'
|
||||||
|
|
||||||
|
export const useEvent = <E extends Event>(
|
||||||
|
target: EventTarget,
|
||||||
|
event: string,
|
||||||
|
callback: (e: E) => void,
|
||||||
|
cancelBubble = false
|
||||||
|
) => {
|
||||||
|
// Hold reference to callback
|
||||||
|
const callbackRef = useRef(callback)
|
||||||
|
callbackRef.current = callback
|
||||||
|
|
||||||
|
// Since we use ref, .current will always be correct callback
|
||||||
|
const listener = useCallback((e: Event) => {
|
||||||
|
if (callbackRef.current) {
|
||||||
|
callbackRef.current(e as E)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Add our listener on mount
|
||||||
|
target.addEventListener(event, listener, cancelBubble)
|
||||||
|
|
||||||
|
// Remove it on dismount
|
||||||
|
return () => target.removeEventListener(event, listener)
|
||||||
|
}, [target, event, cancelBubble, listener])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { useEvent } from './useEvent'
|
||||||
|
|
||||||
|
export const useWindowEvent = <K extends keyof WindowEventMap>(
|
||||||
|
event: K,
|
||||||
|
callback: (e: WindowEventMap[K]) => void,
|
||||||
|
cancelBubble = false
|
||||||
|
) => useEvent(window, event, callback, cancelBubble)
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
export type DashboardContent = {
|
||||||
|
version: string
|
||||||
|
boxes: DashboardContentBox[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardContentBox = {
|
||||||
|
id: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
sensor: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseDashboard = (input: string) => {
|
||||||
|
return JSON.parse(input) as DashboardContent
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS dashboards (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
contents TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
@ -39,14 +39,18 @@ func main() {
|
||||||
// Routes that are only accessible after logging in
|
// Routes that are only accessible after logging in
|
||||||
loginProtected := router.Group("/", middleware.LoginAuthMiddleware(server))
|
loginProtected := router.Group("/", middleware.LoginAuthMiddleware(server))
|
||||||
loginProtected.GET("/api/sensors", routes.GetSensors(server))
|
loginProtected.GET("/api/sensors", routes.GetSensors(server))
|
||||||
loginProtected.GET("/api/sensors/:sensor/values", routes.HandleGetSensorValues(server))
|
loginProtected.GET("/api/sensors/:sensor/values", routes.GetSensorValues(server))
|
||||||
loginProtected.GET("/api/sensors/:sensor/config", routes.GetSensorConfig(server))
|
loginProtected.GET("/api/sensors/:sensor/config", routes.GetSensorConfig(server))
|
||||||
loginProtected.PUT("/api/sensors/:sensor/config/:key", routes.HandlePutSensorConfig(server))
|
loginProtected.PUT("/api/sensors/:sensor/config/:key", routes.PutSensorConfig(server))
|
||||||
|
loginProtected.GET("/api/dashboards", routes.GetDashboards(server))
|
||||||
|
loginProtected.POST("/api/dashboards", routes.PostDashboard(server))
|
||||||
|
loginProtected.GET("/api/dashboards/:id", routes.GetDashboardById(server))
|
||||||
|
loginProtected.PUT("/api/dashboards/:id", routes.PutDashboard(server))
|
||||||
loginProtected.POST("/api/logout", routes.Logout(server))
|
loginProtected.POST("/api/logout", routes.Logout(server))
|
||||||
|
|
||||||
// Routes accessible using auth key
|
// Routes accessible using auth key
|
||||||
keyProtected := router.Group("/", middleware.KeyAuthMiddleware(server))
|
keyProtected := router.Group("/", middleware.KeyAuthMiddleware(server))
|
||||||
keyProtected.POST("/api/sensors/:sensor/values", routes.HandlePostSensorValues(server))
|
keyProtected.POST("/api/sensors/:sensor/values", routes.PostSensorValues(server))
|
||||||
|
|
||||||
// Starts session cleanup goroutine
|
// Starts session cleanup goroutine
|
||||||
server.StartCleaner()
|
server.StartCleaner()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"basic-sensor-receiver/app"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type postDashboardBody struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Contents string `json:"contents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type putDashboardBody struct {
|
||||||
|
Contents string `json:"contents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDashboards(s *app.Server) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
items, err := s.Services.Dashboards.GetList()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDashboardById(s *app.Server) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
item, err := s.Services.Dashboards.GetById(id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PostDashboard(s *app.Server) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
body := postDashboardBody{}
|
||||||
|
|
||||||
|
if err := c.BindJSON(&body); err != nil {
|
||||||
|
c.AbortWithError(400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := s.Services.Dashboards.Create(body.Id, body.Contents)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PutDashboard(s *app.Server) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
body := putDashboardBody{}
|
||||||
|
|
||||||
|
if err := c.BindJSON(&body); err != nil {
|
||||||
|
c.AbortWithError(400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := s.Services.Dashboards.Update(id, body.Contents)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ type sensorConfigValue struct {
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandlePutSensorConfig(s *app.Server) gin.HandlerFunc {
|
func PutSensorConfig(s *app.Server) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var configValue sensorConfigValue
|
var configValue sensorConfigValue
|
||||||
sensor := c.Param("sensor")
|
sensor := c.Param("sensor")
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ type getSensorQuery struct {
|
||||||
To int64 `form:"to"`
|
To int64 `form:"to"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandlePostSensorValues(s *app.Server) gin.HandlerFunc {
|
func PostSensorValues(s *app.Server) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var newValue postSensorValueBody
|
var newValue postSensorValueBody
|
||||||
sensor := c.Param("sensor")
|
sensor := c.Param("sensor")
|
||||||
|
|
@ -35,7 +35,7 @@ func HandlePostSensorValues(s *app.Server) gin.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleGetSensorValues(s *app.Server) gin.HandlerFunc {
|
func GetSensorValues(s *app.Server) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var query getSensorQuery
|
var query getSensorQuery
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
type DashboardsService struct {
|
||||||
|
ctx *Context
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardItem struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Contents string `json:"contents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardsService) GetList() ([]DashboardItem, error) {
|
||||||
|
items := make([]DashboardItem, 0)
|
||||||
|
|
||||||
|
rows, err := s.ctx.DB.Query("SELECT id, contents FROM dashboards")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
item := DashboardItem{}
|
||||||
|
|
||||||
|
err := rows.Scan(&item.Id, &item.Contents)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardsService) Create(id string, contents string) (*DashboardItem, error) {
|
||||||
|
item := DashboardItem{
|
||||||
|
Id: id,
|
||||||
|
Contents: contents,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.ctx.DB.Exec("INSERT INTO dashboards (id, contents) VALUES (?, ?)", item.Id, item.Contents)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardsService) Update(id string, contents string) (*DashboardItem, error) {
|
||||||
|
item := DashboardItem{
|
||||||
|
Id: id,
|
||||||
|
Contents: contents,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.ctx.DB.Exec("UPDATE dashboards SET contents = ? WHERE id = ?", contents, id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardsService) GetById(id string) (*DashboardItem, error) {
|
||||||
|
item := DashboardItem{}
|
||||||
|
|
||||||
|
row := s.ctx.DB.QueryRow("SELECT id, contents FROM dashboards WHERE id = ?", id)
|
||||||
|
|
||||||
|
err := row.Scan(&item.Id, &item.Contents)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,7 @@ func (s *SensorValuesService) Push(sensor string, value float64) (int64, error)
|
||||||
func (s *SensorValuesService) GetList(sensor string, from int64, to int64) ([]sensorValue, error) {
|
func (s *SensorValuesService) GetList(sensor string, from int64, to int64) ([]sensorValue, error) {
|
||||||
var value float64
|
var value float64
|
||||||
var timestamp int64
|
var timestamp int64
|
||||||
var values []sensorValue
|
values := make([]sensorValue, 0)
|
||||||
|
|
||||||
rows, err := s.ctx.DB.Query("SELECT timestamp, value FROM sensor_values WHERE sensor = ? AND timestamp > ? AND timestamp < ? ORDER BY timestamp ASC", sensor, from, to)
|
rows, err := s.ctx.DB.Query("SELECT timestamp, value FROM sensor_values WHERE sensor = ? AND timestamp > ? AND timestamp < ? ORDER BY timestamp ASC", sensor, from, to)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ type sensorItem struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SensorsService) GetList() ([]sensorItem, error) {
|
func (s *SensorsService) GetList() ([]sensorItem, error) {
|
||||||
var sensors []sensorItem
|
sensors := make([]sensorItem, 0)
|
||||||
var sensor string
|
var sensor string
|
||||||
var lastUpdate string
|
var lastUpdate string
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ type Services struct {
|
||||||
Sensors *SensorsService
|
Sensors *SensorsService
|
||||||
Sessions *SessionsService
|
Sessions *SessionsService
|
||||||
Auth *AuthService
|
Auth *AuthService
|
||||||
|
Dashboards *DashboardsService
|
||||||
}
|
}
|
||||||
|
|
||||||
type Context struct {
|
type Context struct {
|
||||||
|
|
@ -29,6 +30,7 @@ func InitializeServices(ctx *Context) *Services {
|
||||||
services.Sensors = &SensorsService{ctx: ctx}
|
services.Sensors = &SensorsService{ctx: ctx}
|
||||||
services.Sessions = &SessionsService{ctx: ctx}
|
services.Sessions = &SessionsService{ctx: ctx}
|
||||||
services.Auth = &AuthService{ctx: ctx}
|
services.Auth = &AuthService{ctx: ctx}
|
||||||
|
services.Dashboards = &DashboardsService{ctx: ctx}
|
||||||
|
|
||||||
return &services
|
return &services
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue