editor.jsx 18 KB
Newer Older
1
2
import { h, Fragment } from 'preact';
import { route } from 'preact-router';
Max Reeves's avatar
Max Reeves committed
3
import { useEffect, useReducer, useState, useRef, useLayoutEffect } from 'preact/hooks';
Matthew Streatfield's avatar
Matthew Streatfield committed
4

5
import * as meerkat from './meerkat'
StreatCodes's avatar
StreatCodes committed
6
import { routeParam, removeParam, TagEditor } from './util';
7
import { CheckCard, CheckCardOptions } from './elements/card';
8
import { CheckSVG, CheckSVGOptions, CheckSVGDefaults } from './elements/svg';
9
import { CheckImage, CheckImageOptions } from './elements/image';
StreatCodes's avatar
StreatCodes committed
10
import { CheckLine, CheckLineOptions, CheckLineDefaults } from './elements/line';
11
12
import { StaticText, StaticTextOptions, StaticTextDefaults } from './statics/text';
import { StaticSVG, StaticSVGOptions, StaticSVGDefaults } from './statics/svg';
13
import { StaticImage, StaticImageOptions } from './statics/image';
Max Reeves's avatar
Max Reeves committed
14
15
import { IframeVideo, IframeVideoOptions } from './elements/video';
import { AudioStream, AudioOptions } from './elements/audio';
16

17
//Manage dashboard state
StreatCodes's avatar
StreatCodes committed
18
const dashboardReducer = (state, action) => {
19
	switch (action.type) {
StreatCodes's avatar
StreatCodes committed
20
21
		case 'setDashboard':
			return action.dashboard;
22
23
24
		case 'setTitle':
			console.log('Setting title to ' + action.title)
			return {...state, title: action.title};
Max Reeves's avatar
Max Reeves committed
25
		case 'setTags':
26
27
			console.log(`Setting tags to ${action.tags}`);
			return {...state, tags: action.tags};
StreatCodes's avatar
StreatCodes committed
28
29
30
		case 'setBackground':
			console.log('Setting background to ' + action.background)
			return {...state, background: action.background};
31
32
33
34
35
		case 'addElement':
			console.log('Adding new element')
			const newElement = {
				type: 'check-card',
				title: 'New Element',
36
37
				rect:{ x: 0, y: 0, w: 15, h: 15},
				options: {
38
					checkId: null,
39
40
41
					nameFontSize: 40,
					statusFontSize: 60
				}
42
			};
43
44
			return {
				...state,
45
				elements: state.elements.concat(newElement)
46
			};
Max Reeves's avatar
Max Reeves committed
47
48
		case 'deleteElement':
			console.log('Deleting element')
Max Reeves's avatar
Max Reeves committed
49
		case 'duplicateElement':
Max Reeves's avatar
Max Reeves committed
50
51
52
53
54
			console.log('Duplicating element')
		case 'getDimensions' :
			console.log('Getting Dimensions')
			console.log({height: action.height, width: action.width })
			return {...state, height: action.height, width: action.width};	
55
56
		case 'updateElement':
			console.log('Updating element')
57
			const newState = {...state};
58
			newState.elements[action.elementIndex] = action.element;
59
			return newState;
StreatCodes's avatar
StreatCodes committed
60
61
62
63
64
65
66
67
68
		case 'reorderElements':
			console.log('Reordering elements')
			const ns = {...state};

			const element = ns.elements[action.sourcePosition];
			ns.elements.splice(action.sourcePosition, 1);
			ns.elements.splice(action.destinationPosition, 0, element);

			return ns;
69
70
71
72
		default: throw new Error(`Unexpected action`);
	}
};

73
//Edit page
74
export function Editor({slug, selectedElementId}) {
StreatCodes's avatar
StreatCodes committed
75
	const [dashboard, dashboardDispatch] =  useReducer(dashboardReducer, null);
StreatCodes's avatar
StreatCodes committed
76
	const [savingDashboard, setSavingDashboard] = useState(false);
77
	const [highlightedElementId, setHighlightedElementId] = useState(null);
StreatCodes's avatar
StreatCodes committed
78
79

	useEffect(() => {
80
81
82
		meerkat.getDashboard(slug).then(async d => {
			dashboardDispatch({ type: 'setDashboard', dashboard: d });
		});
83
	}, [slug]);
StreatCodes's avatar
StreatCodes committed
84
85
86
87

	if(dashboard === null) {
		return <div class="loading center subtle">Loading dashboard</div>
	}
88

89
90
91
	const selectedElement = selectedElementId ? dashboard.elements[selectedElementId] : null;
	if(typeof selectedElement === 'undefined') {
		removeParam('selectedElementId');
StreatCodes's avatar
StreatCodes committed
92
93
		return
	}
94
	const updateElement = element => {
StreatCodes's avatar
StreatCodes committed
95
		dashboardDispatch({
96
97
98
			type: 'updateElement',
			elementIndex: selectedElementId,
			element: element
StreatCodes's avatar
StreatCodes committed
99
100
101
		});
	}

102
	const saveDashboard = async e => {
StreatCodes's avatar
StreatCodes committed
103
		setSavingDashboard(true);
104
105
106
107
108
109
110
111
112
113
		console.log(dashboard);
		try {
			const data = await meerkat.saveDashboard(slug, dashboard);
			route(`/edit/${data.slug}${window.location.search}`)
			//TODO show success
		} catch (e) {
			//TODO improve
			console.log('error saving dashboard:');
			console.log(e);
		}
StreatCodes's avatar
StreatCodes committed
114
		setSavingDashboard(false);
115
116
	}

117
	return <Fragment>
118
		<DashboardView dashboard={dashboard} dashboardDispatch={dashboardDispatch}
119
120
			selectedElementId={selectedElementId ? Number(selectedElementId) : null}
			highlightedElementId={highlightedElementId} />
121
122
123

		<div class="editor">
			<div class="options">
124
				<h3>{dashboard.title}</h3>
125
				<SidePanelSettings dashboard={dashboard} dashboardDispatch={dashboardDispatch} />
StreatCodes's avatar
StreatCodes committed
126
				<hr />
127
128
				<SidePanelElements dashboard={dashboard} dashboardDispatch={dashboardDispatch}
					setHighlightedElementId={setHighlightedElementId} />
129

130
				<ElementSettings selectedElement={selectedElement} updateElement={updateElement} />
131
			</div>
StreatCodes's avatar
StreatCodes committed
132
		</div>
133
		<div class="side-bar-footer lefty-righty">
Max Reeves's avatar
reskin    
Max Reeves committed
134
135
			<button class="btn btn-outline-primary " onClick={e => route('/')}>Home</button>
			<button onClick={saveDashboard} class={ savingDashboard ? 'loading' : ''} class="rounded btn-primary btn-large">Save Dashboard</button>
136
		</div>
137
138
139
	</Fragment>
}

StreatCodes's avatar
StreatCodes committed
140
function TransformableElement({rect, updateRect, rotation, updateRotation, children, glow, highlight}) {
StreatCodes's avatar
StreatCodes committed
141
	//Handle dragging elements
142
	const handleMove = downEvent => {
StreatCodes's avatar
StreatCodes committed
143
		const mousemove = moveEvent => {
144
145
			const elementNode = downEvent.target;
			const dashboardNode = elementNode.parentElement;
StreatCodes's avatar
StreatCodes committed
146
147
	
			//Get max dimensions
148
149
150
151
			let left = elementNode.offsetLeft + moveEvent.movementX;
			let top = elementNode.offsetTop + moveEvent.movementY;
			const maxLeft = dashboardNode.clientWidth - elementNode.clientWidth;
			const maxTop = dashboardNode.clientHeight - elementNode.clientHeight;
StreatCodes's avatar
StreatCodes committed
152
153
154
155
156
157
158

			//limit movement to max dimensions
			left = left < 0 ? 0 : left;
			left = left > maxLeft ? maxLeft : left;
			top = top < 0 ? 0 : top;
			top = top > maxTop ? maxTop : top;

159
160
161
162
			//convert dimensions to relative (px -> percentage based)
			const relativeLeft = left / dashboardNode.clientWidth * 100;
			const relativeTop = top / dashboardNode.clientHeight * 100;

StreatCodes's avatar
StreatCodes committed
163
			//set position
164
			updateRect({x: relativeLeft, y: relativeTop, w: rect.w, h: rect.h});
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
		}

		//Remove listeners on mouse button up
		const mouseup = () => {
			window.removeEventListener('mousemove', mousemove);
			window.removeEventListener('mouseup', mouseup);
		}

		//Add movement and mouseup events
		window.addEventListener('mouseup', mouseup);
		window.addEventListener('mousemove', mousemove);
	}

	const handleResize = downEvent => {
		downEvent.stopPropagation();

		const mousemove = moveEvent => {
182
			//Go up an element due to resize dot
183
184
			const elementNode = downEvent.target.parentElement;
			const dashboardNode = elementNode.parentElement;
185
186
	
			//Get max dimensions
187
188
189
190
			let width = elementNode.clientWidth + moveEvent.movementX;
			let height = elementNode.clientHeight + moveEvent.movementY;
			let maxWidth = dashboardNode.clientWidth - elementNode.offsetLeft;
			let maxHeight = dashboardNode.clientHeight - elementNode.offsetTop;
191
192

			//limit minimun resize
193
			width = width < 40 ? 40 : width;
194
			width = width < maxWidth ? width : maxWidth;
195
			height = height < 40 ? 40 : height;
196
			height = height < maxHeight ? height : maxHeight;
197
198
199
200
			
			//convert dimensions to relative (px -> percentage based)
			const relativeWidth = width / dashboardNode.clientWidth * 100;
			const relativeHeight = height / dashboardNode.clientHeight * 100;
201
202

			//set position
203
			updateRect({x: rect.x, y: rect.y, w: relativeWidth, h: relativeHeight});
StreatCodes's avatar
StreatCodes committed
204
205
206
207
208
209
210
211
212
213
214
		}

		//Remove listeners on mouse button up
		const mouseup = () => {
			window.removeEventListener('mousemove', mousemove);
			window.removeEventListener('mouseup', mouseup);
		}

		//Add movement and mouseup events
		window.addEventListener('mouseup', mouseup);
		window.addEventListener('mousemove', mousemove);
215
216
	}

StreatCodes's avatar
StreatCodes committed
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
	const handleRotate = downEvent => {
		downEvent.stopPropagation();

		const mousemove = moveEvent => {
			//Go up an element due to rotate dot
			const elementRect = downEvent.target.parentElement.getBoundingClientRect();

			let centerX = elementRect.left + ((elementRect.right - elementRect.left) / 2.0);
			let centerY = elementRect.top + ((elementRect.bottom - elementRect.top) / 2.0);

			const mouseX = moveEvent.clientX;
			const mouseY = moveEvent.clientY;

			const radians = Math.atan2(mouseY - centerY, mouseX - centerX);
			updateRotation(radians);
		}

		//Remove listeners on mouse button up
		const mouseup = () => {
			window.removeEventListener('mousemove', mousemove);
			window.removeEventListener('mouseup', mouseup);
		}

		//Add movement and mouseup events
		window.addEventListener('mouseup', mouseup);
		window.addEventListener('mousemove', mousemove);
	}

245
246
247
248
	const left = `${rect.x}%`;
	const top = `${rect.y}%`;
	const width = `${rect.w}%`;
	const height = `${rect.h}%`;
249

StreatCodes's avatar
StreatCodes committed
250
251
	const _rotation = rotation ? `rotate(${rotation}rad)` : `rotate(0rad)`;

252
	return <div class={`check ${glow || highlight ? 'glow' : ''}`}
StreatCodes's avatar
StreatCodes committed
253
		style={{left: left, top: top, width: width, height: height, transform: _rotation}}
254
		onMouseDown={handleMove}>
255
			{children}
256
			<div class="resize" onMouseDown={handleResize}></div>
StreatCodes's avatar
StreatCodes committed
257
			<div class="rotate" onMouseDown={handleRotate}></div>
258
259
260
	</div>
}

261
function DashboardElements({dashboardDispatch, selectedElementId, elements, highlightedElementId}) {
262
	return elements.map((element, index) => {
263
		const updateRect = rect => {
264
			dashboardDispatch({
265
266
267
268
				type: 'updateElement',
				elementIndex: index,
				element: {
					...element,
269
270
271
272
273
					rect: rect
				}
			});
		}

StreatCodes's avatar
StreatCodes committed
274
275
276
277
278
279
280
281
282
283
284
		const updateRotation = radian => {
			dashboardDispatch({
				type: 'updateElement',
				elementIndex: index,
				element: {
					...element,
					rotation: radian
				}
			});
		}

285
286
287
288
		let ele = null;
		if(element.type === 'check-card') { ele = <CheckCard options={element.options} /> }
		if(element.type === 'check-svg') { ele = <CheckSVG options={element.options}/> }
		if(element.type === 'check-image') { ele = <CheckImage options={element.options}/> }
289
		if(element.type === 'check-line') { ele = <CheckLine options={element.options} /> }
290
291
292
		if(element.type === 'static-text') { ele = <StaticText options={element.options}/> }
		if(element.type === 'static-svg') { ele = <StaticSVG options={element.options}/> }
		if(element.type === 'static-image') { ele = <StaticImage options={element.options}/> }
Max Reeves's avatar
Max Reeves committed
293
		if(element.type === 'iframe-video') { ele = <div><IframeVideo options={element.options}/><div class="move-button">Move</div></div> }
Max Reeves's avatar
Max Reeves committed
294
295
296
297
298
299
300
		if(element.type === 'audio-stream') { ele = <AudioStream options={element.options}/> }

		return  <TransformableElement rect={element.rect} updateRect={updateRect}
					glow={selectedElementId === index} highlight={highlightedElementId === index}
					updateRotation={updateRotation} rotation={element.rotation}>
					{ele}
			    </TransformableElement>
StreatCodes's avatar
StreatCodes committed
301
302
303
304
	});
}

//The actual dashboard being rendered
Max Reeves's avatar
Max Reeves committed
305
306
export function DashboardView({dashboard, dashboardDispatch, selectedElementId, highlightedElementId}) {
	const backgroundImage = dashboard.background ? dashboard.background : 'none';
StreatCodes's avatar
StreatCodes committed
307

Max Reeves's avatar
Max Reeves committed
308
309
310
	return <div class="dashboard-wrap" style={{Height: dashboard.height, Width: dashboard.width}}>
		<div class="dashboard" >
		<img src={backgroundImage} style="height: 100%; width: 100%" id="dashboard-dimensions"/>
311
			<DashboardElements elements={dashboard.elements} selectedElementId={selectedElementId}
312
				dashboardDispatch={dashboardDispatch} highlightedElementId={highlightedElementId}/>
Max Reeves's avatar
Max Reeves committed
313
314
			{console.log(dashboard.height)}
    	</div>
StreatCodes's avatar
StreatCodes committed
315
	</div>
316
317
318
}

//Settings view for the sidebar
319
function SidePanelSettings({dashboardDispatch, dashboard}) {
320
	const handleBackgroundImg = async e => {
StreatCodes's avatar
StreatCodes committed
321
		try {
322
			const res = await meerkat.uploadFile(e.target.files[0]);
StreatCodes's avatar
StreatCodes committed
323
			
324
			dashboardDispatch({
StreatCodes's avatar
StreatCodes committed
325
				type: 'setBackground',
326
				background: res.url
StreatCodes's avatar
StreatCodes committed
327
328
329
330
331
332
			});
		} catch (e) {
			//TODO improve
			console.log('failed to upload image and set background');
			console.log(e);
		}
333
334
	}

Max Reeves's avatar
Max Reeves committed
335
336
337
	const updateTags = tags => {
		dashboardDispatch({
			type: 'setTags',
StreatCodes's avatar
StreatCodes committed
338
			tags: tags.map(v => v.toLowerCase().trim())
Max Reeves's avatar
Max Reeves committed
339
340
341
		});
	}

342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
	const clearBackground = e => {
		e.preventDefault();
		dashboardDispatch({
			type: 'setBackground',
			background: null
		});
	}

	const imgControls = src => {
		if(src) {
			return <Fragment>
				<a onClick={clearBackground}>clear</a>&nbsp;
				<a target="_blank" href={src}>view</a>
			</Fragment>
		}
		return null;
	}

360
361
	return <Fragment>
		<label for="title">Title</label>
Max Reeves's avatar
reskin    
Max Reeves committed
362
		<input class="form-control" type="text" id="title" placeholder="Network Overview" value={dashboard.title}
363
			onInput={e => dashboardDispatch({type: 'setTitle', title: e.currentTarget.value})} />
364

StreatCodes's avatar
StreatCodes committed
365
		<TagEditor tags={dashboard.tags} updateTags={tags => updateTags(tags)} />
Max Reeves's avatar
reskin    
Max Reeves committed
366
		
367
		<label for="background-image">Background Image {imgControls(dashboard.background)}</label>
Max Reeves's avatar
reskin    
Max Reeves committed
368
		<input class="form-control" id="background-image" type="file" placeholder="Upload a background image"
369
			accept="image/*" onChange={handleBackgroundImg}/>
StreatCodes's avatar
StreatCodes committed
370
	</Fragment>
371
372
}

373
function SidePanelElements({dashboard, dashboardDispatch, setHighlightedElementId}) {
374
375
376
377
378
379
	const addElement = e => {
		const newId = dashboard.elements.length;
		dashboardDispatch({type: 'addElement'});
		routeParam('selectedElementId', newId);
	}

Max Reeves's avatar
Max Reeves committed
380
381
382
383
384
385
386
387
388
	const deleteElement = (e, index) => {
		e.preventDefault();
		let elements = dashboard.elements;
		elements.splice(index, 1)
		dashboardDispatch({
			type: 'deleteElement',
		});
	};

Max Reeves's avatar
Max Reeves committed
389
390
391
392
393
394
395
396
397
	const duplicateElement = (e, index) => {
		e.preventDefault();
		let elements = dashboard.elements;
		elements.splice(index, 0, elements[index])
		dashboardDispatch({
			type: 'duplicateElement',
		});
	};
	
StreatCodes's avatar
StreatCodes committed
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
	const handleDragStart = e => {
		e.dataTransfer.setData("source-id", e.target.id);
	}

	const handleDrop = e => {
		e.preventDefault();
		e.currentTarget.classList.remove('active');
		const sourceId = e.dataTransfer.getData("source-id");
		const destId = e.target.id;
		dashboardDispatch({
			type: 'reorderElements',
			sourcePosition: sourceId,
			destinationPosition: destId
		});
	}

414
	let elementList = dashboard.elements.map((element, index) => (
Max Reeves's avatar
Max Reeves committed
415
416
417
418
		<div class="element-item" draggable={true} id={index} onDragStart={handleDragStart}>
			<div onClick={ e => routeParam('selectedElementId', index.toString()) }>
				<div class="element-title">{element.title}</div>
			</div>
Max Reeves's avatar
Max Reeves committed
419
420
				<button class="rounded btn-dark btn-sml m-0 mr-1 mt-1 medium" onClick={e => duplicateElement(e,index)}>Duplicate</button>
				<button class="rounded btn-danger btn-sml m-0 mr-1 mt-1 medium" onClick={e => deleteElement(e,index)}>Delete</button>
StreatCodes's avatar
StreatCodes committed
421
			<div class="drop-zone" onDrop={handleDrop} id={index}
Max Reeves's avatar
Max Reeves committed
422
423
424
425
				 				   onDragEnter={e => {e.preventDefault(); e.currentTarget.classList.add('active')}}
				 				   onDragOver={e => e.preventDefault()}
				 				   onDragExit={e => e.currentTarget.classList.remove('active')}>
			</div>
426
427
428
429
430
431
		</div>
	));

	if(elementList.length < 1) { elementList = <div class="subtle">No elements added.</div>}

	return <Fragment>
StreatCodes's avatar
StreatCodes committed
432
		<div class="lefty-righty spacer">
433
			<h3>Elements</h3>
Max Reeves's avatar
reskin    
Max Reeves committed
434
			<button class="small btn btn-outline-primary" onClick={addElement}>New</button>
435
436
437
438
439
440
441
		</div>
		<div class="element-list">
			{elementList}
		</div>
	</Fragment>
}

442
export function ElementSettings({selectedElement, updateElement}) {
443
444
445
446
447
448
449
450
451
	if(selectedElement === null) {
		return null;
	}

	const updateElementOptions = (options) => {
		const newOptions = Object.assign(selectedElement.options, options)
		updateElement({...selectedElement, options: newOptions})
	}

Max Reeves's avatar
Max Reeves committed
452
	//sets good default values for each visual type when they're selected
453
454
455
456
457
	const updateType = e => {
		const newType = e.currentTarget.value
		let defaults = {};
		switch(newType) {
			case 'check-svg': defaults = CheckSVGDefaults; break;
StreatCodes's avatar
StreatCodes committed
458
			case 'check-line': defaults = CheckLineDefaults; break;
459
460
461
462
463
464
465
466
467
468
469
			case 'static-text': defaults = StaticTextDefaults; break;
			case 'static-svg': defaults = StaticSVGDefaults; break;
		}

		updateElement({
			...selectedElement,
			type: newType,
			options: Object.assign(selectedElement.options, defaults)
		});
	}

470
471
472
473
	let ElementOptions = null;
	if(selectedElement.type === 'check-card') { ElementOptions = <CheckCardOptions updateOptions={updateElementOptions} options={selectedElement.options} /> }
	if(selectedElement.type === 'check-svg') { ElementOptions = <CheckSVGOptions updateOptions={updateElementOptions} options={selectedElement.options}/> }
	if(selectedElement.type === 'check-image') { ElementOptions = <CheckImageOptions updateOptions={updateElementOptions} options={selectedElement.options}/> }
474
	if(selectedElement.type === 'check-line') { ElementOptions = <CheckLineOptions updateOptions={updateElementOptions} options={selectedElement.options}/> }
475
476
477
	if(selectedElement.type === 'static-text') { ElementOptions = <StaticTextOptions updateOptions={updateElementOptions} options={selectedElement.options} /> }
	if(selectedElement.type === 'static-svg') { ElementOptions = <StaticSVGOptions updateOptions={updateElementOptions} options={selectedElement.options}/> }
	if(selectedElement.type === 'static-image') { ElementOptions = <StaticImageOptions updateOptions={updateElementOptions} options={selectedElement.options}/> }
Max Reeves's avatar
Max Reeves committed
478
479
	if(selectedElement.type === 'iframe-video') { ElementOptions = <IframeVideoOptions updateOptions={updateElementOptions} options={selectedElement.options}/> }
	if(selectedElement.type === 'audio-stream') { ElementOptions = <AudioOptions updateOptions={updateElementOptions} options={selectedElement.options}/>}
480

Max Reeves's avatar
reskin    
Max Reeves committed
481
482
	return <div class="form-group">
		<div class="editor settings-overlay">
483
484
485
486
487
488
489
490
491
		<div class="options">
			<div class="lefty-righty spacer">
				<h3 class="no-margin">{selectedElement.title}</h3>
				<svg class="feather" onClick={e => removeParam('selectedElementId')}>
					<use xlinkHref={`/res/svgs/feather-sprite.svg#x`}/>
				</svg>
			</div>
			<div class="settings">
				<label for="name">Name</label>
Max Reeves's avatar
reskin    
Max Reeves committed
492
				<input class="form-control" id="name" type="text" placeholder="Cool Element" value={selectedElement.title}
493
494
495
					onInput={e => updateElement({...selectedElement, title: e.currentTarget.value})} />

				<label>Visual Type</label>
Max Reeves's avatar
reskin    
Max Reeves committed
496
				<select class="form-control" name="item-type" value={selectedElement.type} onInput={updateType}>
497
498
499
					<option value="check-card">Icinga Card</option>
					<option value="check-svg">Icinga SVG</option>
					<option value="check-image">Icinga Image</option>
500
					<option value="check-line">Icinga Line</option>
501
502
503
					<option value="static-text">Static Text</option>
					<option value="static-svg">Static SVG</option>
					<option value="static-image">Static Image</option>
Max Reeves's avatar
Max Reeves committed
504
505
					<option value="iframe-video">HLS Stream</option>
					<option value="audio-stream">Audio Stream</option>
506
507
508
509
510
511
512
				</select>
				<hr />

				{ElementOptions}
			</div>
		</div>
	</div>
Max Reeves's avatar
reskin    
Max Reeves committed
513
</div>
Matthew Streatfield's avatar
Matthew Streatfield committed
514
}