mirror of
https://github.com/m1k1o/neko.git
synced 2024-07-24 14:40:50 +12:00
add test page.
This commit is contained in:
266
src/page/components/chat.vue
Normal file
266
src/page/components/chat.vue
Normal file
@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="chat">
|
||||
<ul class="chat-history" ref="history">
|
||||
<template v-for="(message, index) in history">
|
||||
<li :key="index" class="message" v-show="neko && neko.connected">
|
||||
<div class="content">
|
||||
<div class="content-head">
|
||||
<span class="session">{{ session(message.id) }}</span>
|
||||
<span class="timestamp">{{ timestamp(message.created) }}</span>
|
||||
</div>
|
||||
<p>{{ message.content }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="chat-send">
|
||||
<div class="text-container">
|
||||
<textarea ref="input" placeholder="Send a message" @keydown="onKeyDown" v-model="content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/page/assets/styles/main.scss';
|
||||
|
||||
.chat {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
|
||||
.chat-history {
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $background-tertiary transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $background-tertiary;
|
||||
border: 2px solid $background-primary;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: $background-floating;
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 10px 5px 0px 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
user-select: text;
|
||||
word-wrap: break-word;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.content-head {
|
||||
cursor: default;
|
||||
width: 100%;
|
||||
margin-bottom: 3px;
|
||||
display: block;
|
||||
|
||||
.session {
|
||||
display: inline-block;
|
||||
color: $text-normal;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: $text-muted;
|
||||
font-size: 0.7rem;
|
||||
margin-left: 0.3rem;
|
||||
line-height: 12px;
|
||||
&::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-send {
|
||||
flex-shrink: 0;
|
||||
height: 80px;
|
||||
max-height: 80px;
|
||||
padding: 0 10px 10px 10px;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
|
||||
.text-container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba($color: #fff, $alpha: 0.05);
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
.emoji-menu {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
margin: 8px 5px 0 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
font-family: $text-family;
|
||||
border: none;
|
||||
caret-color: $text-normal;
|
||||
color: $text-normal;
|
||||
resize: none;
|
||||
margin: 5px;
|
||||
background-color: transparent;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $background-tertiary transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $background-tertiary;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: $background-floating;
|
||||
}
|
||||
|
||||
&::selection {
|
||||
background: $text-link;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop, Watch, Ref } from 'vue-property-decorator'
|
||||
import Neko from '~/component/main.vue'
|
||||
|
||||
const length = 512 // max length of message
|
||||
|
||||
@Component({
|
||||
name: 'neko-chat',
|
||||
})
|
||||
export default class extends Vue {
|
||||
@Ref('history') readonly _history!: HTMLElement
|
||||
@Prop() readonly neko!: Neko
|
||||
|
||||
history = []
|
||||
content = ''
|
||||
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this._history.scrollTop = this._history.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
timestamp(date: Date | string) {
|
||||
date = new Date(date)
|
||||
|
||||
return (
|
||||
date.getFullYear() +
|
||||
'-' +
|
||||
String(date.getMonth() + 1).padStart(2, '0') +
|
||||
'-' +
|
||||
String(date.getDate()).padStart(2, '0') +
|
||||
' ' +
|
||||
String(date.getHours()).padStart(2, '0') +
|
||||
':' +
|
||||
String(date.getMinutes()).padStart(2, '0') +
|
||||
':' +
|
||||
String(date.getSeconds()).padStart(2, '0')
|
||||
)
|
||||
}
|
||||
|
||||
session(id: string) {
|
||||
let session = this.neko.state.sessions[id]
|
||||
return session ? session.profile.name : id
|
||||
}
|
||||
|
||||
@Watch('neko')
|
||||
onNekoChange() {
|
||||
this.neko.events.on('receive.broadcast', (sender: string, subject: string, body: string) => {
|
||||
if (subject === 'chat') {
|
||||
Vue.set(this, 'history', [...this.history, body])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Watch('history')
|
||||
onHistroyChange() {
|
||||
this.$nextTick(() => {
|
||||
this._history.scrollTop = this._history.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
if (this.content.length > length) {
|
||||
this.content = this.content.substring(0, length)
|
||||
}
|
||||
|
||||
if (this.content.length == length) {
|
||||
if (
|
||||
[8, 16, 17, 18, 20, 33, 34, 35, 36, 37, 38, 39, 40, 45, 46, 91, 93, 144].includes(event.keyCode) ||
|
||||
(event.ctrlKey && [67, 65, 88].includes(event.keyCode))
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.keyCode !== 13 || event.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.content === '') {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('send_message', this.content)
|
||||
|
||||
let message = {
|
||||
id: this.neko.state.session_id,
|
||||
created: new Date(),
|
||||
content: this.content,
|
||||
}
|
||||
|
||||
this.neko.sendBroadcast('chat', message)
|
||||
Vue.set(this, 'history', [...this.history, message])
|
||||
|
||||
this.content = ''
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
</script>
|
79
src/page/components/connect.vue
Normal file
79
src/page/components/connect.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div>
|
||||
<table v-if="!neko.state.authenticated">
|
||||
<tr>
|
||||
<th style="padding: 5px; text-align: left">Username</th>
|
||||
<td><input type="text" placeholder="Username" v-model="username" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="padding: 5px; text-align: left">Password</th>
|
||||
<td><input type="password" placeholder="Password" v-model="password" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th></th>
|
||||
<td><button @click="login()">Login</button></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div v-else style="text-align: center">
|
||||
<p style="padding-bottom: 10px">You are not connected to the server.</p>
|
||||
<button @click="connect()">Connect</button> or
|
||||
<button @click="logout()">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss"></style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from 'vue-property-decorator'
|
||||
import Neko from '~/component/main.vue'
|
||||
|
||||
@Component({
|
||||
name: 'neko-controls',
|
||||
})
|
||||
export default class extends Vue {
|
||||
@Prop() readonly neko!: Neko
|
||||
|
||||
username: string = 'admin'
|
||||
password: string = 'admin'
|
||||
|
||||
async login() {
|
||||
localStorage.setItem('username', this.username)
|
||||
localStorage.setItem('password', this.password)
|
||||
|
||||
try {
|
||||
await this.neko.login(this.username, this.password)
|
||||
} catch (e: any) {
|
||||
alert(e.response ? e.response.data.message : e)
|
||||
}
|
||||
}
|
||||
|
||||
async connect() {
|
||||
try {
|
||||
await this.neko.connect()
|
||||
} catch (e: any) {
|
||||
alert(e)
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await this.neko.logout()
|
||||
} catch (e: any) {
|
||||
alert(e.response ? e.response.data.message : e)
|
||||
}
|
||||
}
|
||||
|
||||
mounted() {
|
||||
const username = localStorage.getItem('username')
|
||||
if (username) {
|
||||
this.username = username
|
||||
}
|
||||
|
||||
const password = localStorage.getItem('password')
|
||||
if (password) {
|
||||
this.password = password
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
247
src/page/components/controls.vue
Normal file
247
src/page/components/controls.vue
Normal file
@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<ul>
|
||||
<li>
|
||||
<i
|
||||
:class="[!can_host ? 'disabled' : '', !hosting ? 'faded' : '', 'fas', 'fa-keyboard', 'request']"
|
||||
@click.stop.prevent="toggleControl"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="locked" />
|
||||
<span />
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<i
|
||||
:class="[{ disabled: !playable }, playing ? 'fa-pause-circle' : 'fa-play-circle', 'fas', 'play']"
|
||||
@click.stop.prevent="toggleMedia"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<div class="volume">
|
||||
<i
|
||||
:class="[volume === 0 || muted ? 'fa-volume-mute' : 'fa-volume-up', 'fas']"
|
||||
@click.stop.prevent="toggleMute"
|
||||
/>
|
||||
<input type="range" min="0" max="100" v-model="volume" />
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<i class="fa-sign-out-alt fas" @click.stop.prevent="disconnect" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles/_variables.scss';
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
li {
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
i {
|
||||
padding: 0 5px;
|
||||
&.faded {
|
||||
color: rgba($color: $text-normal, $alpha: 0.4);
|
||||
}
|
||||
&.disabled {
|
||||
color: rgba($color: $style-error, $alpha: 0.4);
|
||||
}
|
||||
}
|
||||
.volume {
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
input[type='range'] {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
width: 150px;
|
||||
height: 20px;
|
||||
-webkit-appearance: none;
|
||||
&::-moz-range-thumb {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
&::-moz-range-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
background: $style-primary;
|
||||
border-radius: 2px;
|
||||
}
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
margin-top: -4px;
|
||||
}
|
||||
&::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
background: $style-primary;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.switch {
|
||||
margin: 0 5px;
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
input[type='checkbox'] {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
span {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: $background-secondary;
|
||||
transition: 0.2s;
|
||||
border-radius: 34px;
|
||||
&:before {
|
||||
color: $background-tertiary;
|
||||
font-weight: 900;
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
content: '\f3c1';
|
||||
font-size: 8px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
input[type='checkbox'] {
|
||||
&:checked + span {
|
||||
background-color: $style-primary;
|
||||
&:before {
|
||||
content: '\f023';
|
||||
transform: translateX(18px);
|
||||
}
|
||||
}
|
||||
&:disabled + span {
|
||||
&:before {
|
||||
content: '';
|
||||
background-color: rgba($color: $text-normal, $alpha: 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from 'vue-property-decorator'
|
||||
import Neko from '~/component/main.vue'
|
||||
|
||||
@Component({
|
||||
name: 'neko-controls',
|
||||
})
|
||||
export default class extends Vue {
|
||||
@Prop() readonly neko!: Neko
|
||||
|
||||
get can_host() {
|
||||
return this.neko.connected
|
||||
}
|
||||
|
||||
get hosting() {
|
||||
return this.neko.controlling
|
||||
}
|
||||
|
||||
get volume() {
|
||||
return this.neko.state.video.volume * 100
|
||||
}
|
||||
|
||||
set volume(volume: number) {
|
||||
this.neko.setVolume(volume / 100)
|
||||
}
|
||||
|
||||
get muted() {
|
||||
return this.neko.state.video.muted || this.neko.state.video.volume === 0
|
||||
}
|
||||
|
||||
get playing() {
|
||||
return this.neko.state.video.playing
|
||||
}
|
||||
|
||||
get playable() {
|
||||
return this.neko.state.video.playable
|
||||
}
|
||||
|
||||
get locked() {
|
||||
return this.neko.state.control.locked
|
||||
}
|
||||
|
||||
set locked(lock: boolean) {
|
||||
if (lock) {
|
||||
this.neko.control.lock()
|
||||
} else {
|
||||
this.neko.control.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
toggleControl() {
|
||||
if (this.can_host && this.hosting) {
|
||||
this.neko.room.controlRelease()
|
||||
}
|
||||
|
||||
if (this.can_host && !this.hosting) {
|
||||
this.neko.room.controlRequest()
|
||||
}
|
||||
}
|
||||
|
||||
toggleMedia() {
|
||||
if (this.playable && this.playing) {
|
||||
this.neko.pause()
|
||||
}
|
||||
|
||||
if (this.playable && !this.playing) {
|
||||
this.neko.play()
|
||||
}
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
if (this.playable && this.muted) {
|
||||
this.neko.unmute()
|
||||
}
|
||||
|
||||
if (this.playable && !this.muted) {
|
||||
this.neko.mute()
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.neko.logout()
|
||||
}
|
||||
}
|
||||
</script>
|
497
src/page/components/events.vue
Normal file
497
src/page/components/events.vue
Normal file
@ -0,0 +1,497 @@
|
||||
<template>
|
||||
<div class="tab-states">
|
||||
<table class="states">
|
||||
<tr>
|
||||
<th style="width: 50%">authenticated</th>
|
||||
<td :style="!neko.state.authenticated ? 'background: red;' : ''">{{ neko.state.authenticated }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>connection.url</th>
|
||||
<td style="word-break: break-all">{{ neko.state.connection.url }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>connection.token</th>
|
||||
<td>{{ neko.state.connection.token ? 'yes' : 'no' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>connection.status</th>
|
||||
<td
|
||||
:style="
|
||||
neko.state.connection.status == 'disconnected'
|
||||
? 'background: red;'
|
||||
: neko.state.connection.status == 'connecting'
|
||||
? 'background: #17448a;'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{ neko.state.connection.status }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th title="connection.websocket.connected">connection.websocket.con...</th>
|
||||
<td>{{ neko.state.connection.websocket.connected }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>connection.websocket.config</th>
|
||||
<td>
|
||||
<details>
|
||||
<summary>Show</summary>
|
||||
<table class="states">
|
||||
<tr>
|
||||
<th style="width: 40%">max_reconnects</th>
|
||||
<td>
|
||||
{{ neko.state.connection.websocket.config.max_reconnects }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="width: 40%">timeout_ms</th>
|
||||
<td>
|
||||
{{ neko.state.connection.websocket.config.timeout_ms }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="width: 40%">backoff_ms</th>
|
||||
<td>
|
||||
{{ neko.state.connection.websocket.config.backoff_ms }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th title="connection.webrtc.connected">connection.webrtc.connect...</th>
|
||||
<td>{{ neko.state.connection.webrtc.connected }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>connection.webrtc.stable</th>
|
||||
<td>{{ neko.state.connection.webrtc.stable }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>connection.webrtc.config</th>
|
||||
<td>
|
||||
<details>
|
||||
<summary>Show</summary>
|
||||
<table class="states">
|
||||
<tr>
|
||||
<th style="width: 40%">max_reconnects</th>
|
||||
<td>
|
||||
{{ neko.state.connection.webrtc.config.max_reconnects }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="width: 40%">timeout_ms</th>
|
||||
<td>
|
||||
{{ neko.state.connection.webrtc.config.timeout_ms }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="width: 40%">backoff_ms</th>
|
||||
<td>
|
||||
{{ neko.state.connection.webrtc.config.backoff_ms }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>connection.webrtc.stats</th>
|
||||
<td>
|
||||
<table class="states" v-if="neko.state.connection.webrtc.stats != null">
|
||||
<tr>
|
||||
<th style="width: 40%">muted</th>
|
||||
<td :style="neko.state.connection.webrtc.stats.muted ? 'background: red' : ''">
|
||||
{{ neko.state.connection.webrtc.stats.muted }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="width: 40%">bitrate</th>
|
||||
<td>{{ Math.floor(neko.state.connection.webrtc.stats.bitrate / 1024 / 8) }} KB/s</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>loss</th>
|
||||
<td :style="neko.state.connection.webrtc.stats.packetLoss >= 1 ? 'background: red' : ''">
|
||||
{{ Math.floor(neko.state.connection.webrtc.stats.packetLoss) }}%
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
colspan="2"
|
||||
style="background: green; text-align: center"
|
||||
v-if="neko.state.connection.webrtc.stats.paused"
|
||||
>
|
||||
webrtc is paused
|
||||
</td>
|
||||
<td
|
||||
colspan="2"
|
||||
style="background: red; text-align: center"
|
||||
v-else-if="!neko.state.connection.webrtc.stats.fps"
|
||||
>
|
||||
frame rate is zero
|
||||
</td>
|
||||
<td colspan="2" v-else>
|
||||
{{
|
||||
neko.state.connection.webrtc.stats.width +
|
||||
'x' +
|
||||
neko.state.connection.webrtc.stats.height +
|
||||
'@' +
|
||||
Math.floor(neko.state.connection.webrtc.stats.fps * 100) / 100
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>connection.webrtc.video</th>
|
||||
<td>{{ neko.state.connection.webrtc.video }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th rowspan="2">connection.webrtc.videos</th>
|
||||
<td>Total {{ neko.state.connection.webrtc.videos.length }} videos.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<select :value="neko.state.connection.webrtc.video" @input="neko.setWebRTCVideo($event.target.value)">
|
||||
<option v-for="video in neko.state.connection.webrtc.videos" :key="video" :value="video">
|
||||
{{ video }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>connection.screencast</th>
|
||||
<td>{{ neko.state.connection.screencast }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>connection.type</th>
|
||||
<td :style="neko.state.connection.type == 'fallback' ? 'background: #17448a;' : ''">
|
||||
{{ neko.state.connection.type }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>video.playable</th>
|
||||
<td>{{ neko.state.video.playable }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th rowspan="2">video.playing</th>
|
||||
<td>{{ neko.state.video.playing }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button v-if="!neko.state.video.playing" @click="neko.play()">play</button>
|
||||
<button v-else @click="neko.pause()">pause</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th rowspan="2">video.volume</th>
|
||||
<td>{{ neko.state.video.volume }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
:value="neko.state.video.volume"
|
||||
@input="neko.setVolume(Number($event.target.value))"
|
||||
step="0.01"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th rowspan="2">video.muted</th>
|
||||
<td>{{ neko.state.video.muted }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button v-if="!neko.state.video.muted" @click="neko.mute()">mute</button>
|
||||
<button v-else @click="neko.unmute()">unmute</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>control.scroll.inverse</th>
|
||||
<td>
|
||||
<div class="space-between">
|
||||
<span>{{ neko.state.control.scroll.inverse }}</span>
|
||||
<button @click="neko.setScrollInverse(!neko.state.control.scroll.inverse)">
|
||||
<i class="fas fa-toggle-on"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th rowspan="2">control.scroll.sensitivity</th>
|
||||
<td>{{ neko.state.control.scroll.sensitivity }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="range"
|
||||
min="-5"
|
||||
max="5"
|
||||
:value="neko.state.control.scroll.sensitivity"
|
||||
@input="neko.setScrollSensitivity(Number($event.target.value))"
|
||||
step="1"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>control.clipboard</th>
|
||||
<td>
|
||||
<textarea
|
||||
:readonly="!neko.controlling"
|
||||
:value="neko.state.control.clipboard ? neko.state.control.clipboard.text : ''"
|
||||
@input="clipboardText = $event.target.value"
|
||||
></textarea>
|
||||
<button :disabled="!neko.controlling" @click="neko.room.clipboardSetText({ text: clipboardText })">
|
||||
send clipboard
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th rowspan="2">control.keyboard</th>
|
||||
<td>
|
||||
{{
|
||||
neko.state.control.keyboard.layout +
|
||||
(neko.state.control.keyboard.variant ? ' (' + neko.state.control.keyboard.variant + ')' : '')
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Layout"
|
||||
:value="neko.state.control.keyboard.layout"
|
||||
@input="neko.setKeyboard($event.target.value, neko.state.control.keyboard.variant)"
|
||||
style="width: 50%; box-sizing: border-box"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Variant"
|
||||
:value="neko.state.control.keyboard.variant"
|
||||
@input="neko.setKeyboard(neko.state.control.keyboard.layout, $event.target.value)"
|
||||
style="width: 50%; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th rowspan="2">control.host_id</th>
|
||||
<td>{{ neko.state.control.host_id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button v-if="!neko.controlling" @click="neko.room.controlRequest()">request control</button>
|
||||
<button v-else @click="neko.room.controlRelease()">release control</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>screen.size</th>
|
||||
<td>
|
||||
{{ neko.state.screen.size.width }}x{{ neko.state.screen.size.height }}@{{ neko.state.screen.size.rate }}
|
||||
</td>
|
||||
</tr>
|
||||
<template v-if="neko.is_admin">
|
||||
<tr>
|
||||
<th rowspan="2">screen.configurations</th>
|
||||
<td>Total {{ neko.state.screen.configurations.length }} configurations.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<select
|
||||
:value="Object.values(neko.state.screen.size).join()"
|
||||
@input="
|
||||
a = String($event.target.value).split(',')
|
||||
neko.setScreenSize(parseInt(a[0]), parseInt(a[1]), parseInt(a[2]))
|
||||
"
|
||||
>
|
||||
<option
|
||||
v-for="{ width, height, rate } in neko.state.screen.configurations"
|
||||
:key="String(width) + String(height) + String(rate)"
|
||||
:value="[width, height, rate].join()"
|
||||
>
|
||||
{{ width }}x{{ height }}@{{ rate }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="screenChangingToggle">screenChangingToggle</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else>
|
||||
<th>screen.configurations</th>
|
||||
<td>Session is not admin.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>session_id</th>
|
||||
<td>{{ neko.state.session_id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>sessions</th>
|
||||
<td>Total {{ Object.values(neko.state.sessions).length }} sessions.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th class="middle">settings.private_mode</th>
|
||||
<td>
|
||||
<div class="space-between">
|
||||
<span>{{ neko.state.settings.private_mode }}</span>
|
||||
<button @click="updateSettings({ private_mode: !neko.state.settings.private_mode })">
|
||||
<i class="fas fa-toggle-on"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="middle">settings.implicit_hosting</th>
|
||||
<td>
|
||||
<div class="space-between">
|
||||
<span>{{ neko.state.settings.implicit_hosting }}</span>
|
||||
<button @click="updateSettings({ implicit_hosting: !neko.state.settings.implicit_hosting })">
|
||||
<i class="fas fa-toggle-on"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="middle">settings.inactive_cursors</th>
|
||||
<td>
|
||||
<div class="space-between">
|
||||
<span>{{ neko.state.settings.inactive_cursors }}</span>
|
||||
<button @click="updateSettings({ inactive_cursors: !neko.state.settings.inactive_cursors })">
|
||||
<i class="fas fa-toggle-on"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="middle">settings.merciful_reconnect</th>
|
||||
<td>
|
||||
<div class="space-between">
|
||||
<span>{{ neko.state.settings.merciful_reconnect }}</span>
|
||||
<button @click="updateSettings({ merciful_reconnect: !neko.state.settings.merciful_reconnect })">
|
||||
<i class="fas fa-toggle-on"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>cursors</th>
|
||||
<td>{{ neko.state.cursors }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>control actions</th>
|
||||
<td>
|
||||
<button title="cut" @click="neko.control.cut()"><i class="fas fa-cut" /></button>
|
||||
<button title="copy" @click="neko.control.copy()"><i class="fas fa-copy" /></button>
|
||||
<button title="paste" @click="neko.control.paste()"><i class="fas fa-paste" /></button>
|
||||
<button title="select all" @click="neko.control.selectAll()"><i class="fas fa-i-cursor" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>control keypress</th>
|
||||
<td style="text-align: center">
|
||||
<button style="width: 20px" v-for="l in letters" :key="l" @click="neko.control.keyPress(l)">
|
||||
{{ String.fromCharCode(l) }}
|
||||
</button>
|
||||
<div style="display: flex">
|
||||
<button title="shift" @click="shift = !shift">
|
||||
<i v-if="shift" class="fas fa-caret-square-up" />
|
||||
<i v-else class="far fa-caret-square-up" />
|
||||
</button>
|
||||
<button style="width: 100%" @click="neko.control.keyPress(' '.charCodeAt(0))">space</button>
|
||||
<button title="shift" @click="shift = !shift">
|
||||
<i v-if="shift" class="fas fa-caret-square-up" />
|
||||
<i v-else class="far fa-caret-square-up" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.tab-states {
|
||||
&,
|
||||
.states {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
border: 1px solid #ccc;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.middle {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.space-between {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from 'vue-property-decorator'
|
||||
import Neko from '~/component/main.vue'
|
||||
|
||||
@Component({
|
||||
name: 'neko-events',
|
||||
})
|
||||
export default class extends Vue {
|
||||
@Prop() readonly neko!: Neko
|
||||
|
||||
clipboardText: string = ''
|
||||
|
||||
shift = false
|
||||
get letters(): number[] {
|
||||
let letters = [] as number[]
|
||||
for (let i = (this.shift ? 'A' : 'a').charCodeAt(0); i <= (this.shift ? 'Z' : 'z').charCodeAt(0); i++) {
|
||||
letters.push(i)
|
||||
}
|
||||
return letters
|
||||
}
|
||||
|
||||
// fast sceen changing test
|
||||
screen_interval = null
|
||||
screenChangingToggle() {
|
||||
if (this.screen_interval === null) {
|
||||
let sizes = this.neko.state.screen.configurations
|
||||
let len = sizes.length
|
||||
|
||||
//@ts-ignore
|
||||
this.screen_interval = setInterval(() => {
|
||||
let { width, height, rate } = sizes[Math.floor(Math.random() * len)]
|
||||
|
||||
this.neko.setScreenSize(width, height, rate)
|
||||
}, 10)
|
||||
} else {
|
||||
//@ts-ignore
|
||||
clearInterval(this.screen_interval)
|
||||
this.screen_interval = null
|
||||
}
|
||||
}
|
||||
|
||||
async updateSettings(settings: any) {
|
||||
try {
|
||||
await this.neko.room.settingsSet(settings)
|
||||
} catch (e: any) {
|
||||
alert(e.response ? e.response.data.message : e)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
112
src/page/components/header.vue
Normal file
112
src/page/components/header.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="header">
|
||||
<div class="neko">
|
||||
<span class="logo"><b>n</b>.eko</span>
|
||||
<div class="server">
|
||||
<span>Server:</span>
|
||||
<input type="text" placeholder="URL" v-model="url" />
|
||||
<button @click="setUrl">change</button>
|
||||
</div>
|
||||
<ul class="menu">
|
||||
<li>
|
||||
<i class="fas fa-bars toggle" @click="toggleMenu" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles/_variables.scss';
|
||||
|
||||
.header {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
|
||||
.neko {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 150px;
|
||||
margin-left: 20px;
|
||||
|
||||
.logo {
|
||||
font-size: 30px;
|
||||
line-height: 30px;
|
||||
|
||||
b {
|
||||
font-weight: 900;
|
||||
}
|
||||
}
|
||||
|
||||
.server {
|
||||
max-width: 850px;
|
||||
width: 100%;
|
||||
margin: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
margin: 0 5px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
justify-self: flex-end;
|
||||
margin-right: 10px;
|
||||
white-space: nowrap;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
|
||||
i {
|
||||
display: block;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
text-align: center;
|
||||
line-height: 32px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
background: $background-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Ref, Watch, Vue } from 'vue-property-decorator'
|
||||
import Neko from '~/component/main.vue'
|
||||
|
||||
@Component({
|
||||
name: 'neko-header',
|
||||
})
|
||||
export default class extends Vue {
|
||||
@Prop() readonly neko!: Neko
|
||||
|
||||
url: string = location.href
|
||||
|
||||
async setUrl() {
|
||||
if (this.url == '') {
|
||||
this.url = location.href
|
||||
}
|
||||
|
||||
await this.neko.setUrl(this.url)
|
||||
}
|
||||
|
||||
toggleMenu() {
|
||||
this.$emit('toggle')
|
||||
//this.$accessor.client.toggleSide()
|
||||
}
|
||||
}
|
||||
</script>
|
145
src/page/components/media.vue
Normal file
145
src/page/components/media.vue
Normal file
@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="media" style="width: 100%">
|
||||
<!--
|
||||
<button @click="getDevices">List available devices</button>
|
||||
<button v-for="d in devices" :key="d.deviceId">
|
||||
{{ d.kind }} : {{ d.label }} id = {{ d.deviceId }}
|
||||
</button>
|
||||
-->
|
||||
<button v-if="micTracks.length == 0" @click="addMicrophone">Add microphone</button>
|
||||
<button v-else @click="stopMicrophone">Stop microphone</button>
|
||||
<br />
|
||||
<audio v-show="micTracks.length > 0" ref="audio" controls />
|
||||
<hr />
|
||||
<button v-if="camTracks.length == 0" @click="addWebcam">Add webcam</button>
|
||||
<button v-else @click="stopWebcam">Stop webcam</button>
|
||||
<br />
|
||||
<video v-show="camTracks.length > 0" ref="video" controls />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop, Ref } from 'vue-property-decorator'
|
||||
import Neko from '~/component/main.vue'
|
||||
|
||||
@Component({
|
||||
name: 'neko-media',
|
||||
})
|
||||
export default class extends Vue {
|
||||
@Prop() readonly neko!: Neko
|
||||
@Ref('audio') readonly _audio!: HTMLAudioElement
|
||||
@Ref('video') readonly _video!: HTMLVideoElement
|
||||
|
||||
private micTracks: MediaStreamTrack[] = []
|
||||
private micSenders: RTCRtpSender[] = []
|
||||
|
||||
private camTracks: MediaStreamTrack[] = []
|
||||
private camSenders: RTCRtpSender[] = []
|
||||
|
||||
//private devices: any[] = []
|
||||
|
||||
async addMicrophone() {
|
||||
this.micTracks = []
|
||||
this.micSenders = []
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true })
|
||||
|
||||
this._audio.srcObject = stream
|
||||
console.log('Got MediaStream:', stream)
|
||||
|
||||
const tracks = stream.getTracks()
|
||||
console.log('Got tracks:', tracks)
|
||||
|
||||
tracks.forEach((track) => {
|
||||
this.micTracks.push(track)
|
||||
console.log('Adding track', track, stream)
|
||||
|
||||
const rtcp = this.neko.addTrack(track, stream)
|
||||
this.micSenders.push(rtcp)
|
||||
console.log('rtcp sender', rtcp, rtcp.transport)
|
||||
|
||||
// TODO: Can be null.
|
||||
rtcp.transport?.addEventListener('statechange', () => {
|
||||
console.log('track - on state change', rtcp.transport?.state)
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
alert('Error accessing media devices.' + error)
|
||||
}
|
||||
}
|
||||
|
||||
stopMicrophone() {
|
||||
this.micTracks.forEach((track) => {
|
||||
track.stop()
|
||||
})
|
||||
|
||||
this.micSenders.forEach((rtcp) => {
|
||||
this.neko.removeTrack(rtcp)
|
||||
})
|
||||
|
||||
this._audio.srcObject = null
|
||||
this.micTracks = []
|
||||
this.micSenders = []
|
||||
}
|
||||
|
||||
async addWebcam() {
|
||||
this.camTracks = []
|
||||
this.camSenders = []
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
audio: false,
|
||||
})
|
||||
|
||||
this._video.srcObject = stream
|
||||
console.log('Got MediaStream:', stream)
|
||||
|
||||
const tracks = stream.getTracks()
|
||||
console.log('Got tracks:', tracks)
|
||||
|
||||
tracks.forEach((track) => {
|
||||
this.camTracks.push(track)
|
||||
console.log('Adding track', track, stream)
|
||||
|
||||
const rtcp = this.neko.addTrack(track, stream)
|
||||
this.camSenders.push(rtcp)
|
||||
console.log('rtcp sender', rtcp, rtcp.transport)
|
||||
|
||||
// TODO: Can be null.
|
||||
rtcp.transport?.addEventListener('statechange', () => {
|
||||
console.log('track - on state change', rtcp.transport?.state)
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
alert('Error accessing media devices.' + error)
|
||||
}
|
||||
}
|
||||
|
||||
stopWebcam() {
|
||||
this.camTracks.forEach((track) => {
|
||||
track.stop()
|
||||
})
|
||||
|
||||
this.camSenders.forEach((rtcp) => {
|
||||
this.neko.removeTrack(rtcp)
|
||||
})
|
||||
|
||||
this._audio.srcObject = null
|
||||
this.camTracks = []
|
||||
this.camSenders = []
|
||||
}
|
||||
|
||||
/*async getDevices() {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
this.devices = devices.map(({ kind, label, deviceId }) => ({ kind, label, deviceId }))
|
||||
console.log(this.devices)
|
||||
}*/
|
||||
}
|
||||
</script>
|
569
src/page/components/members.vue
Normal file
569
src/page/components/members.vue
Normal file
@ -0,0 +1,569 @@
|
||||
<template>
|
||||
<div class="members">
|
||||
<table class="plugins" v-if="plugins">
|
||||
<tr>
|
||||
<td colspan="2" class="name">Plugins for {{ plugins.profile.name }}</td>
|
||||
</tr>
|
||||
<tr v-for="([key], i) in plugins.old" :key="key">
|
||||
<th>{{ key }}</th>
|
||||
<td><input type="text" v-model="plugins.old[i][1]" placeholder="value (JSON)" /></td>
|
||||
</tr>
|
||||
<tr v-for="([key], i) in plugins.new" :key="key">
|
||||
<th><input type="text" v-model="plugins.new[i][0]" placeholder="key (string)" /></th>
|
||||
<td><input type="text" v-model="plugins.new[i][1]" placeholder="value (JSON)" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="text-align: center">
|
||||
<button @click="$set(plugins, 'new', [...plugins.new, ['', '']])">+</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<button @click="savePlugins">save</button>
|
||||
<button @click="plugins = null">close</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p class="title">
|
||||
<span>Sessions</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="member"
|
||||
:class="{
|
||||
'is-admin': neko.is_admin,
|
||||
}"
|
||||
v-for="(session, id) in sessions"
|
||||
:key="'session-' + id"
|
||||
>
|
||||
<div class="topbar">
|
||||
<div class="name">
|
||||
<i v-if="neko.is_admin" class="fa fa-trash-alt" @click="memberRemove(id)" title="remove" />
|
||||
{{ session.profile.name }}
|
||||
</div>
|
||||
<div class="controls">
|
||||
<i
|
||||
class="fa fa-shield-alt"
|
||||
:class="{
|
||||
'state-has': session.profile.is_admin,
|
||||
}"
|
||||
@click="neko.is_admin && updateProfile(id, { is_admin: !session.profile.is_admin })"
|
||||
title="is_admin"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-lock-open"
|
||||
:class="{
|
||||
'state-has': session.profile.can_login,
|
||||
}"
|
||||
@click="neko.is_admin && updateProfile(id, { can_login: !session.profile.can_login })"
|
||||
title="can_login"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-sign-in-alt"
|
||||
:class="{
|
||||
'state-has': session.profile.can_connect,
|
||||
'state-is': session.state.is_connected,
|
||||
'state-disabled': !session.profile.can_login,
|
||||
}"
|
||||
@click="neko.is_admin && updateProfile(id, { can_connect: !session.profile.can_connect })"
|
||||
title="can_connect"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-desktop"
|
||||
:class="{
|
||||
'state-has': session.profile.can_watch,
|
||||
'state-is': session.state.is_watching,
|
||||
'state-disabled': !session.profile.can_login || !session.profile.can_connect,
|
||||
}"
|
||||
@click="neko.is_admin && updateProfile(id, { can_watch: !session.profile.can_watch })"
|
||||
title="can_watch"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-keyboard"
|
||||
:class="{
|
||||
'state-has': session.profile.can_host,
|
||||
'state-is': neko.state.control.host_id == id,
|
||||
'state-disabled': !session.profile.can_login || !session.profile.can_connect,
|
||||
}"
|
||||
@click="neko.is_admin && updateProfile(id, { can_host: !session.profile.can_host })"
|
||||
title="can_host"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-microphone"
|
||||
:class="{
|
||||
'state-has': session.profile.can_share_media,
|
||||
'state-disabled': !session.profile.can_login || !session.profile.can_connect,
|
||||
}"
|
||||
@click="neko.is_admin && updateProfile(id, { can_share_media: !session.profile.can_share_media })"
|
||||
title="can_share_media"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-clipboard"
|
||||
:class="{
|
||||
'state-has': session.profile.can_access_clipboard,
|
||||
'state-disabled': !session.profile.can_login || !session.profile.can_connect,
|
||||
}"
|
||||
@click="neko.is_admin && updateProfile(id, { can_access_clipboard: !session.profile.can_access_clipboard })"
|
||||
title="can_access_clipboard"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-mouse"
|
||||
:class="{
|
||||
'state-has': session.profile.sends_inactive_cursor,
|
||||
'state-is':
|
||||
session.profile.sends_inactive_cursor &&
|
||||
neko.state.settings.inactive_cursors &&
|
||||
neko.state.cursors.some((e) => e.id == id),
|
||||
'state-disabled': !session.profile.can_login || !session.profile.can_connect,
|
||||
}"
|
||||
@click="
|
||||
neko.is_admin && updateProfile(id, { sends_inactive_cursor: !session.profile.sends_inactive_cursor })
|
||||
"
|
||||
title="sends_inactive_cursor"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-mouse-pointer"
|
||||
:class="{
|
||||
'state-has': session.profile.can_see_inactive_cursors,
|
||||
'state-disabled': !session.profile.can_login || !session.profile.can_connect,
|
||||
}"
|
||||
@click="
|
||||
neko.is_admin &&
|
||||
updateProfile(id, { can_see_inactive_cursors: !session.profile.can_see_inactive_cursors })
|
||||
"
|
||||
title="can_see_inactive_cursors"
|
||||
/>
|
||||
<i class="fa fa-puzzle-piece state-has" @click="showPlugins(id, session.profile)" title="plugins" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="title">
|
||||
<span>Members</span>
|
||||
<button @click="membersLoad">reload</button>
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="member"
|
||||
:class="{
|
||||
'is-admin': neko.is_admin,
|
||||
}"
|
||||
v-for="member in membersWithoutSessions"
|
||||
:key="'member-' + member.id"
|
||||
>
|
||||
<div class="topbar">
|
||||
<div class="name">
|
||||
<i v-if="neko.is_admin" class="fa fa-trash-alt" @click="memberRemove(member.id)" title="remove" />
|
||||
{{ member.profile.name }}
|
||||
</div>
|
||||
<div class="controls">
|
||||
<i
|
||||
class="fa fa-shield-alt"
|
||||
:class="{
|
||||
'state-has': member.profile.is_admin,
|
||||
}"
|
||||
@click="neko.is_admin && updateProfile(member.id, { is_admin: !member.profile.is_admin })"
|
||||
title="is_admin"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-lock-open"
|
||||
:class="{
|
||||
'state-has': member.profile.can_login,
|
||||
}"
|
||||
@click="neko.is_admin && updateProfile(member.id, { can_login: !member.profile.can_login })"
|
||||
title="can_login"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-sign-in-alt"
|
||||
:class="{
|
||||
'state-has': member.profile.can_connect,
|
||||
'state-disabled': !member.profile.can_login,
|
||||
}"
|
||||
@click="neko.is_admin && updateProfile(member.id, { can_connect: !member.profile.can_connect })"
|
||||
title="can_connect"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-desktop"
|
||||
:class="{
|
||||
'state-has': member.profile.can_watch,
|
||||
'state-disabled': !member.profile.can_login || !member.profile.can_connect,
|
||||
}"
|
||||
@click="neko.is_admin && updateProfile(member.id, { can_watch: !member.profile.can_watch })"
|
||||
title="can_watch"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-keyboard"
|
||||
:class="{
|
||||
'state-has': member.profile.can_host,
|
||||
'state-disabled': !member.profile.can_login || !member.profile.can_connect,
|
||||
}"
|
||||
@click="neko.is_admin && updateProfile(member.id, { can_host: !member.profile.can_host })"
|
||||
title="can_host"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-microphone"
|
||||
:class="{
|
||||
'state-has': member.profile.can_share_media,
|
||||
'state-disabled': !member.profile.can_login || !member.profile.can_connect,
|
||||
}"
|
||||
@click="neko.is_admin && updateProfile(member.id, { can_share_media: !member.profile.can_share_media })"
|
||||
title="can_share_media"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-clipboard"
|
||||
:class="{
|
||||
'state-has': member.profile.can_access_clipboard,
|
||||
'state-disabled': !member.profile.can_login || !member.profile.can_connect,
|
||||
}"
|
||||
@click="
|
||||
neko.is_admin && updateProfile(member.id, { can_access_clipboard: !member.profile.can_access_clipboard })
|
||||
"
|
||||
title="can_access_clipboard"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-mouse"
|
||||
:class="{
|
||||
'state-has': member.profile.sends_inactive_cursor,
|
||||
'state-disabled': !member.profile.can_login || !member.profile.can_connect,
|
||||
}"
|
||||
@click="
|
||||
neko.is_admin &&
|
||||
updateProfile(member.id, { sends_inactive_cursor: !member.profile.sends_inactive_cursor })
|
||||
"
|
||||
title="sends_inactive_cursor"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-mouse-pointer"
|
||||
:class="{
|
||||
'state-has': member.profile.can_see_inactive_cursors,
|
||||
'state-disabled': !member.profile.can_login || !member.profile.can_connect,
|
||||
}"
|
||||
@click="
|
||||
neko.is_admin &&
|
||||
updateProfile(member.id, { can_see_inactive_cursors: !member.profile.can_see_inactive_cursors })
|
||||
"
|
||||
title="can_see_inactive_cursors"
|
||||
/>
|
||||
<i class="fa fa-puzzle-piece state-has" @click="showPlugins(member.id, member.profile)" title="plugins" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="new-member" v-if="neko.is_admin">
|
||||
<tr>
|
||||
<td colspan="2" class="name">New Member</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>username</th>
|
||||
<td><input type="text" v-model="newUsername" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>password</th>
|
||||
<td><input type="text" v-model="newPassword" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" class="name" style="text-align: center">Profile</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
<td><input type="text" v-model="newProfile.name" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>is_admin</th>
|
||||
<td><input type="checkbox" v-model="newProfile.is_admin" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>can_login</th>
|
||||
<td><input type="checkbox" v-model="newProfile.can_login" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>can_connect</th>
|
||||
<td><input type="checkbox" v-model="newProfile.can_connect" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>can_watch</th>
|
||||
<td><input type="checkbox" v-model="newProfile.can_watch" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>can_host</th>
|
||||
<td><input type="checkbox" v-model="newProfile.can_host" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>can_share_media</th>
|
||||
<td><input type="checkbox" v-model="newProfile.can_share_media" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>can_access_clipboard</th>
|
||||
<td><input type="checkbox" v-model="newProfile.can_access_clipboard" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>sends_inactive_cursor</th>
|
||||
<td><input type="checkbox" v-model="newProfile.sends_inactive_cursor" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>can_see_inactive_cursors</th>
|
||||
<td><input type="checkbox" v-model="newProfile.can_see_inactive_cursors" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><button @click="memberCreate">create</button></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/page/assets/styles/main.scss';
|
||||
|
||||
.title {
|
||||
padding: 4px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.members {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
overflow: hidden;
|
||||
|
||||
.member {
|
||||
padding: 5px;
|
||||
margin: 5px 0;
|
||||
border: 1px solid white;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.is-admin .fa {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.name {
|
||||
flex: 1 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fa {
|
||||
padding: 5px;
|
||||
color: rgb(211, 47, 47);
|
||||
|
||||
&.state-has {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.state-is {
|
||||
color: green;
|
||||
}
|
||||
|
||||
&.state-disabled {
|
||||
color: #555;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.new-member,
|
||||
.plugins {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
border: 1px solid #ccc;
|
||||
padding: 4px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.plugins {
|
||||
position: absolute;
|
||||
width: auto;
|
||||
box-shadow: 0px 0px 10px 5px black;
|
||||
background: $background-tertiary;
|
||||
|
||||
textarea,
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from 'vue-property-decorator'
|
||||
import Neko, { ApiModels, StateModels } from '~/component/main.vue'
|
||||
|
||||
@Component({
|
||||
name: 'neko-members',
|
||||
})
|
||||
export default class extends Vue {
|
||||
@Prop() readonly neko!: Neko
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
// init
|
||||
this.newProfile = Object.assign({}, this.defProfile)
|
||||
}
|
||||
|
||||
get sessions(): Record<string, StateModels.Session> {
|
||||
return this.neko.state.sessions
|
||||
}
|
||||
|
||||
get membersWithoutSessions(): ApiModels.MemberData[] {
|
||||
return this.members.filter(({ id }) => id && !(id in this.sessions))
|
||||
}
|
||||
|
||||
members: ApiModels.MemberData[] = []
|
||||
plugins: {
|
||||
id: string
|
||||
old: Array<Array<string>>
|
||||
new: Array<Array<string>>
|
||||
profile: ApiModels.MemberProfile
|
||||
} | null = null
|
||||
|
||||
newUsername: string = ''
|
||||
newPassword: string = ''
|
||||
newProfile: ApiModels.MemberProfile = {}
|
||||
defProfile: ApiModels.MemberProfile = {
|
||||
name: '',
|
||||
is_admin: false,
|
||||
can_login: true,
|
||||
can_connect: true,
|
||||
can_watch: true,
|
||||
can_host: true,
|
||||
can_share_media: true,
|
||||
can_access_clipboard: true,
|
||||
sends_inactive_cursor: true,
|
||||
can_see_inactive_cursors: true,
|
||||
}
|
||||
|
||||
async memberCreate() {
|
||||
try {
|
||||
const res = await this.neko.members.membersCreate({
|
||||
username: this.newUsername,
|
||||
password: this.newPassword,
|
||||
profile: this.newProfile,
|
||||
})
|
||||
|
||||
if (res.data) {
|
||||
Vue.set(this, 'members', [...this.members, res.data])
|
||||
}
|
||||
|
||||
// clear
|
||||
Vue.set(this, 'newUsername', '')
|
||||
Vue.set(this, 'newPassword', '')
|
||||
Vue.set(this, 'newProfile', Object.assign({}, this.defProfile))
|
||||
} catch (e: any) {
|
||||
alert(e.response ? e.response.data.message : e)
|
||||
}
|
||||
}
|
||||
|
||||
async membersLoad(limit: number = 0) {
|
||||
const offset = 0
|
||||
|
||||
try {
|
||||
const res = await this.neko.members.membersList(limit, offset)
|
||||
Vue.set(this, 'members', res.data)
|
||||
} catch (e: any) {
|
||||
alert(e.response ? e.response.data.message : e)
|
||||
}
|
||||
}
|
||||
|
||||
async memberGetProfile(memberId: string): Promise<ApiModels.MemberProfile | undefined> {
|
||||
try {
|
||||
const res = await this.neko.members.membersGetProfile(memberId)
|
||||
return res.data
|
||||
} catch (e: any) {
|
||||
alert(e.response ? e.response.data.message : e)
|
||||
}
|
||||
}
|
||||
|
||||
async updateProfile(memberId: string, memberProfile: ApiModels.MemberProfile) {
|
||||
try {
|
||||
const res = await this.neko.members.membersUpdateProfile(memberId, memberProfile)
|
||||
const members = this.members.map((member) => {
|
||||
if (member.id == memberId) {
|
||||
return {
|
||||
id: memberId,
|
||||
profile: { ...member.profile, ...memberProfile },
|
||||
}
|
||||
} else {
|
||||
return member
|
||||
}
|
||||
})
|
||||
Vue.set(this, 'members', members)
|
||||
} catch (e: any) {
|
||||
alert(e.response ? e.response.data.message : e)
|
||||
}
|
||||
}
|
||||
|
||||
async updatePassword(memberId: string, password: string) {
|
||||
try {
|
||||
await this.neko.members.membersUpdatePassword(memberId, { password })
|
||||
} catch (e: any) {
|
||||
alert(e.response ? e.response.data.message : e)
|
||||
}
|
||||
}
|
||||
|
||||
async memberRemove(memberId: string) {
|
||||
try {
|
||||
await this.neko.members.membersRemove(memberId)
|
||||
const members = this.members.filter(({ id }) => id != memberId)
|
||||
Vue.set(this, 'members', members)
|
||||
} catch (e: any) {
|
||||
alert(e.response ? e.response.data.message : e)
|
||||
}
|
||||
}
|
||||
|
||||
showPlugins(id: string, profile: ApiModels.MemberProfile) {
|
||||
const old = Object.entries(profile.plugins || {}).map(([key, val]) => [key, JSON.stringify(val, null, 2)])
|
||||
|
||||
this.plugins = {
|
||||
id,
|
||||
old,
|
||||
new: old.length > 0 ? [] : [['', '']],
|
||||
profile,
|
||||
}
|
||||
}
|
||||
|
||||
savePlugins() {
|
||||
if (!this.plugins) return
|
||||
|
||||
let errKey = ''
|
||||
try {
|
||||
let plugins = {} as any
|
||||
for (let [key, val] of this.plugins.old) {
|
||||
errKey = key
|
||||
plugins[key] = JSON.parse(val)
|
||||
}
|
||||
for (let [key, val] of this.plugins.new) {
|
||||
errKey = key
|
||||
plugins[key] = JSON.parse(val)
|
||||
}
|
||||
|
||||
this.updateProfile(this.plugins.id, { plugins })
|
||||
this.plugins = null
|
||||
} catch (e: any) {
|
||||
alert(errKey + ': ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.membersLoad(10)
|
||||
}
|
||||
}
|
||||
</script>
|
Reference in New Issue
Block a user