mirror of
https://github.com/m1k1o/neko.git
synced 2024-07-24 14:40:50 +12:00
Merge branch 'dev' into sk_lang
This commit is contained in:
@ -15,53 +15,55 @@
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve --mode development",
|
||||
"build": "vue-cli-service build",
|
||||
"build:lib": "vue-cli-service build --target lib --name neko-lib 'src/lib.ts'",
|
||||
"build:emoji": "ts-node --files --project tools/tsconfig.json tools/emoji.ts",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.13.0",
|
||||
"animejs": "^3.1.0",
|
||||
"axios": "^0.19.1",
|
||||
"date-fns": "^2.11.1",
|
||||
"emoji-datasource": "^5.0.1",
|
||||
"emojilib": "^2.4.0",
|
||||
"eventemitter3": "^4.0.0",
|
||||
"@fortawesome/fontawesome-free": "^5.14.0",
|
||||
"animejs": "^3.2.0",
|
||||
"axios": "^0.21.1",
|
||||
"date-fns": "^2.16.1",
|
||||
"emoji-datasource": "^6.0.1",
|
||||
"emojilib": "^3.0.1",
|
||||
"eventemitter3": "^4.0.7",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"simple-markdown": "^0.7.2",
|
||||
"sweetalert2": "^9.10.9",
|
||||
"typed-vuex": "^0.1.17",
|
||||
"sweetalert2": "^10.15.7",
|
||||
"typed-vuex": "^0.1.21",
|
||||
"v-tooltip": "^2.0.3",
|
||||
"vue": "^2.6.10",
|
||||
"vue-class-component": "^7.2.3",
|
||||
"vue": "^2.6.12",
|
||||
"vue-class-component": "^7.2.6",
|
||||
"vue-clickaway": "^2.2.2",
|
||||
"vue-context": "^5.1.0",
|
||||
"vue-i18n": "^8.16.0",
|
||||
"vue-context": "^5.2.0",
|
||||
"vue-i18n": "^8.21.1",
|
||||
"vue-notification": "^1.3.20",
|
||||
"vue-property-decorator": "^8.4.1",
|
||||
"vuex": "^3.1.3"
|
||||
"vue-property-decorator": "^9.1.2",
|
||||
"vuex": "^3.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/animejs": "^3.1.0",
|
||||
"@types/node": "^13.7.0",
|
||||
"@types/animejs": "^3.1.2",
|
||||
"@types/node": "^14.14.37",
|
||||
"@types/vue": "^2.0.0",
|
||||
"@types/vue-clickaway": "^2.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^2.26.0",
|
||||
"@typescript-eslint/parser": "^2.26.0",
|
||||
"@vue/cli-plugin-babel": "^4.1.0",
|
||||
"@vue/cli-plugin-eslint": "^4.1.0",
|
||||
"@vue/cli-plugin-typescript": "^4.1.0",
|
||||
"@vue/cli-plugin-vuex": "^4.1.0",
|
||||
"@vue/cli-service": "^4.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.19.0",
|
||||
"@typescript-eslint/parser": "^4.19.0",
|
||||
"@vue/cli-plugin-babel": "^4.5.6",
|
||||
"@vue/cli-plugin-eslint": "^4.5.6",
|
||||
"@vue/cli-plugin-typescript": "^4.5.6",
|
||||
"@vue/cli-plugin-vuex": "^4.5.6",
|
||||
"@vue/cli-service": "^4.5.12",
|
||||
"@vue/eslint-config-prettier": "^6.0.0",
|
||||
"@vue/eslint-config-typescript": "^5.0.2",
|
||||
"@vue/eslint-config-typescript": "^7.0.0",
|
||||
"core-js": "^3.9.1",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-plugin-prettier": "^3.1.1",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"node-sass": "^4.12.0",
|
||||
"prettier": "^2.0.2",
|
||||
"sass-loader": "^8.0.0",
|
||||
"ts-node": "^8.6.2",
|
||||
"typescript": "^3.8.3",
|
||||
"vue-template-compiler": "^2.6.10"
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"eslint-plugin-vue": "^7.8.0",
|
||||
"node-sass": "^5.0.0",
|
||||
"prettier": "^2.1.2",
|
||||
"sass-loader": "^10.1.1",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.2.3",
|
||||
"vue-template-compiler": "^2.6.12"
|
||||
}
|
||||
}
|
||||
|
@ -5,13 +5,14 @@
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>n.eko</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#19bd9c">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
|
||||
<link rel="manifest" href="site.webmanifest">
|
||||
<link rel="mask-icon" href="safari-pinned-tab.svg" color="#19bd9c">
|
||||
<meta name="msapplication-TileColor" content="#19bd9c">
|
||||
<meta name="theme-color" content="#19bd9c">
|
||||
<style> /* weird iOS bug, if this is not set right here, video just does not start */ .video-container { width: 100%; height: 100%; } </style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
|
@ -29,7 +29,7 @@
|
||||
<neko-side v-if="side" />
|
||||
<neko-connect v-if="!connected" />
|
||||
<neko-about v-if="about" />
|
||||
<notifications group="neko" position="top left" style="top: 50px;" />
|
||||
<notifications group="neko" position="top left" :ignoreDuplicates="true" style="top: 50px;pointer-events: none" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@ -109,25 +109,29 @@
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
#neko {
|
||||
&.expanded {
|
||||
.neko-main {
|
||||
transform: translateX(-$side-width);
|
||||
}
|
||||
.neko-menu {
|
||||
transform: translateX(-$side-width);
|
||||
#neko.expanded {
|
||||
.neko-main {
|
||||
transform: translateX(calc(-100% + 65px));
|
||||
|
||||
video {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.neko-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 65px;
|
||||
width: calc(100% - 65px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
#neko {
|
||||
.neko-main {
|
||||
.room-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
#neko .neko-main .room-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
58
client/src/components/avatar.vue
Normal file
58
client/src/components/avatar.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<!--
|
||||
<img :src="`https://ui-avatars.com/api/?name=${seed}&size=${size}`" />
|
||||
-->
|
||||
<div
|
||||
class="avatar"
|
||||
:style="{
|
||||
width: size + 'px',
|
||||
height: size + 'px',
|
||||
lineHeight: size + 'px',
|
||||
fontSize: size / 2 + 'px',
|
||||
backgroundColor: Background(seed),
|
||||
}"
|
||||
>
|
||||
{{ seed.substring(0, 2).toUpperCase() }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.avatar {
|
||||
user-select: none;
|
||||
text-align: center;
|
||||
background: white;
|
||||
color: black;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from 'vue-property-decorator'
|
||||
|
||||
@Component({
|
||||
name: 'neko-avatar',
|
||||
})
|
||||
export default class extends Vue {
|
||||
@Prop(String) readonly seed: string | undefined
|
||||
@Prop(Number) readonly size: number | undefined
|
||||
|
||||
Background(seed: string) {
|
||||
let a = 0,
|
||||
b = 0,
|
||||
c = 0
|
||||
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
a += seed.charCodeAt(i) * 3
|
||||
b += seed.charCodeAt(i) * 5
|
||||
c += seed.charCodeAt(i) * 7
|
||||
}
|
||||
|
||||
let x = Math.floor(128 + (a % 128))
|
||||
let y = Math.floor(128 + (b % 128))
|
||||
let z = Math.floor(128 + (c % 128))
|
||||
return 'rgb(' + x + ',' + y + ',' + z + ')'
|
||||
}
|
||||
}
|
||||
</script>
|
@ -4,7 +4,7 @@
|
||||
<template v-for="(message, index) in history">
|
||||
<li :key="index" class="message" v-if="message.type === 'text'">
|
||||
<div class="author" @contextmenu.stop.prevent="onContext($event, { member: member(message.id) })">
|
||||
<img :src="`https://api.adorable.io/avatars/40/${member(message.id).displayname}.png`" />
|
||||
<neko-avatar class="avatar" :seed="member(message.id).displayname" :size="40" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="content-head">
|
||||
@ -106,7 +106,7 @@
|
||||
background: $style-primary;
|
||||
margin: 0px 10px 10px 0px;
|
||||
|
||||
img {
|
||||
.avatar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@ -329,6 +329,7 @@
|
||||
import Markdown from './markdown'
|
||||
import Content from './context.vue'
|
||||
import Emoji from './emoji.vue'
|
||||
import Avatar from './avatar.vue'
|
||||
|
||||
const length = 512 // max length of message
|
||||
|
||||
@ -338,6 +339,7 @@
|
||||
'neko-markdown': Markdown,
|
||||
'neko-context': Content,
|
||||
'neko-emoji': Emoji,
|
||||
'neko-avatar': Avatar,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
|
75
client/src/components/clipboard.vue
Normal file
75
client/src/components/clipboard.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="clipboard" v-if="opened" @click="$event.stopPropagation()">
|
||||
<textarea ref="textarea" v-model="clipboard" @focus="$event.target.select()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.clipboard {
|
||||
background-color: $background-primary;
|
||||
border-radius: 0.25rem;
|
||||
display: block;
|
||||
padding: 5px;
|
||||
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
|
||||
&,
|
||||
textarea {
|
||||
max-width: 320px;
|
||||
width: 100%;
|
||||
max-height: 120px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
border: 0;
|
||||
color: $text-normal;
|
||||
background: none;
|
||||
|
||||
&::selection {
|
||||
background: $text-normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Ref, Vue } from 'vue-property-decorator'
|
||||
|
||||
@Component({
|
||||
name: 'neko-clipboard',
|
||||
})
|
||||
export default class extends Vue {
|
||||
@Ref('textarea') readonly _textarea!: HTMLTextAreaElement
|
||||
|
||||
private opened: boolean = false
|
||||
private typing: any = null
|
||||
|
||||
get clipboard() {
|
||||
return this.$accessor.remote.clipboard
|
||||
}
|
||||
|
||||
set clipboard(data: string) {
|
||||
this.$accessor.remote.setClipboard(data)
|
||||
|
||||
if (this.typing) {
|
||||
clearTimeout(this.typing)
|
||||
}
|
||||
|
||||
this.typing = setTimeout(() => this.$accessor.remote.sendClipboard(this.clipboard), 500)
|
||||
}
|
||||
|
||||
open() {
|
||||
this.opened = true
|
||||
document.body.addEventListener('click', this.close)
|
||||
setTimeout(() => this._textarea.focus(), 0)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.opened = false
|
||||
document.body.removeEventListener('click', this.close)
|
||||
}
|
||||
}
|
||||
</script>
|
@ -6,9 +6,10 @@
|
||||
<span><b>n</b>.eko</span>
|
||||
</div>
|
||||
<form class="message" v-if="!connecting" @submit.stop.prevent="connect">
|
||||
<span>{{ $t('connect.title') }}</span>
|
||||
<span v-if="!autoPassword">{{ $t('connect.login_title') }}</span>
|
||||
<span v-else>{{ $t('connect.invitation_title') }}</span>
|
||||
<input type="text" :placeholder="$t('connect.displayname')" v-model="displayname" />
|
||||
<input type="password" :placeholder="$t('connect.password')" v-model="password" />
|
||||
<input type="password" :placeholder="$t('connect.password')" v-model="password" v-if="!autoPassword" />
|
||||
<button type="submit" @click.stop.prevent="login">
|
||||
{{ $t('connect.connect') }}
|
||||
</button>
|
||||
@ -150,12 +151,21 @@
|
||||
|
||||
@Component({ name: 'neko-connect' })
|
||||
export default class extends Vue {
|
||||
private displayname = ''
|
||||
private password = ''
|
||||
private autoPassword: string | null = new URL(location.href).searchParams.get('pwd')
|
||||
|
||||
private displayname: string = ''
|
||||
private password: string = ''
|
||||
|
||||
mounted() {
|
||||
if (this.$accessor.displayname !== '' && this.$accessor.password !== '') {
|
||||
this.$accessor.login({ displayname: this.$accessor.displayname, password: this.$accessor.password })
|
||||
let password = this.$accessor.password
|
||||
if (this.autoPassword !== null) {
|
||||
this.removeUrlParam('pwd')
|
||||
password = this.autoPassword
|
||||
}
|
||||
|
||||
if (this.$accessor.displayname !== '' && password !== '') {
|
||||
this.$accessor.login({ displayname: this.$accessor.displayname, password })
|
||||
this.autoPassword = null
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,8 +173,44 @@
|
||||
return this.$accessor.connecting
|
||||
}
|
||||
|
||||
login() {
|
||||
this.$accessor.login({ displayname: this.displayname, password: this.password })
|
||||
removeUrlParam(param: string) {
|
||||
let url = document.location.href
|
||||
let urlparts = url.split('?')
|
||||
|
||||
if (urlparts.length >= 2) {
|
||||
let urlBase = urlparts.shift()
|
||||
let queryString = urlparts.join('?')
|
||||
|
||||
let prefix = encodeURIComponent(param) + '='
|
||||
let pars = queryString.split(/[&;]/g)
|
||||
for (let i = pars.length; i-- > 0; ) {
|
||||
if (pars[i].lastIndexOf(prefix, 0) !== -1) {
|
||||
pars.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
url = urlBase + (pars.length > 0 ? '?' + pars.join('&') : '')
|
||||
window.history.pushState('', document.title, url)
|
||||
}
|
||||
}
|
||||
|
||||
async login() {
|
||||
let password = this.password
|
||||
if (this.autoPassword !== null) {
|
||||
password = this.autoPassword
|
||||
}
|
||||
|
||||
try {
|
||||
await this.$accessor.login({ displayname: this.displayname, password })
|
||||
|
||||
this.autoPassword = null
|
||||
} catch (err) {
|
||||
this.$swal({
|
||||
title: this.$t('connect.error') as string,
|
||||
text: err.message,
|
||||
icon: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -3,7 +3,7 @@
|
||||
<template slot-scope="child" v-if="child.data">
|
||||
<li class="header">
|
||||
<div class="user">
|
||||
<img :src="`https://api.adorable.io/avatars/25/${child.data.member.displayname}.png`" />
|
||||
<neko-avatar class="avatar" :seed="child.data.member.displayname" :size="25" />
|
||||
<strong>{{ child.data.member.displayname }}</strong>
|
||||
</div>
|
||||
</li>
|
||||
@ -39,10 +39,10 @@
|
||||
<template v-if="admin && !child.data.member.admin">
|
||||
<li class="seperator" />
|
||||
<li>
|
||||
<span @click="kick(child.data.member)" style="color: #f04747;">{{ $t('context.kick') }}</span>
|
||||
<span @click="kick(child.data.member)" style="color: #f04747">{{ $t('context.kick') }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span @click="ban(child.data.member)" style="color: #f04747;">{{ $t('context.ban') }}</span>
|
||||
<span @click="ban(child.data.member)" style="color: #f04747">{{ $t('context.ban') }}</span>
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
@ -80,7 +80,7 @@
|
||||
align-content: center;
|
||||
padding: 5px 0;
|
||||
|
||||
img {
|
||||
.avatar {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 50%;
|
||||
@ -137,11 +137,13 @@
|
||||
|
||||
// @ts-ignore
|
||||
import { VueContext } from 'vue-context'
|
||||
import Avatar from './avatar.vue'
|
||||
|
||||
@Component({
|
||||
name: 'neko-context',
|
||||
components: {
|
||||
'vue-context': VueContext,
|
||||
'neko-avatar': Avatar,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
|
@ -4,7 +4,7 @@
|
||||
<ul class="members-list">
|
||||
<li v-if="member">
|
||||
<div :class="[{ host: member.id === host }, 'self', 'member']">
|
||||
<img :src="`https://api.adorable.io/avatars/50/${member.displayname}.png`" />
|
||||
<neko-avatar class="avatar" :seed="member.displayname" :size="50" />
|
||||
</div>
|
||||
</li>
|
||||
<template v-for="(member, index) in members">
|
||||
@ -13,11 +13,11 @@
|
||||
:key="index"
|
||||
v-tooltip="{ content: member.displayname, placement: 'bottom', offset: -15, boundariesElement: 'body' }"
|
||||
>
|
||||
<div :class="[{ host: member.id === host, admin: member.admin }, 'member']">
|
||||
<img
|
||||
:src="`https://api.adorable.io/avatars/50/${member.displayname}.png`"
|
||||
@contextmenu.stop.prevent="onContext($event, { member })"
|
||||
/>
|
||||
<div
|
||||
:class="[{ host: member.id === host, admin: member.admin }, 'member']"
|
||||
@contextmenu.stop.prevent="onContext($event, { member })"
|
||||
>
|
||||
<neko-avatar class="avatar" :seed="member.displayname" :size="50" />
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@ -130,7 +130,7 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
img {
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
@ -161,11 +161,13 @@
|
||||
import { Member } from '~/neko/types'
|
||||
|
||||
import Content from './context.vue'
|
||||
import Avatar from './avatar.vue'
|
||||
|
||||
@Component({
|
||||
name: 'neko-members',
|
||||
components: {
|
||||
'neko-context': Content,
|
||||
'neko-avatar': Avatar,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
|
@ -13,6 +13,13 @@
|
||||
v-if="admin"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<select v-model="$i18n.locale">
|
||||
<option v-for="(lang, i) in langs" :key="`Lang${i}`" :value="lang">
|
||||
{{ lang }}
|
||||
</option>
|
||||
</select>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
@ -28,10 +35,33 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-color: $background-tertiary;
|
||||
border: 1px solid $background-primary;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
height: 24px;
|
||||
vertical-align: text-bottom;
|
||||
display: inline-block;
|
||||
|
||||
option {
|
||||
font-weight: normal;
|
||||
color: $text-normal;
|
||||
background-color: $background-tertiary;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border: 1px solid $background-primary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Ref, Watch, Vue } from 'vue-property-decorator'
|
||||
import { messages } from '~/locale'
|
||||
|
||||
@Component({ name: 'neko-menu' })
|
||||
export default class extends Vue {
|
||||
@ -39,6 +69,10 @@
|
||||
return this.$accessor.user.admin
|
||||
}
|
||||
|
||||
get langs() {
|
||||
return Object.keys(messages)
|
||||
}
|
||||
|
||||
about() {
|
||||
this.$accessor.client.toggleAbout()
|
||||
}
|
||||
|
@ -39,15 +39,24 @@
|
||||
<span>{{ $t('setting.keyboard_layout') }}</span>
|
||||
<label class="select">
|
||||
<select v-model="keyboard_layout">
|
||||
<option
|
||||
v-for="(name, code) in keyboard_layouts_list"
|
||||
:key="code"
|
||||
:value="code"
|
||||
>{{ name }}</option>
|
||||
<option v-for="(name, code) in keyboard_layouts_list" :key="code" :value="code">{{ name }}</option>
|
||||
</select>
|
||||
<span />
|
||||
</label>
|
||||
</li>
|
||||
<template v-if="admin">
|
||||
<li>
|
||||
<span>{{ $t('setting.broadcast_is_active') }}</span>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="broadcast_is_active" />
|
||||
<span />
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<span>{{ $t('setting.broadcast_url') }}</span>
|
||||
<input v-model="broadcast_url" :disabled="broadcast_is_active" class="input" />
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="connected">
|
||||
<button @click.stop.prevent="logout">{{ $t('logout') }}</button>
|
||||
</li>
|
||||
@ -198,20 +207,33 @@
|
||||
|
||||
.select {
|
||||
max-width: 120px;
|
||||
text-align: right;
|
||||
|
||||
select:hover {
|
||||
border: 1px solid $background-secondary;
|
||||
}
|
||||
|
||||
select {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 4px;
|
||||
height: 30px;
|
||||
text-align: right;
|
||||
padding: 0 5px 0 10px;
|
||||
margin: 0;
|
||||
line-height: 30px;
|
||||
font-weight: bold;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
|
||||
color: black;
|
||||
background-color: $style-primary;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
background-color: $background-tertiary;
|
||||
font-weight: lighter;
|
||||
cursor: pointer;
|
||||
|
||||
option {
|
||||
font-weight: normal;
|
||||
@ -220,6 +242,30 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
display: block;
|
||||
height: 30px;
|
||||
text-align: right;
|
||||
padding: 0 10px;
|
||||
margin-left: 10px;
|
||||
line-height: 30px;
|
||||
text-overflow: ellipsis;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
background-color: $background-tertiary;
|
||||
font-weight: lighter;
|
||||
user-select: auto;
|
||||
|
||||
&::selection {
|
||||
background: $text-normal;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -230,6 +276,12 @@
|
||||
|
||||
@Component({ name: 'neko-settings' })
|
||||
export default class extends Vue {
|
||||
private broadcast_url: string = ''
|
||||
|
||||
get admin() {
|
||||
return this.$accessor.user.admin
|
||||
}
|
||||
|
||||
get connected() {
|
||||
return this.$accessor.connected
|
||||
}
|
||||
@ -282,6 +334,27 @@
|
||||
return this.$accessor.settings.keyboard_layout
|
||||
}
|
||||
|
||||
get broadcast_is_active() {
|
||||
return this.$accessor.settings.broadcast_is_active
|
||||
}
|
||||
|
||||
set broadcast_is_active(value: boolean) {
|
||||
if (value) {
|
||||
this.$accessor.settings.broadcastCreate(this.broadcast_url)
|
||||
} else {
|
||||
this.$accessor.settings.broadcastDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
get broadcast_url_remote() {
|
||||
return this.$accessor.settings.broadcast_url
|
||||
}
|
||||
|
||||
@Watch('broadcast_url_remote', { immediate: true })
|
||||
onBroadcastUrlChange() {
|
||||
this.broadcast_url = this.broadcast_url_remote
|
||||
}
|
||||
|
||||
set keyboard_layout(value: string) {
|
||||
this.$accessor.settings.setKeyboardLayout(value)
|
||||
this.$accessor.remote.changeKeyboard()
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div ref="component" class="video">
|
||||
<div ref="player" class="player">
|
||||
<div ref="container" class="player-container">
|
||||
<video ref="video" />
|
||||
<video ref="video" playsinline />
|
||||
<div class="emotes">
|
||||
<template v-for="(emote, index) in emotes">
|
||||
<neko-emote :id="index" :key="index" />
|
||||
@ -26,11 +26,31 @@
|
||||
</div>
|
||||
<div ref="aspect" class="player-aspect" />
|
||||
</div>
|
||||
<ul v-if="!fullscreen" class="video-menu">
|
||||
<ul v-if="!fullscreen" class="video-menu top">
|
||||
<li><i @click.stop.prevent="requestFullscreen" class="fas fa-expand"></i></li>
|
||||
<li v-if="admin"><i @click.stop.prevent="onResolution" class="fas fa-desktop"></i></li>
|
||||
<li class="request-control">
|
||||
<i
|
||||
:class="[hosted && !hosting ? 'disabled' : '', !hosted && !hosting ? 'faded' : '', 'fas', 'fa-keyboard']"
|
||||
@click.stop.prevent="toggleControl"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<neko-resolution ref="resolution" />
|
||||
<ul v-if="!fullscreen" class="video-menu bottom">
|
||||
<li v-if="hosting && (!clipboard_read_available || !clipboard_write_available)">
|
||||
<i @click.stop.prevent="onClipboard" class="fas fa-clipboard"></i>
|
||||
</li>
|
||||
<li>
|
||||
<i
|
||||
v-if="pip_available"
|
||||
@click.stop.prevent="requestPictureInPicture"
|
||||
v-tooltip="{ content: 'Picture-in-Picture', placement: 'left', offset: 5, boundariesElement: 'body' }"
|
||||
class="fas fa-external-link-alt"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<neko-resolution ref="resolution" v-if="admin" />
|
||||
<neko-clipboard ref="clipboard" v-if="hosting && (!clipboard_read_available || !clipboard_write_available)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -49,7 +69,14 @@
|
||||
.video-menu {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 15px;
|
||||
|
||||
&.top {
|
||||
top: 15px;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
bottom: 15px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0 0 10px 0;
|
||||
@ -64,6 +91,28 @@
|
||||
text-align: center;
|
||||
color: rgba($color: #fff, $alpha: 0.6);
|
||||
cursor: pointer;
|
||||
|
||||
&.faded {
|
||||
color: rgba($color: $text-normal, $alpha: 0.4);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: rgba($color: $style-error, $alpha: 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&.request-control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
&.request-control {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -139,7 +188,9 @@
|
||||
|
||||
import Emote from './emote.vue'
|
||||
import Resolution from './resolution.vue'
|
||||
import Clipboard from './clipboard.vue'
|
||||
|
||||
// @ts-ignore
|
||||
import GuacamoleKeyboard from '~/utils/guacamole-keyboard.ts'
|
||||
|
||||
@Component({
|
||||
@ -147,6 +198,7 @@
|
||||
components: {
|
||||
'neko-emote': Emote,
|
||||
'neko-resolution': Resolution,
|
||||
'neko-clipboard': Clipboard,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
@ -157,6 +209,7 @@
|
||||
@Ref('player') readonly _player!: HTMLElement
|
||||
@Ref('video') readonly _video!: HTMLVideoElement
|
||||
@Ref('resolution') readonly _resolution!: any
|
||||
@Ref('clipboard') readonly _clipboard!: any
|
||||
|
||||
private keyboard = GuacamoleKeyboard()
|
||||
private observer = new ResizeObserver(this.onResise.bind(this))
|
||||
@ -179,6 +232,10 @@
|
||||
return this.$accessor.remote.hosting
|
||||
}
|
||||
|
||||
get hosted() {
|
||||
return this.$accessor.remote.hosted
|
||||
}
|
||||
|
||||
get volume() {
|
||||
return this.$accessor.video.volume
|
||||
}
|
||||
@ -219,6 +276,19 @@
|
||||
return this.$accessor.settings.scroll_invert
|
||||
}
|
||||
|
||||
get pip_available() {
|
||||
//@ts-ignore
|
||||
return typeof document.createElement('video').requestPictureInPicture === 'function'
|
||||
}
|
||||
|
||||
get clipboard_read_available() {
|
||||
return 'clipboard' in navigator && typeof navigator.clipboard.readText === 'function'
|
||||
}
|
||||
|
||||
get clipboard_write_available() {
|
||||
return 'clipboard' in navigator && typeof navigator.clipboard.writeText === 'function'
|
||||
}
|
||||
|
||||
get clipboard() {
|
||||
return this.$accessor.remote.clipboard
|
||||
}
|
||||
@ -292,7 +362,7 @@
|
||||
|
||||
@Watch('clipboard')
|
||||
onClipboardChanged(clipboard: string) {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
if (this.clipboard_write_available) {
|
||||
navigator.clipboard.writeText(clipboard).catch(console.error)
|
||||
}
|
||||
}
|
||||
@ -328,13 +398,12 @@
|
||||
this.$accessor.video.setPlayable(false)
|
||||
})
|
||||
|
||||
this._video.addEventListener('error', event => {
|
||||
this._video.addEventListener('error', (event) => {
|
||||
this.$log.error(event.error)
|
||||
this.$accessor.video.setPlayable(false)
|
||||
})
|
||||
|
||||
document.addEventListener('focusin', this.onFocus.bind(this))
|
||||
document.addEventListener('focusout', this.onBlur.bind(this))
|
||||
|
||||
/* Initialize Guacamole Keyboard */
|
||||
this.keyboard.onkeydown = (key: number) => {
|
||||
@ -359,7 +428,6 @@
|
||||
this.observer.disconnect()
|
||||
this.$accessor.video.setPlayable(false)
|
||||
document.removeEventListener('focusin', this.onFocus.bind(this))
|
||||
document.removeEventListener('focusout', this.onBlur.bind(this))
|
||||
/* Guacamole Keyboard does not provide destroy functions */
|
||||
}
|
||||
|
||||
@ -374,7 +442,7 @@
|
||||
.then(() => {
|
||||
this.onResise()
|
||||
})
|
||||
.catch(err => this.$log.error)
|
||||
.catch((err) => this.$log.error)
|
||||
} catch (err) {
|
||||
this.$log.error(err)
|
||||
}
|
||||
@ -400,8 +468,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
toggleControl() {
|
||||
if (!this.playable) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$accessor.remote.toggle()
|
||||
}
|
||||
|
||||
requestFullscreen() {
|
||||
this._player.requestFullscreen()
|
||||
if (typeof this._player.requestFullscreen === 'function') {
|
||||
this._player.requestFullscreen()
|
||||
//@ts-ignore
|
||||
} else if (typeof this._player.webkitRequestFullscreen === 'function') {
|
||||
//@ts-ignore
|
||||
this._player.webkitRequestFullscreen()
|
||||
//@ts-ignore
|
||||
} else if (typeof this._player.webkitEnterFullscreen === 'function') {
|
||||
//@ts-ignore
|
||||
this._player.webkitEnterFullscreen()
|
||||
//@ts-ignore
|
||||
} else if (typeof this._player.msRequestFullScreen === 'function') {
|
||||
//@ts-ignore
|
||||
this._player.msRequestFullScreen()
|
||||
}
|
||||
|
||||
this.onResise()
|
||||
}
|
||||
|
||||
requestPictureInPicture() {
|
||||
//@ts-ignore
|
||||
this._video.requestPictureInPicture()
|
||||
this.onResise()
|
||||
}
|
||||
|
||||
@ -410,10 +507,10 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (this.hosting && navigator.clipboard && typeof navigator.clipboard.readText === 'function') {
|
||||
if (this.hosting && this.clipboard_read_available) {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then(text => {
|
||||
.then((text) => {
|
||||
if (this.clipboard !== text) {
|
||||
this.$accessor.remote.setClipboard(text)
|
||||
this.$accessor.remote.sendClipboard(text)
|
||||
@ -423,14 +520,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
onBlur() {
|
||||
if (!this.focused || !this.hosting || this.locked) {
|
||||
return
|
||||
}
|
||||
|
||||
this.keyboard.reset()
|
||||
}
|
||||
|
||||
onMousePos(e: MouseEvent) {
|
||||
const { w, h } = this.$accessor.video.resolution
|
||||
const rect = this._overlay.getBoundingClientRect()
|
||||
@ -480,16 +569,34 @@
|
||||
if (!this.hosting || this.locked) {
|
||||
return
|
||||
}
|
||||
|
||||
this.onMousePos(e)
|
||||
}
|
||||
|
||||
onMouseEnter(e: MouseEvent) {
|
||||
if (this.hosting) {
|
||||
this.$accessor.remote.syncKeyboardModifierState({
|
||||
capsLock: e.getModifierState('CapsLock'),
|
||||
numLock: e.getModifierState('NumLock'),
|
||||
scrollLock: e.getModifierState('ScrollLock'),
|
||||
})
|
||||
}
|
||||
|
||||
this._overlay.focus()
|
||||
this.onFocus()
|
||||
this.focused = true
|
||||
}
|
||||
|
||||
onMouseLeave(e: MouseEvent) {
|
||||
if (this.hosting) {
|
||||
this.$accessor.remote.setKeyboardModifierState({
|
||||
capsLock: e.getModifierState('CapsLock'),
|
||||
numLock: e.getModifierState('NumLock'),
|
||||
scrollLock: e.getModifierState('ScrollLock'),
|
||||
})
|
||||
}
|
||||
|
||||
this.keyboard.reset()
|
||||
this.focused = false
|
||||
}
|
||||
|
||||
@ -512,5 +619,9 @@
|
||||
onResolution(event: MouseEvent) {
|
||||
this._resolution.open(event)
|
||||
}
|
||||
|
||||
onClipboard(event: MouseEvent) {
|
||||
this._clipboard.open(event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
83
client/src/lib.ts
Normal file
83
client/src/lib.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { accessor as neko } from './store'
|
||||
import { PluginObject } from 'vue'
|
||||
|
||||
// Plugins
|
||||
import Logger from './plugins/log'
|
||||
import Client from './plugins/neko'
|
||||
import Axios from './plugins/axios'
|
||||
import Swal from './plugins/swal'
|
||||
import Anime from './plugins/anime'
|
||||
import { i18n } from './plugins/i18n'
|
||||
|
||||
// Components
|
||||
import Connect from '~/components/connect.vue'
|
||||
import Video from '~/components/video.vue'
|
||||
import Menu from '~/components/menu.vue'
|
||||
import Side from '~/components/side.vue'
|
||||
import Controls from '~/components/controls.vue'
|
||||
import Members from '~/components/members.vue'
|
||||
import Emotes from '~/components/emotes.vue'
|
||||
import About from '~/components/about.vue'
|
||||
import Header from '~/components/header.vue'
|
||||
import Chat from '~/components/chat.vue'
|
||||
import Clipboard from '~/components/clipboard.vue'
|
||||
import Emoji from '~/components/emoji.vue'
|
||||
import Emote from '~/components/emote.vue'
|
||||
import Context from '~/components/context.vue'
|
||||
import Markdown from '~/components/markdown'
|
||||
import Avatar from '~/components/avatar.vue'
|
||||
|
||||
// Vue
|
||||
import Vue from 'vue'
|
||||
import ToolTip from 'v-tooltip'
|
||||
|
||||
Vue.use(ToolTip)
|
||||
|
||||
const exportMixin = {
|
||||
computed: {
|
||||
$accessor() {
|
||||
return neko
|
||||
},
|
||||
$client () {
|
||||
return window.$client
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const plugini18n: PluginObject<undefined> = {
|
||||
install(Vue) {
|
||||
Vue.prototype.i18n = i18n
|
||||
Vue.prototype.$t = i18n.t.bind(i18n)
|
||||
},
|
||||
}
|
||||
|
||||
function extend (component: any) {
|
||||
return component
|
||||
.use(plugini18n)
|
||||
.use(Logger)
|
||||
.use(Axios)
|
||||
.use(Swal)
|
||||
.use(Anime)
|
||||
.use(Client)
|
||||
.extend(exportMixin)
|
||||
}
|
||||
|
||||
export const NekoConnect = extend(Connect)
|
||||
export const NekoVideo = extend(Video)
|
||||
export const NekoMenu = extend(Menu)
|
||||
export const NekoSide = extend(Side)
|
||||
export const NekoControls = extend(Controls)
|
||||
export const NekoMembers = extend(Members)
|
||||
export const NekoEmotes = extend(Emotes)
|
||||
export const NekoAbout = extend(About)
|
||||
export const NekoHeader = extend(Header)
|
||||
export const NekoChat = extend(Chat)
|
||||
export const NekoClipboard = extend(Clipboard)
|
||||
export const NekoEmoji = extend(Emoji)
|
||||
export const NekoEmote = extend(Emote)
|
||||
export const NekoMarkdown = extend(Markdown)
|
||||
export const NekoContext = extend(Context)
|
||||
export const NekoAvatar = extend(Avatar)
|
||||
|
||||
neko.initialise()
|
||||
export default neko
|
@ -10,10 +10,12 @@ export const side = {
|
||||
}
|
||||
|
||||
export const connect = {
|
||||
title: 'Please Login',
|
||||
displayname: 'Display Name',
|
||||
login_title: 'Please Login',
|
||||
invitation_title: 'You have been invited to this room',
|
||||
displayname: 'Enter your display name',
|
||||
password: 'Password',
|
||||
connect: 'Connect',
|
||||
error: 'Login error',
|
||||
}
|
||||
|
||||
export const context = {
|
||||
@ -48,10 +50,10 @@ export const controls = {
|
||||
}
|
||||
|
||||
export const room = {
|
||||
lock: 'Lock Room',
|
||||
unlock: 'Unlock Room',
|
||||
locked: 'Room Locked',
|
||||
unlocked: 'Room Unlocked',
|
||||
lock: 'Lock Room (for users)',
|
||||
unlock: 'Unlock Room (for users)',
|
||||
locked: 'Room Locked (for users)',
|
||||
unlocked: 'Room Unlocked (for users)',
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
@ -60,7 +62,9 @@ export const setting = {
|
||||
autoplay: 'Autoplay Video',
|
||||
ignore_emotes: 'Ignore Emotes',
|
||||
chat_sound: 'Play Chat Sound',
|
||||
keyboard_layout: 'Change Keyboard Layout',
|
||||
keyboard_layout: 'Keyboard Layout',
|
||||
broadcast_is_active: 'Broadcast Enabled',
|
||||
broadcast_url: 'RTMP url',
|
||||
}
|
||||
|
||||
export const connection = {
|
||||
|
97
client/src/locale/es-sp.ts
Normal file
97
client/src/locale/es-sp.ts
Normal file
@ -0,0 +1,97 @@
|
||||
export const logout = 'salir'
|
||||
export const unsupported = 'este navegador no soporta webrtc'
|
||||
export const admin_loggedin = 'Registrado como admin'
|
||||
export const you = 'Tú'
|
||||
export const send_a_message = 'Enviar un mensaje'
|
||||
|
||||
export const side = {
|
||||
chat: 'Chat',
|
||||
settings: 'Configuración',
|
||||
}
|
||||
|
||||
export const connect = {
|
||||
login_title: 'Por favor regístrate',
|
||||
invitation_title: 'Te han invitado a esta sala',
|
||||
displayname: 'Introduce tu nombre',
|
||||
password: 'Contraseña',
|
||||
connect: 'Conectar',
|
||||
error: 'Error de login',
|
||||
}
|
||||
|
||||
export const context = {
|
||||
ignore: 'Ignorar',
|
||||
unignore: 'No ignorar',
|
||||
mute: 'Silenciar',
|
||||
unmute: 'No silenciar',
|
||||
release: 'Forzar liberar los controles',
|
||||
take: 'Forzar obtener los controles',
|
||||
give: 'Dar los controles',
|
||||
kick: 'Echar',
|
||||
ban: 'Bloquear IP',
|
||||
confirm: {
|
||||
kick_title: 'Echar a {name}?',
|
||||
kick_text: 'Seguro que quiere echar a {name}?',
|
||||
ban_title: 'Bloquear a {name}?',
|
||||
ban_text: 'Seguroq ue quieres bloquear a {name}? Necesitarás reiniciar el servidor para deshacer esta acción.',
|
||||
mute_title: 'Silenciar a {name}?',
|
||||
mute_text: 'Seguro que quieres silenciar a {name}?',
|
||||
unmute_title: 'Dejar de silenciar a {name}?',
|
||||
unmute_text: 'Seguro que quieres dejar de silenciar a {name}?',
|
||||
button_yes: 'Sí',
|
||||
button_cancel: 'Cancelar',
|
||||
},
|
||||
}
|
||||
|
||||
export const controls = {
|
||||
release: 'Controles liberador',
|
||||
request: 'Controles solicitados',
|
||||
lock: 'Controles bloqueados',
|
||||
unlock: 'Controles desbloqueados',
|
||||
}
|
||||
|
||||
export const room = {
|
||||
lock: 'Bloquear sala (para usuarios)',
|
||||
unlock: 'Desbloquear sala (para usuarios)',
|
||||
locked: 'Sala bloqueada (para usuarios)',
|
||||
unlocked: 'Sala desbloqueada (para usuarios)',
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Sensibilidad del Scroll',
|
||||
scroll_invert: 'Invertir Scroll',
|
||||
autoplay: 'Auto Reproducir Video',
|
||||
ignore_emotes: 'Ignorar Emotes',
|
||||
chat_sound: 'Reproducir Sonidos Chat',
|
||||
keyboard_layout: 'Keyboard Layout',
|
||||
broadcast_is_active: 'Habilitar Broadcast',
|
||||
broadcast_url: 'RTMP url',
|
||||
}
|
||||
|
||||
export const connection = {
|
||||
logged_out: 'Has salido!',
|
||||
connected: 'Connectado correctamente',
|
||||
disconnected: 'Has sido desconectado',
|
||||
button_confirm: 'De acuerdo',
|
||||
}
|
||||
|
||||
export const notifications = {
|
||||
connected: '{name} se ha conectado',
|
||||
disconnected: '{name} se ha desconnectado',
|
||||
controls_taken: '{name} tiene los controles',
|
||||
controls_taken_force: 'controles confiscados',
|
||||
controls_taken_steal: 'cogió los controles de {name}',
|
||||
controls_released: '{name} ha liberado los controles',
|
||||
controls_released_force: 'controles liberados',
|
||||
controls_released_steal: 'controles liberados de {name}',
|
||||
controls_given: 'controles asignados a {name}',
|
||||
controls_has: '{name} tiene los controles',
|
||||
controls_has_alt: 'Pero le diré que quieres los controles',
|
||||
controls_requesting: '{name} quiere los controles',
|
||||
resolution: 'resolución cambiada a {width}x{height}@{rate}',
|
||||
banned: '{name} bloqueado',
|
||||
kicked: '{name} expulsado',
|
||||
muted: '{name} silenciado',
|
||||
unmuted: '{name} no silenciado',
|
||||
room_locked: 'bloqueó la sala',
|
||||
room_unlocked: 'desbloqueó la sala',
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import * as en from './en-us'
|
||||
import * as es from './es-sp'
|
||||
|
||||
export const messages = {
|
||||
en,
|
||||
es,
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import EventEmitter from 'eventemitter3'
|
||||
import { OPCODE } from './data'
|
||||
import { EVENT, WebSocketEvents } from './events'
|
||||
|
||||
import { WebSocketMessages, WebSocketPayloads, SignalProvidePayload } from './messages'
|
||||
import { WebSocketMessages, WebSocketPayloads, SignalProvidePayload, SignalCandidatePayload } from './messages'
|
||||
|
||||
export interface BaseEvents {
|
||||
info: (...message: any[]) => void
|
||||
@ -19,6 +19,7 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
|
||||
protected _displayname?: string
|
||||
protected _state: RTCIceConnectionState = 'disconnected'
|
||||
protected _id = ''
|
||||
protected _candidates: RTCIceCandidate[] = []
|
||||
|
||||
get id() {
|
||||
return this._id
|
||||
@ -52,18 +53,18 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
|
||||
}
|
||||
|
||||
if (displayname === '') {
|
||||
throw new Error('Must add a displayname') // TODO: Better handling
|
||||
throw new Error('Display Name cannot be empty.')
|
||||
}
|
||||
|
||||
this._displayname = displayname
|
||||
this[EVENT.CONNECTING]()
|
||||
|
||||
try {
|
||||
this._ws = new WebSocket(`${url}ws?password=${password}`)
|
||||
this._ws = new WebSocket(`${url}?password=${encodeURIComponent(password)}`)
|
||||
this.emit('debug', `connecting to ${this._ws.url}`)
|
||||
this._ws.onmessage = this.onMessage.bind(this)
|
||||
this._ws.onerror = event => this.onError.bind(this)
|
||||
this._ws.onclose = event => this.onDisconnected.bind(this, new Error('websocket closed'))
|
||||
this._ws.onerror = (event) => this.onError.bind(this)
|
||||
this._ws.onclose = (event) => this.onDisconnected.bind(this, new Error('websocket closed'))
|
||||
this._timeout = setTimeout(this.onTimeout.bind(this), 15000)
|
||||
} catch (err) {
|
||||
this.onDisconnected(err)
|
||||
@ -75,17 +76,43 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
|
||||
clearTimeout(this._timeout)
|
||||
}
|
||||
|
||||
if (this.socketOpen) {
|
||||
if (this._ws) {
|
||||
// reset all events
|
||||
this._ws.onmessage = () => {}
|
||||
this._ws.onerror = () => {}
|
||||
this._ws.onclose = () => {}
|
||||
|
||||
try {
|
||||
this._ws!.close()
|
||||
this._ws.close()
|
||||
} catch (err) {}
|
||||
|
||||
this._ws = undefined
|
||||
}
|
||||
|
||||
if (this.peerConnected) {
|
||||
if (this._channel) {
|
||||
// reset all events
|
||||
this._channel.onmessage = () => {}
|
||||
this._channel.onerror = () => {}
|
||||
this._channel.onclose = () => {}
|
||||
|
||||
try {
|
||||
this._peer!.close()
|
||||
this._channel.close()
|
||||
} catch (err) {}
|
||||
|
||||
this._channel = undefined
|
||||
}
|
||||
|
||||
if (this._peer) {
|
||||
// reset all events
|
||||
this._peer.onconnectionstatechange = () => {}
|
||||
this._peer.onsignalingstatechange = () => {}
|
||||
this._peer.oniceconnectionstatechange = () => {}
|
||||
this._peer.ontrack = () => {}
|
||||
|
||||
try {
|
||||
this._peer.close()
|
||||
} catch (err) {}
|
||||
|
||||
this._peer = undefined
|
||||
}
|
||||
|
||||
@ -179,15 +206,15 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
|
||||
})
|
||||
}
|
||||
|
||||
this._peer.onconnectionstatechange = event => {
|
||||
this._peer.onconnectionstatechange = (event) => {
|
||||
this.emit('debug', `peer connection state changed`, this._peer ? this._peer.connectionState : undefined)
|
||||
}
|
||||
|
||||
this._peer.onsignalingstatechange = event => {
|
||||
this._peer.onsignalingstatechange = (event) => {
|
||||
this.emit('debug', `peer signaling state changed`, this._peer ? this._peer.signalingState : undefined)
|
||||
}
|
||||
|
||||
this._peer.oniceconnectionstatechange = event => {
|
||||
this._peer.oniceconnectionstatechange = (event) => {
|
||||
this._state = this._peer!.iceConnectionState
|
||||
|
||||
this.emit('debug', `peer ice connection state changed: ${this._peer!.iceConnectionState}`)
|
||||
@ -220,9 +247,15 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
|
||||
this._channel.onclose = this.onDisconnected.bind(this, new Error('peer data channel closed'))
|
||||
|
||||
this._peer.setRemoteDescription({ type: 'offer', sdp })
|
||||
|
||||
for (const candidate of this._candidates) {
|
||||
this._peer.addIceCandidate(candidate)
|
||||
}
|
||||
this._candidates = []
|
||||
|
||||
this._peer
|
||||
.createAnswer()
|
||||
.then(d => {
|
||||
.then((d) => {
|
||||
this._peer!.setLocalDescription(d)
|
||||
this._ws!.send(
|
||||
JSON.stringify({
|
||||
@ -232,7 +265,7 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch(err => this.emit('error', err))
|
||||
.catch((err) => this.emit('error', err))
|
||||
}
|
||||
|
||||
private onMessage(e: MessageEvent) {
|
||||
@ -247,6 +280,17 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
|
||||
return
|
||||
}
|
||||
|
||||
if (event === EVENT.SIGNAL.CANDIDATE) {
|
||||
const { data } = payload as SignalCandidatePayload
|
||||
const candidate: RTCIceCandidate = JSON.parse(data)
|
||||
if (this._peer) {
|
||||
this._peer.addIceCandidate(candidate)
|
||||
} else {
|
||||
this._candidates.push(candidate)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (typeof this[event] === 'function') {
|
||||
// @ts-ignore
|
||||
|
@ -14,6 +14,7 @@ export const EVENT = {
|
||||
SIGNAL: {
|
||||
ANSWER: 'signal/answer',
|
||||
PROVIDE: 'signal/provide',
|
||||
CANDIDATE: 'signal/candidate',
|
||||
},
|
||||
MEMBER: {
|
||||
LIST: 'member/list',
|
||||
@ -27,7 +28,7 @@ export const EVENT = {
|
||||
REQUESTING: 'control/requesting',
|
||||
CLIPBOARD: 'control/clipboard',
|
||||
GIVE: 'control/give',
|
||||
KEYBOARD: 'control/keyboard'
|
||||
KEYBOARD: 'control/keyboard',
|
||||
},
|
||||
CHAT: {
|
||||
MESSAGE: 'chat/message',
|
||||
@ -38,6 +39,11 @@ export const EVENT = {
|
||||
RESOLUTION: 'screen/resolution',
|
||||
SET: 'screen/set',
|
||||
},
|
||||
BROADCAST: {
|
||||
STATUS: 'broadcast/status',
|
||||
CREATE: 'broadcast/create',
|
||||
DESTROY: 'broadcast/destroy',
|
||||
},
|
||||
ADMIN: {
|
||||
BAN: 'admin/ban',
|
||||
KICK: 'admin/kick',
|
||||
@ -60,6 +66,7 @@ export type WebSocketEvents =
|
||||
| SignalEvents
|
||||
| ChatEvents
|
||||
| ScreenEvents
|
||||
| BroadcastEvents
|
||||
| AdminEvents
|
||||
|
||||
export type ControlEvents =
|
||||
@ -72,10 +79,15 @@ export type ControlEvents =
|
||||
|
||||
export type SystemEvents = typeof EVENT.SYSTEM.DISCONNECT
|
||||
export type MemberEvents = typeof EVENT.MEMBER.LIST | typeof EVENT.MEMBER.CONNECTED | typeof EVENT.MEMBER.DISCONNECTED
|
||||
export type SignalEvents = typeof EVENT.SIGNAL.ANSWER | typeof EVENT.SIGNAL.PROVIDE
|
||||
export type SignalEvents = typeof EVENT.SIGNAL.ANSWER | typeof EVENT.SIGNAL.PROVIDE | typeof EVENT.SIGNAL.CANDIDATE
|
||||
export type ChatEvents = typeof EVENT.CHAT.MESSAGE | typeof EVENT.CHAT.EMOTE
|
||||
export type ScreenEvents = typeof EVENT.SCREEN.CONFIGURATIONS | typeof EVENT.SCREEN.RESOLUTION | typeof EVENT.SCREEN.SET
|
||||
|
||||
export type BroadcastEvents =
|
||||
| typeof EVENT.BROADCAST.STATUS
|
||||
| typeof EVENT.BROADCAST.CREATE
|
||||
| typeof EVENT.BROADCAST.DESTROY
|
||||
|
||||
export type AdminEvents =
|
||||
| typeof EVENT.ADMIN.BAN
|
||||
| typeof EVENT.ADMIN.KICK
|
||||
|
@ -18,6 +18,7 @@ import {
|
||||
ControlClipboardPayload,
|
||||
ScreenConfigurationsPayload,
|
||||
ScreenResolutionPayload,
|
||||
BroadcastStatusPayload,
|
||||
AdminPayload,
|
||||
AdminTargetPayload,
|
||||
} from './messages'
|
||||
@ -27,10 +28,21 @@ interface NekoEvents extends BaseEvents {}
|
||||
export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
||||
private $vue!: Vue
|
||||
private $accessor!: typeof accessor
|
||||
private url!: string
|
||||
|
||||
init(vue: Vue) {
|
||||
const url =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? `ws://${location.host.split(':')[0]}:${process.env.VUE_APP_SERVER_PORT}/ws`
|
||||
: location.protocol.replace(/^http/, 'ws') + '//' + location.host + location.pathname.replace(/\/$/, '') + '/ws'
|
||||
|
||||
this.initWithURL(vue, url)
|
||||
}
|
||||
|
||||
initWithURL(vue: Vue, url: string) {
|
||||
this.$vue = vue
|
||||
this.$accessor = vue.$accessor
|
||||
this.url = url
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
@ -42,12 +54,7 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
||||
}
|
||||
|
||||
login(password: string, displayname: string) {
|
||||
const url =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? `ws://${location.host.split(':')[0]}:${process.env.VUE_APP_SERVER_PORT}/`
|
||||
: `${/https/gi.test(location.protocol) ? 'wss' : 'ws'}://${location.host}/`
|
||||
|
||||
this.connect(url, password, displayname)
|
||||
this.connect(this.url, password, displayname)
|
||||
}
|
||||
|
||||
logout() {
|
||||
@ -83,6 +90,11 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
||||
|
||||
protected [EVENT.DISCONNECTED](reason?: Error) {
|
||||
this.cleanup()
|
||||
|
||||
if (reason && reason.message == 'kicked') {
|
||||
this.$accessor.logout()
|
||||
}
|
||||
|
||||
this.$vue.$notify({
|
||||
group: 'neko',
|
||||
type: 'error',
|
||||
@ -326,6 +338,13 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
||||
})
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// Broadcast Events
|
||||
/////////////////////////////
|
||||
protected [EVENT.BROADCAST.STATUS](payload: BroadcastStatusPayload) {
|
||||
this.$accessor.settings.broadcastStatus(payload)
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// Admin Events
|
||||
/////////////////////////////
|
||||
|
@ -15,6 +15,7 @@ export type WebSocketMessages =
|
||||
| WebSocketMessage
|
||||
| SignalProvideMessage
|
||||
| SignalAnswerMessage
|
||||
| SignalCandidateMessage
|
||||
| MemberListMessage
|
||||
| MemberConnectMessage
|
||||
| MemberDisconnectMessage
|
||||
@ -26,6 +27,7 @@ export type WebSocketMessages =
|
||||
export type WebSocketPayloads =
|
||||
| SignalProvidePayload
|
||||
| SignalAnswerPayload
|
||||
| SignalCandidatePayload
|
||||
| MemberListPayload
|
||||
| Member
|
||||
| ControlPayload
|
||||
@ -37,6 +39,8 @@ export type WebSocketPayloads =
|
||||
| ScreenResolutionPayload
|
||||
| ScreenConfigurationsPayload
|
||||
| AdminPayload
|
||||
| BroadcastStatusPayload
|
||||
| BroadcastCreatePayload
|
||||
|
||||
export interface WebSocketMessage {
|
||||
event: WebSocketEvents | string
|
||||
@ -76,6 +80,14 @@ export interface SignalAnswerPayload {
|
||||
displayname: string
|
||||
}
|
||||
|
||||
// signal/candidate
|
||||
export interface SignalCandidateMessage extends WebSocketMessage, SignalCandidatePayload {
|
||||
event: typeof EVENT.SIGNAL.CANDIDATE
|
||||
}
|
||||
export interface SignalCandidatePayload {
|
||||
data: string
|
||||
}
|
||||
|
||||
/*
|
||||
MEMBER MESSAGES/PAYLOADS
|
||||
*/
|
||||
@ -122,7 +134,10 @@ export interface ControlClipboardPayload {
|
||||
}
|
||||
|
||||
export interface ControlKeyboardPayload {
|
||||
layout: string
|
||||
layout?: string
|
||||
capsLock?: boolean
|
||||
numLock?: boolean
|
||||
scrollLock?: boolean
|
||||
}
|
||||
|
||||
/*
|
||||
@ -174,6 +189,18 @@ export interface ScreenConfigurationsPayload {
|
||||
configurations: ScreenConfigurations
|
||||
}
|
||||
|
||||
/*
|
||||
BROADCAST PAYLOADS
|
||||
*/
|
||||
export interface BroadcastCreatePayload {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface BroadcastStatusPayload {
|
||||
url: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
/*
|
||||
ADMIN PAYLOADS
|
||||
*/
|
||||
|
@ -25,10 +25,10 @@ class VueSweetalert2 {
|
||||
if (options) {
|
||||
const mixed = Swal.mixin(options)
|
||||
|
||||
return mixed.fire.apply(mixed, args)
|
||||
return mixed.fire(...args)
|
||||
}
|
||||
|
||||
return Swal.fire.apply(Swal, args)
|
||||
return Swal.fire(...args)
|
||||
}
|
||||
|
||||
let methodName: string | number | symbol
|
||||
@ -40,7 +40,7 @@ class VueSweetalert2 {
|
||||
swalFunction[methodName] = ((method) => {
|
||||
return (...args: any[]) => {
|
||||
// @ts-ignore
|
||||
return Swal[method].apply(Swal, args)
|
||||
return Swal[method](...args)
|
||||
}
|
||||
})(methodName)
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ export const actions = actionTree(
|
||||
{
|
||||
initialise() {
|
||||
$http
|
||||
.get<Emojis>('/emoji.json')
|
||||
.get<Emojis>('emoji.json')
|
||||
.then((req) => {
|
||||
for (const group of req.data.groups) {
|
||||
accessor.emoji.addGroup(group)
|
||||
|
@ -3,12 +3,17 @@ import { Member } from '~/neko/types'
|
||||
import { EVENT } from '~/neko/events'
|
||||
import { accessor } from '~/store'
|
||||
|
||||
const keyboardModifierState = (capsLock: boolean, numLock: boolean, scrollLock: boolean) =>
|
||||
Number(capsLock) + 2 * Number(numLock) + 4 * Number(scrollLock)
|
||||
|
||||
export const namespaced = true
|
||||
|
||||
export const state = () => ({
|
||||
id: '',
|
||||
clipboard: '',
|
||||
locked: false,
|
||||
|
||||
keyboardModifierState: -1,
|
||||
})
|
||||
|
||||
export const getters = getterTree(state, {
|
||||
@ -36,6 +41,10 @@ export const mutations = mutationTree(state, {
|
||||
state.clipboard = clipboard
|
||||
},
|
||||
|
||||
setKeyboardModifierState(state, { capsLock, numLock, scrollLock }) {
|
||||
state.keyboardModifierState = keyboardModifierState(capsLock, numLock, scrollLock)
|
||||
},
|
||||
|
||||
setLocked(state, locked: boolean) {
|
||||
state.locked = locked
|
||||
},
|
||||
@ -44,6 +53,7 @@ export const mutations = mutationTree(state, {
|
||||
state.id = ''
|
||||
state.clipboard = ''
|
||||
state.locked = false
|
||||
state.keyboardModifierState = -1
|
||||
},
|
||||
})
|
||||
|
||||
@ -71,7 +81,7 @@ export const actions = actionTree(
|
||||
},
|
||||
|
||||
request({ getters }) {
|
||||
if (!accessor.connected || !getters.hosting) {
|
||||
if (!accessor.connected || getters.hosting) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -79,7 +89,7 @@ export const actions = actionTree(
|
||||
},
|
||||
|
||||
release({ getters }) {
|
||||
if (!accessor.connected || getters.hosting) {
|
||||
if (!accessor.connected || !getters.hosting) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -140,6 +150,15 @@ export const actions = actionTree(
|
||||
}
|
||||
|
||||
$client.sendMessage(EVENT.CONTROL.KEYBOARD, { layout: accessor.settings.keyboard_layout })
|
||||
}
|
||||
},
|
||||
|
||||
syncKeyboardModifierState({ state, getters }, { capsLock, numLock, scrollLock }) {
|
||||
if (state.keyboardModifierState === keyboardModifierState(capsLock, numLock, scrollLock)) {
|
||||
return
|
||||
}
|
||||
|
||||
accessor.remote.setKeyboardModifierState({ capsLock, numLock, scrollLock })
|
||||
$client.sendMessage(EVENT.CONTROL.KEYBOARD, { capsLock, numLock, scrollLock })
|
||||
},
|
||||
},
|
||||
)
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { getterTree, mutationTree, actionTree } from 'typed-vuex'
|
||||
import { get, set } from '~/utils/localstorage'
|
||||
import { EVENT } from '~/neko/events'
|
||||
import { accessor } from '~/store'
|
||||
|
||||
export const namespaced = true
|
||||
@ -18,6 +19,9 @@ export const state = () => {
|
||||
keyboard_layout: get<string>('keyboard_layout', 'us'),
|
||||
|
||||
keyboard_layouts_list: {} as KeyboardLayouts,
|
||||
|
||||
broadcast_is_active: false,
|
||||
broadcast_url: '',
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,6 +61,10 @@ export const mutations = mutationTree(state, {
|
||||
setKeyboardLayoutsList(state, value: KeyboardLayouts) {
|
||||
state.keyboard_layouts_list = value
|
||||
},
|
||||
setBroadcastStatus(state, { url, isActive }) {
|
||||
state.broadcast_url = url
|
||||
state.broadcast_is_active = isActive
|
||||
},
|
||||
})
|
||||
|
||||
export const actions = actionTree(
|
||||
@ -64,12 +72,21 @@ export const actions = actionTree(
|
||||
{
|
||||
initialise() {
|
||||
$http
|
||||
.get<KeyboardLayouts>('/keyboard_layouts.json')
|
||||
.get<KeyboardLayouts>('keyboard_layouts.json')
|
||||
.then((req) => {
|
||||
accessor.settings.setKeyboardLayoutsList(req.data)
|
||||
console.log(req.data)
|
||||
})
|
||||
.catch(console.error)
|
||||
},
|
||||
|
||||
broadcastStatus({ getters }, { url, isActive }) {
|
||||
accessor.settings.setBroadcastStatus({ url, isActive })
|
||||
},
|
||||
broadcastCreate({ getters }, url: string) {
|
||||
$client.sendMessage(EVENT.BROADCAST.CREATE, { url })
|
||||
},
|
||||
broadcastDestroy({ getters }) {
|
||||
$client.sendMessage(EVENT.BROADCAST.DESTROY)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
@ -48,6 +48,11 @@ export const mutations = mutationTree(state, {
|
||||
state.id = id
|
||||
},
|
||||
addMember(state, member: Member) {
|
||||
// remove html tags
|
||||
const tmp = document.createElement('div')
|
||||
tmp.innerHTML = member.displayname
|
||||
member.displayname = tmp.textContent || tmp.innerText || ''
|
||||
|
||||
state.members = {
|
||||
...state.members,
|
||||
[member.id]: {
|
||||
|
@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
|
@ -4,41 +4,41 @@ export interface GuacamoleKeyboardInterface {
|
||||
/**
|
||||
* Fired whenever the user presses a key with the element associated
|
||||
* with this Guacamole.Keyboard in focus.
|
||||
*
|
||||
*
|
||||
* @event
|
||||
* @param {Number} keysym The keysym of the key being pressed.
|
||||
* @return {Boolean} true if the key event should be allowed through to the
|
||||
* browser, false otherwise.
|
||||
*/
|
||||
onkeydown?: (keysym: number) => boolean;
|
||||
onkeydown?: (keysym: number) => boolean
|
||||
|
||||
/**
|
||||
* Fired whenever the user releases a key with the element associated
|
||||
* with this Guacamole.Keyboard in focus.
|
||||
*
|
||||
*
|
||||
* @event
|
||||
* @param {Number} keysym The keysym of the key being released.
|
||||
*/
|
||||
onkeyup?: (keysym: number) => void;
|
||||
onkeyup?: (keysym: number) => void
|
||||
|
||||
/**
|
||||
* Marks a key as pressed, firing the keydown event if registered. Key
|
||||
* repeat for the pressed key will start after a delay if that key is
|
||||
* not a modifier. The return value of this function depends on the
|
||||
* return value of the keydown event handler, if any.
|
||||
*
|
||||
*
|
||||
* @param {Number} keysym The keysym of the key to press.
|
||||
* @return {Boolean} true if event should NOT be canceled, false otherwise.
|
||||
*/
|
||||
press: (keysym: number) => boolean;
|
||||
press: (keysym: number) => boolean
|
||||
|
||||
/**
|
||||
* Marks a key as released, firing the keyup event if registered.
|
||||
*
|
||||
*
|
||||
* @param {Number} keysym The keysym of the key to release.
|
||||
*/
|
||||
release: (keysym: number) => void;
|
||||
|
||||
release: (keysym: number) => void
|
||||
|
||||
/**
|
||||
* Presses and releases the keys necessary to type the given string of
|
||||
* text.
|
||||
@ -46,14 +46,14 @@ export interface GuacamoleKeyboardInterface {
|
||||
* @param {String} str
|
||||
* The string to type.
|
||||
*/
|
||||
type: (str: string) => void;
|
||||
type: (str: string) => void
|
||||
|
||||
/**
|
||||
* Resets the state of this keyboard, releasing all keys, and firing keyup
|
||||
* events for each released key.
|
||||
*/
|
||||
reset: () => void;
|
||||
|
||||
reset: () => void
|
||||
|
||||
/**
|
||||
* Attaches event listeners to the given Element, automatically translating
|
||||
* received key, input, and composition events into simple keydown/keyup
|
||||
@ -64,13 +64,13 @@ export interface GuacamoleKeyboardInterface {
|
||||
* The Element to attach event listeners to for the sake of handling
|
||||
* key or input events.
|
||||
*/
|
||||
listenTo: (element: Element | Document) => void;
|
||||
listenTo: (element: Element | Document) => void
|
||||
}
|
||||
|
||||
export default function(element?: Element): GuacamoleKeyboardInterface {
|
||||
var Keyboard = {};
|
||||
export default function (element?: Element): GuacamoleKeyboardInterface {
|
||||
const Keyboard = {}
|
||||
|
||||
GuacamoleKeyboard.bind(Keyboard, element)();
|
||||
GuacamoleKeyboard.bind(Keyboard, element)()
|
||||
|
||||
return Keyboard as GuacamoleKeyboardInterface;
|
||||
return Keyboard as GuacamoleKeyboardInterface
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ export function makeid(length: number) {
|
||||
let result = ''
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
const charactersLength = characters.length
|
||||
for (var i = 0; i < length; i++) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength))
|
||||
}
|
||||
return result
|
||||
|
@ -13,7 +13,7 @@ export function set<T extends string | number | boolean>(key: string, val: T) {
|
||||
}
|
||||
|
||||
export function get<T extends string | number | boolean>(key: string, def: T): T {
|
||||
let store = localStorage.getItem(key)
|
||||
const store = localStorage.getItem(key)
|
||||
if (store) {
|
||||
switch (typeof def) {
|
||||
case 'number':
|
||||
|
@ -5,12 +5,14 @@ module.exports = {
|
||||
css: {
|
||||
loaderOptions: {
|
||||
sass: {
|
||||
prependData: `
|
||||
additionalData: `
|
||||
@import "@/assets/styles/_variables.scss";
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
publicPath: './',
|
||||
assetsDir: './',
|
||||
configureWebpack: {
|
||||
resolve: {
|
||||
alias: {
|
||||
|
Reference in New Issue
Block a user