整理した
This commit is contained in:
306
src/client/app/common/views/components/autocomplete.vue
Normal file
306
src/client/app/common/views/components/autocomplete.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<div class="mk-autocomplete" @contextmenu.prevent="() => {}">
|
||||
<ol class="users" ref="suggests" v-if="users.length > 0">
|
||||
<li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1">
|
||||
<img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=32`" alt=""/>
|
||||
<span class="name">{{ user.name }}</span>
|
||||
<span class="username">@{{ getAcct(user) }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
<ol class="emojis" ref="suggests" v-if="emojis.length > 0">
|
||||
<li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1">
|
||||
<span class="emoji">{{ emoji.emoji }}</span>
|
||||
<span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span>
|
||||
<span class="alias" v-if="emoji.alias">({{ emoji.alias }})</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import * as emojilib from 'emojilib';
|
||||
import contains from '../../../common/scripts/contains';
|
||||
import getAcct from '../../../../../common/user/get-acct';
|
||||
|
||||
const lib = Object.entries(emojilib.lib).filter((x: any) => {
|
||||
return x[1].category != 'flags';
|
||||
});
|
||||
const emjdb = lib.map((x: any) => ({
|
||||
emoji: x[1].char,
|
||||
name: x[0],
|
||||
alias: null
|
||||
}));
|
||||
lib.forEach((x: any) => {
|
||||
if (x[1].keywords) {
|
||||
x[1].keywords.forEach(k => {
|
||||
emjdb.push({
|
||||
emoji: x[1].char,
|
||||
name: k,
|
||||
alias: x[0]
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
emjdb.sort((a, b) => a.name.length - b.name.length);
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['type', 'q', 'textarea', 'complete', 'close', 'x', 'y'],
|
||||
data() {
|
||||
return {
|
||||
fetching: true,
|
||||
users: [],
|
||||
emojis: [],
|
||||
select: -1,
|
||||
emojilib
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
items(): HTMLCollection {
|
||||
return (this.$refs.suggests as Element).children;
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
//#region 位置調整
|
||||
const margin = 32;
|
||||
|
||||
if (this.x + this.$el.offsetWidth > window.innerWidth - margin) {
|
||||
this.$el.style.left = (this.x - this.$el.offsetWidth) + 'px';
|
||||
this.$el.style.marginLeft = '-16px';
|
||||
} else {
|
||||
this.$el.style.left = this.x + 'px';
|
||||
this.$el.style.marginLeft = '0';
|
||||
}
|
||||
|
||||
if (this.y + this.$el.offsetHeight > window.innerHeight - margin) {
|
||||
this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px';
|
||||
this.$el.style.marginTop = '0';
|
||||
} else {
|
||||
this.$el.style.top = this.y + 'px';
|
||||
this.$el.style.marginTop = 'calc(1em + 8px)';
|
||||
}
|
||||
//#endregion
|
||||
},
|
||||
mounted() {
|
||||
this.textarea.addEventListener('keydown', this.onKeydown);
|
||||
|
||||
Array.from(document.querySelectorAll('body *')).forEach(el => {
|
||||
el.addEventListener('mousedown', this.onMousedown);
|
||||
});
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.exec();
|
||||
|
||||
this.$watch('q', () => {
|
||||
this.$nextTick(() => {
|
||||
this.exec();
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.textarea.removeEventListener('keydown', this.onKeydown);
|
||||
|
||||
Array.from(document.querySelectorAll('body *')).forEach(el => {
|
||||
el.removeEventListener('mousedown', this.onMousedown);
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
getAcct,
|
||||
exec() {
|
||||
this.select = -1;
|
||||
if (this.$refs.suggests) {
|
||||
Array.from(this.items).forEach(el => {
|
||||
el.removeAttribute('data-selected');
|
||||
});
|
||||
}
|
||||
|
||||
if (this.type == 'user') {
|
||||
const cache = sessionStorage.getItem(this.q);
|
||||
if (cache) {
|
||||
const users = JSON.parse(cache);
|
||||
this.users = users;
|
||||
this.fetching = false;
|
||||
} else {
|
||||
(this as any).api('users/search_by_username', {
|
||||
query: this.q,
|
||||
limit: 30
|
||||
}).then(users => {
|
||||
this.users = users;
|
||||
this.fetching = false;
|
||||
|
||||
// キャッシュ
|
||||
sessionStorage.setItem(this.q, JSON.stringify(users));
|
||||
});
|
||||
}
|
||||
} else if (this.type == 'emoji') {
|
||||
const matched = [];
|
||||
emjdb.some(x => {
|
||||
if (x.name.indexOf(this.q) == 0 && !x.alias && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
|
||||
return matched.length == 30;
|
||||
});
|
||||
if (matched.length < 30) {
|
||||
emjdb.some(x => {
|
||||
if (x.name.indexOf(this.q) == 0 && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
|
||||
return matched.length == 30;
|
||||
});
|
||||
}
|
||||
if (matched.length < 30) {
|
||||
emjdb.some(x => {
|
||||
if (x.name.indexOf(this.q) > -1 && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
|
||||
return matched.length == 30;
|
||||
});
|
||||
}
|
||||
this.emojis = matched;
|
||||
}
|
||||
},
|
||||
|
||||
onMousedown(e) {
|
||||
if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close();
|
||||
},
|
||||
|
||||
onKeydown(e) {
|
||||
const cancel = () => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
switch (e.which) {
|
||||
case 10: // [ENTER]
|
||||
case 13: // [ENTER]
|
||||
if (this.select !== -1) {
|
||||
cancel();
|
||||
(this.items[this.select] as any).click();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
break;
|
||||
|
||||
case 27: // [ESC]
|
||||
cancel();
|
||||
this.close();
|
||||
break;
|
||||
|
||||
case 38: // [↑]
|
||||
if (this.select !== -1) {
|
||||
cancel();
|
||||
this.selectPrev();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
break;
|
||||
|
||||
case 9: // [TAB]
|
||||
case 40: // [↓]
|
||||
cancel();
|
||||
this.selectNext();
|
||||
break;
|
||||
|
||||
default:
|
||||
e.stopPropagation();
|
||||
this.textarea.focus();
|
||||
}
|
||||
},
|
||||
|
||||
selectNext() {
|
||||
if (++this.select >= this.items.length) this.select = 0;
|
||||
this.applySelect();
|
||||
},
|
||||
|
||||
selectPrev() {
|
||||
if (--this.select < 0) this.select = this.items.length - 1;
|
||||
this.applySelect();
|
||||
},
|
||||
|
||||
applySelect() {
|
||||
Array.from(this.items).forEach(el => {
|
||||
el.removeAttribute('data-selected');
|
||||
});
|
||||
|
||||
this.items[this.select].setAttribute('data-selected', 'true');
|
||||
(this.items[this.select] as any).focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.mk-autocomplete
|
||||
position fixed
|
||||
z-index 65535
|
||||
margin-top calc(1em + 8px)
|
||||
overflow hidden
|
||||
background #fff
|
||||
border solid 1px rgba(0, 0, 0, 0.1)
|
||||
border-radius 4px
|
||||
transition top 0.1s ease, left 0.1s ease
|
||||
|
||||
> ol
|
||||
display block
|
||||
margin 0
|
||||
padding 4px 0
|
||||
max-height 190px
|
||||
max-width 500px
|
||||
overflow auto
|
||||
list-style none
|
||||
|
||||
> li
|
||||
display block
|
||||
padding 4px 12px
|
||||
white-space nowrap
|
||||
overflow hidden
|
||||
font-size 0.9em
|
||||
color rgba(0, 0, 0, 0.8)
|
||||
cursor default
|
||||
|
||||
&, *
|
||||
user-select none
|
||||
|
||||
&:hover
|
||||
&[data-selected='true']
|
||||
background $theme-color
|
||||
|
||||
&, *
|
||||
color #fff !important
|
||||
|
||||
&:active
|
||||
background darken($theme-color, 10%)
|
||||
|
||||
&, *
|
||||
color #fff !important
|
||||
|
||||
> .users > li
|
||||
|
||||
.avatar
|
||||
vertical-align middle
|
||||
min-width 28px
|
||||
min-height 28px
|
||||
max-width 28px
|
||||
max-height 28px
|
||||
margin 0 8px 0 0
|
||||
border-radius 100%
|
||||
|
||||
.name
|
||||
margin 0 8px 0 0
|
||||
color rgba(0, 0, 0, 0.8)
|
||||
|
||||
.username
|
||||
color rgba(0, 0, 0, 0.3)
|
||||
|
||||
> .emojis > li
|
||||
|
||||
.emoji
|
||||
display inline-block
|
||||
margin 0 4px 0 0
|
||||
width 24px
|
||||
|
||||
.name
|
||||
color rgba(0, 0, 0, 0.8)
|
||||
|
||||
.alias
|
||||
margin 0 0 0 8px
|
||||
color rgba(0, 0, 0, 0.3)
|
||||
|
||||
</style>
|
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="troubleshooter">
|
||||
<h1>%fa:wrench%%i18n:common.tags.mk-error.troubleshooter.title%</h1>
|
||||
<div>
|
||||
<p :data-wip="network == null">
|
||||
<template v-if="network != null">
|
||||
<template v-if="network">%fa:check%</template>
|
||||
<template v-if="!network">%fa:times%</template>
|
||||
</template>
|
||||
{{ network == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-network%' : '%i18n:common.tags.mk-error.troubleshooter.network%' }}<mk-ellipsis v-if="network == null"/>
|
||||
</p>
|
||||
<p v-if="network == true" :data-wip="internet == null">
|
||||
<template v-if="internet != null">
|
||||
<template v-if="internet">%fa:check%</template>
|
||||
<template v-if="!internet">%fa:times%</template>
|
||||
</template>
|
||||
{{ internet == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-internet%' : '%i18n:common.tags.mk-error.troubleshooter.internet%' }}<mk-ellipsis v-if="internet == null"/>
|
||||
</p>
|
||||
<p v-if="internet == true" :data-wip="server == null">
|
||||
<template v-if="server != null">
|
||||
<template v-if="server">%fa:check%</template>
|
||||
<template v-if="!server">%fa:times%</template>
|
||||
</template>
|
||||
{{ server == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-server%' : '%i18n:common.tags.mk-error.troubleshooter.server%' }}<mk-ellipsis v-if="server == null"/>
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="!end">%i18n:common.tags.mk-error.troubleshooter.finding%<mk-ellipsis/></p>
|
||||
<p v-if="network === false"><b>%fa:exclamation-triangle%%i18n:common.tags.mk-error.troubleshooter.no-network%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-network-desc%</p>
|
||||
<p v-if="internet === false"><b>%fa:exclamation-triangle%%i18n:common.tags.mk-error.troubleshooter.no-internet%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-internet-desc%</p>
|
||||
<p v-if="server === false"><b>%fa:exclamation-triangle%%i18n:common.tags.mk-error.troubleshooter.no-server%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-server-desc%</p>
|
||||
<p v-if="server === true" class="success"><b>%fa:info-circle%%i18n:common.tags.mk-error.troubleshooter.success%</b><br>%i18n:common.tags.mk-error.troubleshooter.success-desc%</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { apiUrl } from '../../../config';
|
||||
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
network: navigator.onLine,
|
||||
end: false,
|
||||
internet: null,
|
||||
server: null
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
if (!this.network) {
|
||||
this.end = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check internet connection
|
||||
fetch('https://google.com?rand=' + Math.random(), {
|
||||
mode: 'no-cors'
|
||||
}).then(() => {
|
||||
this.internet = true;
|
||||
|
||||
// Check misskey server is available
|
||||
fetch(`${apiUrl}/meta`).then(() => {
|
||||
this.end = true;
|
||||
this.server = true;
|
||||
})
|
||||
.catch(() => {
|
||||
this.end = true;
|
||||
this.server = false;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.end = true;
|
||||
this.internet = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.troubleshooter
|
||||
width 100%
|
||||
max-width 500px
|
||||
text-align left
|
||||
background #fff
|
||||
border-radius 8px
|
||||
border solid 1px #ddd
|
||||
|
||||
> h1
|
||||
margin 0
|
||||
padding 0.6em 1.2em
|
||||
font-size 1em
|
||||
color #444
|
||||
border-bottom solid 1px #eee
|
||||
|
||||
> [data-fa]
|
||||
margin-right 0.25em
|
||||
|
||||
> div
|
||||
overflow hidden
|
||||
padding 0.6em 1.2em
|
||||
|
||||
> p
|
||||
margin 0.5em 0
|
||||
font-size 0.9em
|
||||
color #444
|
||||
|
||||
&[data-wip]
|
||||
color #888
|
||||
|
||||
> [data-fa]
|
||||
margin-right 0.25em
|
||||
|
||||
&.times
|
||||
color #e03524
|
||||
|
||||
&.check
|
||||
color #84c32f
|
||||
|
||||
> p
|
||||
margin 0
|
||||
padding 0.7em 1.2em
|
||||
font-size 1em
|
||||
color #444
|
||||
border-top solid 1px #eee
|
||||
|
||||
> b
|
||||
> [data-fa]
|
||||
margin-right 0.25em
|
||||
|
||||
&.success
|
||||
> b
|
||||
color #39adad
|
||||
|
||||
&:not(.success)
|
||||
> b
|
||||
color #ad4339
|
||||
|
||||
</style>
|
106
src/client/app/common/views/components/connect-failed.vue
Normal file
106
src/client/app/common/views/components/connect-failed.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="mk-connect-failed">
|
||||
<img src="data:image/jpeg;base64,%base64:/assets/error.jpg%" alt=""/>
|
||||
<h1>%i18n:common.tags.mk-error.title%</h1>
|
||||
<p class="text">
|
||||
{{ '%i18n:common.tags.mk-error.description%'.substr(0, '%i18n:common.tags.mk-error.description%'.indexOf('{')) }}
|
||||
<a @click="reload">{{ '%i18n:common.tags.mk-error.description%'.match(/\{(.+?)\}/)[1] }}</a>
|
||||
{{ '%i18n:common.tags.mk-error.description%'.substr('%i18n:common.tags.mk-error.description%'.indexOf('}') + 1) }}
|
||||
</p>
|
||||
<button v-if="!troubleshooting" @click="troubleshooting = true">%i18n:common.tags.mk-error.troubleshoot%</button>
|
||||
<x-troubleshooter v-if="troubleshooting"/>
|
||||
<p class="thanks">%i18n:common.tags.mk-error.thanks%</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XTroubleshooter from './connect-failed.troubleshooter.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XTroubleshooter
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
troubleshooting: false
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
document.title = 'Oops!';
|
||||
document.documentElement.style.background = '#f8f8f8';
|
||||
},
|
||||
methods: {
|
||||
reload() {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.mk-connect-failed
|
||||
width 100%
|
||||
padding 32px 18px
|
||||
text-align center
|
||||
|
||||
> img
|
||||
display block
|
||||
height 200px
|
||||
margin 0 auto
|
||||
pointer-events none
|
||||
user-select none
|
||||
|
||||
> h1
|
||||
display block
|
||||
margin 1.25em auto 0.65em auto
|
||||
font-size 1.5em
|
||||
color #555
|
||||
|
||||
> .text
|
||||
display block
|
||||
margin 0 auto
|
||||
max-width 600px
|
||||
font-size 1em
|
||||
color #666
|
||||
|
||||
> button
|
||||
display block
|
||||
margin 1em auto 0 auto
|
||||
padding 8px 10px
|
||||
color $theme-color-foreground
|
||||
background $theme-color
|
||||
|
||||
&:focus
|
||||
outline solid 3px rgba($theme-color, 0.3)
|
||||
|
||||
&:hover
|
||||
background lighten($theme-color, 10%)
|
||||
|
||||
&:active
|
||||
background darken($theme-color, 10%)
|
||||
|
||||
> .troubleshooter
|
||||
margin 1em auto 0 auto
|
||||
|
||||
> .thanks
|
||||
display block
|
||||
margin 2em auto 0 auto
|
||||
padding 2em 0 0 0
|
||||
max-width 600px
|
||||
font-size 0.9em
|
||||
font-style oblique
|
||||
color #aaa
|
||||
border-top solid 1px #eee
|
||||
|
||||
@media (max-width 500px)
|
||||
padding 24px 18px
|
||||
font-size 80%
|
||||
|
||||
> img
|
||||
height 150px
|
||||
|
||||
</style>
|
||||
|
26
src/client/app/common/views/components/ellipsis.vue
Normal file
26
src/client/app/common/views/components/ellipsis.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<span class="mk-ellipsis">
|
||||
<span>.</span><span>.</span><span>.</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.mk-ellipsis
|
||||
> span
|
||||
animation ellipsis 1.4s infinite ease-in-out both
|
||||
|
||||
&:nth-child(1)
|
||||
animation-delay 0s
|
||||
|
||||
&:nth-child(2)
|
||||
animation-delay 0.16s
|
||||
|
||||
&:nth-child(3)
|
||||
animation-delay 0.32s
|
||||
|
||||
@keyframes ellipsis
|
||||
0%, 80%, 100%
|
||||
opacity 1
|
||||
40%
|
||||
opacity 0
|
||||
</style>
|
17
src/client/app/common/views/components/file-type-icon.vue
Normal file
17
src/client/app/common/views/components/file-type-icon.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<span class="mk-file-type-icon">
|
||||
<template v-if="kind == 'image'">%fa:file-image%</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
export default Vue.extend({
|
||||
props: ['type'],
|
||||
computed: {
|
||||
kind(): string {
|
||||
return this.type.split('/')[0];
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
42
src/client/app/common/views/components/forkit.vue
Normal file
42
src/client/app/common/views/components/forkit.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<a class="a" href="https://github.com/syuilo/misskey" target="_blank" title="%i18n:common.tags.mk-forkit.open-github-link%" aria-label="%i18n:common.tags.mk-forkit.open-github-link%">
|
||||
<svg width="80" height="80" viewBox="0 0 250 250" aria-hidden="aria-hidden">
|
||||
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
|
||||
<path class="octo-arm" d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor"></path>
|
||||
<path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.a
|
||||
display block
|
||||
position absolute
|
||||
top 0
|
||||
right 0
|
||||
|
||||
> svg
|
||||
display block
|
||||
//fill #151513
|
||||
//color #fff
|
||||
fill $theme-color
|
||||
color $theme-color-foreground
|
||||
|
||||
.octo-arm
|
||||
transform-origin 130px 106px
|
||||
|
||||
&:hover
|
||||
.octo-arm
|
||||
animation octocat-wave 560ms ease-in-out
|
||||
|
||||
@keyframes octocat-wave
|
||||
0%, 100%
|
||||
transform rotate(0)
|
||||
20%, 60%
|
||||
transform rotate(-25deg)
|
||||
40%, 80%
|
||||
transform rotate(10deg)
|
||||
|
||||
</style>
|
51
src/client/app/common/views/components/index.ts
Normal file
51
src/client/app/common/views/components/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
import signin from './signin.vue';
|
||||
import signup from './signup.vue';
|
||||
import forkit from './forkit.vue';
|
||||
import nav from './nav.vue';
|
||||
import postHtml from './post-html';
|
||||
import poll from './poll.vue';
|
||||
import pollEditor from './poll-editor.vue';
|
||||
import reactionIcon from './reaction-icon.vue';
|
||||
import reactionsViewer from './reactions-viewer.vue';
|
||||
import time from './time.vue';
|
||||
import timer from './timer.vue';
|
||||
import mediaList from './media-list.vue';
|
||||
import uploader from './uploader.vue';
|
||||
import specialMessage from './special-message.vue';
|
||||
import streamIndicator from './stream-indicator.vue';
|
||||
import ellipsis from './ellipsis.vue';
|
||||
import messaging from './messaging.vue';
|
||||
import messagingRoom from './messaging-room.vue';
|
||||
import urlPreview from './url-preview.vue';
|
||||
import twitterSetting from './twitter-setting.vue';
|
||||
import fileTypeIcon from './file-type-icon.vue';
|
||||
import Switch from './switch.vue';
|
||||
import Othello from './othello.vue';
|
||||
import welcomeTimeline from './welcome-timeline.vue';
|
||||
|
||||
Vue.component('mk-signin', signin);
|
||||
Vue.component('mk-signup', signup);
|
||||
Vue.component('mk-forkit', forkit);
|
||||
Vue.component('mk-nav', nav);
|
||||
Vue.component('mk-post-html', postHtml);
|
||||
Vue.component('mk-poll', poll);
|
||||
Vue.component('mk-poll-editor', pollEditor);
|
||||
Vue.component('mk-reaction-icon', reactionIcon);
|
||||
Vue.component('mk-reactions-viewer', reactionsViewer);
|
||||
Vue.component('mk-time', time);
|
||||
Vue.component('mk-timer', timer);
|
||||
Vue.component('mk-media-list', mediaList);
|
||||
Vue.component('mk-uploader', uploader);
|
||||
Vue.component('mk-special-message', specialMessage);
|
||||
Vue.component('mk-stream-indicator', streamIndicator);
|
||||
Vue.component('mk-ellipsis', ellipsis);
|
||||
Vue.component('mk-messaging', messaging);
|
||||
Vue.component('mk-messaging-room', messagingRoom);
|
||||
Vue.component('mk-url-preview', urlPreview);
|
||||
Vue.component('mk-twitter-setting', twitterSetting);
|
||||
Vue.component('mk-file-type-icon', fileTypeIcon);
|
||||
Vue.component('mk-switch', Switch);
|
||||
Vue.component('mk-othello', Othello);
|
||||
Vue.component('mk-welcome-timeline', welcomeTimeline);
|
57
src/client/app/common/views/components/media-list.vue
Normal file
57
src/client/app/common/views/components/media-list.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="mk-media-list" :data-count="mediaList.length">
|
||||
<template v-for="media in mediaList">
|
||||
<mk-media-video :video="media" :key="media.id" v-if="media.type.startsWith('video')" :inline-playable="mediaList.length === 1"/>
|
||||
<mk-media-image :image="media" :key="media.id" v-else />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['mediaList'],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.mk-media-list
|
||||
display grid
|
||||
grid-gap 4px
|
||||
height 256px
|
||||
|
||||
@media (max-width 500px)
|
||||
height 192px
|
||||
|
||||
&[data-count="1"]
|
||||
grid-template-rows 1fr
|
||||
&[data-count="2"]
|
||||
grid-template-columns 1fr 1fr
|
||||
grid-template-rows 1fr
|
||||
&[data-count="3"]
|
||||
grid-template-columns 1fr 0.5fr
|
||||
grid-template-rows 1fr 1fr
|
||||
:nth-child(1)
|
||||
grid-row 1 / 3
|
||||
:nth-child(3)
|
||||
grid-column 2 / 3
|
||||
grid-row 2/3
|
||||
&[data-count="4"]
|
||||
grid-template-columns 1fr 1fr
|
||||
grid-template-rows 1fr 1fr
|
||||
|
||||
:nth-child(1)
|
||||
grid-column 1 / 2
|
||||
grid-row 1 / 2
|
||||
:nth-child(2)
|
||||
grid-column 2 / 3
|
||||
grid-row 1 / 2
|
||||
:nth-child(3)
|
||||
grid-column 1 / 2
|
||||
grid-row 2 / 3
|
||||
:nth-child(4)
|
||||
grid-column 2 / 3
|
||||
grid-row 2 / 3
|
||||
|
||||
</style>
|
305
src/client/app/common/views/components/messaging-room.form.vue
Normal file
305
src/client/app/common/views/components/messaging-room.form.vue
Normal file
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div class="mk-messaging-form"
|
||||
@dragover.stop="onDragover"
|
||||
@drop.stop="onDrop"
|
||||
>
|
||||
<textarea
|
||||
v-model="text"
|
||||
ref="textarea"
|
||||
@keypress="onKeypress"
|
||||
@paste="onPaste"
|
||||
placeholder="%i18n:common.input-message-here%"
|
||||
v-autocomplete="'text'"
|
||||
></textarea>
|
||||
<div class="file" @click="file = null" v-if="file">{{ file.name }}</div>
|
||||
<mk-uploader ref="uploader" @uploaded="onUploaded"/>
|
||||
<button class="send" @click="send" :disabled="!canSend || sending" title="%i18n:common.send%">
|
||||
<template v-if="!sending">%fa:paper-plane%</template><template v-if="sending">%fa:spinner .spin%</template>
|
||||
</button>
|
||||
<button class="attach-from-local" @click="chooseFile" title="%i18n:common.tags.mk-messaging-form.attach-from-local%">
|
||||
%fa:upload%
|
||||
</button>
|
||||
<button class="attach-from-drive" @click="chooseFileFromDrive" title="%i18n:common.tags.mk-messaging-form.attach-from-drive%">
|
||||
%fa:R folder-open%
|
||||
</button>
|
||||
<input ref="file" type="file" @change="onChangeFile"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import * as autosize from 'autosize';
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['user'],
|
||||
data() {
|
||||
return {
|
||||
text: null,
|
||||
file: null,
|
||||
sending: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
draftId(): string {
|
||||
return this.user.id;
|
||||
},
|
||||
canSend(): boolean {
|
||||
return (this.text != null && this.text != '') || this.file != null;
|
||||
},
|
||||
room(): any {
|
||||
return this.$parent;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
text() {
|
||||
this.saveDraft();
|
||||
},
|
||||
file() {
|
||||
this.saveDraft();
|
||||
|
||||
if (this.room.isBottom()) {
|
||||
this.room.scrollToBottom();
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
autosize(this.$refs.textarea);
|
||||
|
||||
// 書きかけの投稿を復元
|
||||
const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftId];
|
||||
if (draft) {
|
||||
this.text = draft.data.text;
|
||||
this.file = draft.data.file;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onPaste(e) {
|
||||
const data = e.clipboardData;
|
||||
const items = data.items;
|
||||
|
||||
if (items.length == 1) {
|
||||
if (items[0].kind == 'file') {
|
||||
this.upload(items[0].getAsFile());
|
||||
}
|
||||
} else {
|
||||
if (items[0].kind == 'file') {
|
||||
alert('メッセージに添付できるのはひとつのファイルのみです');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onDragover(e) {
|
||||
const isFile = e.dataTransfer.items[0].kind == 'file';
|
||||
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
|
||||
if (isFile || isDriveFile) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
|
||||
}
|
||||
},
|
||||
|
||||
onDrop(e): void {
|
||||
// ファイルだったら
|
||||
if (e.dataTransfer.files.length == 1) {
|
||||
e.preventDefault();
|
||||
this.upload(e.dataTransfer.files[0]);
|
||||
return;
|
||||
} else if (e.dataTransfer.files.length > 1) {
|
||||
e.preventDefault();
|
||||
alert('メッセージに添付できるのはひとつのファイルのみです');
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = e.dataTransfer.getData('mk_drive_file');
|
||||
if (driveFile != null && driveFile != '') {
|
||||
this.file = JSON.parse(driveFile);
|
||||
e.preventDefault();
|
||||
}
|
||||
//#endregion
|
||||
},
|
||||
|
||||
onKeypress(e) {
|
||||
if ((e.which == 10 || e.which == 13) && e.ctrlKey) {
|
||||
this.send();
|
||||
}
|
||||
},
|
||||
|
||||
chooseFile() {
|
||||
(this.$refs.file as any).click();
|
||||
},
|
||||
|
||||
chooseFileFromDrive() {
|
||||
(this as any).apis.chooseDriveFile({
|
||||
multiple: false
|
||||
}).then(file => {
|
||||
this.file = file;
|
||||
});
|
||||
},
|
||||
|
||||
onChangeFile() {
|
||||
this.upload((this.$refs.file as any).files[0]);
|
||||
},
|
||||
|
||||
upload(file) {
|
||||
(this.$refs.uploader as any).upload(file);
|
||||
},
|
||||
|
||||
onUploaded(file) {
|
||||
this.file = file;
|
||||
},
|
||||
|
||||
send() {
|
||||
this.sending = true;
|
||||
(this as any).api('messaging/messages/create', {
|
||||
userId: this.user.id,
|
||||
text: this.text ? this.text : undefined,
|
||||
fileId: this.file ? this.file.id : undefined
|
||||
}).then(message => {
|
||||
this.clear();
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
}).then(() => {
|
||||
this.sending = false;
|
||||
});
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.text = '';
|
||||
this.file = null;
|
||||
this.deleteDraft();
|
||||
},
|
||||
|
||||
saveDraft() {
|
||||
const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
data[this.draftId] = {
|
||||
updatedAt: new Date(),
|
||||
data: {
|
||||
text: this.text,
|
||||
file: this.file
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('message_drafts', JSON.stringify(data));
|
||||
},
|
||||
|
||||
deleteDraft() {
|
||||
const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
delete data[this.draftId];
|
||||
|
||||
localStorage.setItem('message_drafts', JSON.stringify(data));
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.mk-messaging-form
|
||||
> textarea
|
||||
cursor auto
|
||||
display block
|
||||
width 100%
|
||||
min-width 100%
|
||||
max-width 100%
|
||||
height 64px
|
||||
margin 0
|
||||
padding 8px
|
||||
resize none
|
||||
font-size 1em
|
||||
color #000
|
||||
outline none
|
||||
border none
|
||||
border-top solid 1px #eee
|
||||
border-radius 0
|
||||
box-shadow none
|
||||
background transparent
|
||||
|
||||
> .file
|
||||
padding 8px
|
||||
color #444
|
||||
background #eee
|
||||
cursor pointer
|
||||
|
||||
> .send
|
||||
position absolute
|
||||
bottom 0
|
||||
right 0
|
||||
margin 0
|
||||
padding 10px 14px
|
||||
font-size 1em
|
||||
color #aaa
|
||||
transition color 0.1s ease
|
||||
|
||||
&:hover
|
||||
color $theme-color
|
||||
|
||||
&:active
|
||||
color darken($theme-color, 10%)
|
||||
transition color 0s ease
|
||||
|
||||
.files
|
||||
display block
|
||||
margin 0
|
||||
padding 0 8px
|
||||
list-style none
|
||||
|
||||
&:after
|
||||
content ''
|
||||
display block
|
||||
clear both
|
||||
|
||||
> li
|
||||
display block
|
||||
float left
|
||||
margin 4px
|
||||
padding 0
|
||||
width 64px
|
||||
height 64px
|
||||
background-color #eee
|
||||
background-repeat no-repeat
|
||||
background-position center center
|
||||
background-size cover
|
||||
cursor move
|
||||
|
||||
&:hover
|
||||
> .remove
|
||||
display block
|
||||
|
||||
> .remove
|
||||
display none
|
||||
position absolute
|
||||
right -6px
|
||||
top -6px
|
||||
margin 0
|
||||
padding 0
|
||||
background transparent
|
||||
outline none
|
||||
border none
|
||||
border-radius 0
|
||||
box-shadow none
|
||||
cursor pointer
|
||||
|
||||
.attach-from-local
|
||||
.attach-from-drive
|
||||
margin 0
|
||||
padding 10px 14px
|
||||
font-size 1em
|
||||
font-weight normal
|
||||
text-decoration none
|
||||
color #aaa
|
||||
transition color 0.1s ease
|
||||
|
||||
&:hover
|
||||
color $theme-color
|
||||
|
||||
&:active
|
||||
color darken($theme-color, 10%)
|
||||
transition color 0s ease
|
||||
|
||||
input[type=file]
|
||||
display none
|
||||
|
||||
</style>
|
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div class="message" :data-is-me="isMe">
|
||||
<router-link class="avatar-anchor" :to="`/@${acct}`" :title="acct" target="_blank">
|
||||
<img class="avatar" :src="`${message.user.avatarUrl}?thumbnail&size=80`" alt=""/>
|
||||
</router-link>
|
||||
<div class="content">
|
||||
<div class="balloon" :data-no-text="message.text == null">
|
||||
<p class="read" v-if="isMe && message.isRead">%i18n:common.tags.mk-messaging-message.is-read%</p>
|
||||
<button class="delete-button" v-if="isMe" title="%i18n:common.delete%">
|
||||
<img src="/assets/desktop/messaging/delete.png" alt="Delete"/>
|
||||
</button>
|
||||
<div class="content" v-if="!message.isDeleted">
|
||||
<mk-post-html class="text" v-if="message.ast" :ast="message.ast" :i="os.i"/>
|
||||
<div class="file" v-if="message.file">
|
||||
<a :href="message.file.url" target="_blank" :title="message.file.name">
|
||||
<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/>
|
||||
<p v-else>{{ message.file.name }}</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content" v-if="message.isDeleted">
|
||||
<p class="is-deleted">%i18n:common.tags.mk-messaging-message.deleted%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
|
||||
<footer>
|
||||
<mk-time :time="message.createdAt"/>
|
||||
<template v-if="message.is_edited">%fa:pencil-alt%</template>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import getAcct from '../../../../../common/user/get-acct';
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['message'],
|
||||
computed: {
|
||||
acct() {
|
||||
return getAcct(this.message.user);
|
||||
},
|
||||
isMe(): boolean {
|
||||
return this.message.userId == (this as any).os.i.id;
|
||||
},
|
||||
urls(): string[] {
|
||||
if (this.message.ast) {
|
||||
return this.message.ast
|
||||
.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
|
||||
.map(t => t.url);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.message
|
||||
$me-balloon-color = #23A7B6
|
||||
|
||||
padding 10px 12px 10px 12px
|
||||
background-color transparent
|
||||
|
||||
> .avatar-anchor
|
||||
display block
|
||||
position absolute
|
||||
top 10px
|
||||
|
||||
> .avatar
|
||||
display block
|
||||
min-width 54px
|
||||
min-height 54px
|
||||
max-width 54px
|
||||
max-height 54px
|
||||
margin 0
|
||||
border-radius 8px
|
||||
transition all 0.1s ease
|
||||
|
||||
> .content
|
||||
|
||||
> .balloon
|
||||
display block
|
||||
padding 0
|
||||
max-width calc(100% - 16px)
|
||||
min-height 38px
|
||||
border-radius 16px
|
||||
|
||||
&:before
|
||||
content ""
|
||||
pointer-events none
|
||||
display block
|
||||
position absolute
|
||||
top 12px
|
||||
|
||||
& + *
|
||||
clear both
|
||||
|
||||
&:hover
|
||||
> .delete-button
|
||||
display block
|
||||
|
||||
> .delete-button
|
||||
display none
|
||||
position absolute
|
||||
z-index 1
|
||||
top -4px
|
||||
right -4px
|
||||
margin 0
|
||||
padding 0
|
||||
cursor pointer
|
||||
outline none
|
||||
border none
|
||||
border-radius 0
|
||||
box-shadow none
|
||||
background transparent
|
||||
|
||||
> img
|
||||
vertical-align bottom
|
||||
width 16px
|
||||
height 16px
|
||||
cursor pointer
|
||||
|
||||
> .read
|
||||
user-select none
|
||||
display block
|
||||
position absolute
|
||||
z-index 1
|
||||
bottom -4px
|
||||
left -12px
|
||||
margin 0
|
||||
color rgba(0, 0, 0, 0.5)
|
||||
font-size 11px
|
||||
|
||||
> .content
|
||||
|
||||
> .is-deleted
|
||||
display block
|
||||
margin 0
|
||||
padding 0
|
||||
overflow hidden
|
||||
overflow-wrap break-word
|
||||
font-size 1em
|
||||
color rgba(0, 0, 0, 0.5)
|
||||
|
||||
> .text
|
||||
display block
|
||||
margin 0
|
||||
padding 8px 16px
|
||||
overflow hidden
|
||||
overflow-wrap break-word
|
||||
font-size 1em
|
||||
color rgba(0, 0, 0, 0.8)
|
||||
|
||||
& + .file
|
||||
> a
|
||||
border-radius 0 0 16px 16px
|
||||
|
||||
> .file
|
||||
> a
|
||||
display block
|
||||
max-width 100%
|
||||
max-height 512px
|
||||
border-radius 16px
|
||||
overflow hidden
|
||||
text-decoration none
|
||||
|
||||
&:hover
|
||||
text-decoration none
|
||||
|
||||
> p
|
||||
background #ccc
|
||||
|
||||
> *
|
||||
display block
|
||||
margin 0
|
||||
width 100%
|
||||
height 100%
|
||||
|
||||
> p
|
||||
padding 30px
|
||||
text-align center
|
||||
color #555
|
||||
background #ddd
|
||||
|
||||
> .mk-url-preview
|
||||
margin 8px 0
|
||||
|
||||
> footer
|
||||
display block
|
||||
margin 2px 0 0 0
|
||||
font-size 10px
|
||||
color rgba(0, 0, 0, 0.4)
|
||||
|
||||
> [data-fa]
|
||||
margin-left 4px
|
||||
|
||||
&:not([data-is-me])
|
||||
> .avatar-anchor
|
||||
left 12px
|
||||
|
||||
> .content
|
||||
padding-left 66px
|
||||
|
||||
> .balloon
|
||||
float left
|
||||
background #eee
|
||||
|
||||
&[data-no-text]
|
||||
background transparent
|
||||
|
||||
&:not([data-no-text]):before
|
||||
left -14px
|
||||
border-top solid 8px transparent
|
||||
border-right solid 8px #eee
|
||||
border-bottom solid 8px transparent
|
||||
border-left solid 8px transparent
|
||||
|
||||
> footer
|
||||
text-align left
|
||||
|
||||
&[data-is-me]
|
||||
> .avatar-anchor
|
||||
right 12px
|
||||
|
||||
> .content
|
||||
padding-right 66px
|
||||
|
||||
> .balloon
|
||||
float right
|
||||
background $me-balloon-color
|
||||
|
||||
&[data-no-text]
|
||||
background transparent
|
||||
|
||||
&:not([data-no-text]):before
|
||||
right -14px
|
||||
left auto
|
||||
border-top solid 8px transparent
|
||||
border-right solid 8px transparent
|
||||
border-bottom solid 8px transparent
|
||||
border-left solid 8px $me-balloon-color
|
||||
|
||||
> .content
|
||||
|
||||
> p.is-deleted
|
||||
color rgba(255, 255, 255, 0.5)
|
||||
|
||||
> .text >>>
|
||||
&, *
|
||||
color #fff !important
|
||||
|
||||
> footer
|
||||
text-align right
|
||||
|
||||
&[data-is-deleted]
|
||||
> .baloon
|
||||
opacity 0.5
|
||||
|
||||
</style>
|
377
src/client/app/common/views/components/messaging-room.vue
Normal file
377
src/client/app/common/views/components/messaging-room.vue
Normal file
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div class="mk-messaging-room"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@drop.prevent.stop="onDrop"
|
||||
>
|
||||
<div class="stream">
|
||||
<p class="init" v-if="init">%fa:spinner .spin%%i18n:common.loading%</p>
|
||||
<p class="empty" v-if="!init && messages.length == 0">%fa:info-circle%%i18n:common.tags.mk-messaging-room.empty%</p>
|
||||
<p class="no-history" v-if="!init && messages.length > 0 && !existMoreMessages">%fa:flag%%i18n:common.tags.mk-messaging-room.no-history%</p>
|
||||
<button class="more" :class="{ fetching: fetchingMoreMessages }" v-if="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages">
|
||||
<template v-if="fetchingMoreMessages">%fa:spinner .pulse .fw%</template>{{ fetchingMoreMessages ? '%i18n:common.loading%' : '%i18n:common.tags.mk-messaging-room.more%' }}
|
||||
</button>
|
||||
<template v-for="(message, i) in _messages">
|
||||
<x-message :message="message" :key="message.id"/>
|
||||
<p class="date" v-if="i != messages.length - 1 && message._date != _messages[i + 1]._date">
|
||||
<span>{{ _messages[i + 1]._datetext }}</span>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<footer>
|
||||
<div ref="notifications" class="notifications"></div>
|
||||
<x-form :user="user" ref="form"/>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { MessagingStream } from '../../scripts/streaming/messaging';
|
||||
import XMessage from './messaging-room.message.vue';
|
||||
import XForm from './messaging-room.form.vue';
|
||||
import { url } from '../../../config';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XMessage,
|
||||
XForm
|
||||
},
|
||||
|
||||
props: ['user', 'isNaked'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
init: true,
|
||||
fetchingMoreMessages: false,
|
||||
messages: [],
|
||||
existMoreMessages: false,
|
||||
connection: null
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
_messages(): any[] {
|
||||
return (this.messages as any).map(message => {
|
||||
const date = new Date(message.createdAt).getDate();
|
||||
const month = new Date(message.createdAt).getMonth() + 1;
|
||||
message._date = date;
|
||||
message._datetext = `${month}月 ${date}日`;
|
||||
return message;
|
||||
});
|
||||
},
|
||||
|
||||
form(): any {
|
||||
return this.$refs.form;
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.connection = new MessagingStream((this as any).os, (this as any).os.i, this.user.id);
|
||||
|
||||
this.connection.on('message', this.onMessage);
|
||||
this.connection.on('read', this.onRead);
|
||||
|
||||
document.addEventListener('visibilitychange', this.onVisibilitychange);
|
||||
|
||||
this.fetchMessages().then(() => {
|
||||
this.init = false;
|
||||
this.scrollToBottom();
|
||||
});
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.connection.off('message', this.onMessage);
|
||||
this.connection.off('read', this.onRead);
|
||||
this.connection.close();
|
||||
|
||||
document.removeEventListener('visibilitychange', this.onVisibilitychange);
|
||||
},
|
||||
|
||||
methods: {
|
||||
onDragover(e) {
|
||||
const isFile = e.dataTransfer.items[0].kind == 'file';
|
||||
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
|
||||
|
||||
if (isFile || isDriveFile) {
|
||||
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
|
||||
} else {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
onDrop(e): void {
|
||||
// ファイルだったら
|
||||
if (e.dataTransfer.files.length == 1) {
|
||||
this.form.upload(e.dataTransfer.files[0]);
|
||||
return;
|
||||
} else if (e.dataTransfer.files.length > 1) {
|
||||
alert('メッセージに添付できるのはひとつのファイルのみです');
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = e.dataTransfer.getData('mk_drive_file');
|
||||
if (driveFile != null && driveFile != '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
this.form.file = file;
|
||||
}
|
||||
//#endregion
|
||||
},
|
||||
|
||||
fetchMessages() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const max = this.existMoreMessages ? 20 : 10;
|
||||
|
||||
(this as any).api('messaging/messages', {
|
||||
userId: this.user.id,
|
||||
limit: max + 1,
|
||||
untilId: this.existMoreMessages ? this.messages[0].id : undefined
|
||||
}).then(messages => {
|
||||
if (messages.length == max + 1) {
|
||||
this.existMoreMessages = true;
|
||||
messages.pop();
|
||||
} else {
|
||||
this.existMoreMessages = false;
|
||||
}
|
||||
|
||||
this.messages.unshift.apply(this.messages, messages.reverse());
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
fetchMoreMessages() {
|
||||
this.fetchingMoreMessages = true;
|
||||
this.fetchMessages().then(() => {
|
||||
this.fetchingMoreMessages = false;
|
||||
});
|
||||
},
|
||||
|
||||
onMessage(message) {
|
||||
// サウンドを再生する
|
||||
if ((this as any).os.isEnableSounds) {
|
||||
const sound = new Audio(`${url}/assets/message.mp3`);
|
||||
sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1;
|
||||
sound.play();
|
||||
}
|
||||
|
||||
const isBottom = this.isBottom();
|
||||
|
||||
this.messages.push(message);
|
||||
if (message.userId != (this as any).os.i.id && !document.hidden) {
|
||||
this.connection.send({
|
||||
type: 'read',
|
||||
id: message.id
|
||||
});
|
||||
}
|
||||
|
||||
if (isBottom) {
|
||||
// Scroll to bottom
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
} else if (message.userId != (this as any).os.i.id) {
|
||||
// Notify
|
||||
this.notify('%i18n:common.tags.mk-messaging-room.new-message%');
|
||||
}
|
||||
},
|
||||
|
||||
onRead(ids) {
|
||||
if (!Array.isArray(ids)) ids = [ids];
|
||||
ids.forEach(id => {
|
||||
if (this.messages.some(x => x.id == id)) {
|
||||
const exist = this.messages.map(x => x.id).indexOf(id);
|
||||
this.messages[exist].isRead = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
isBottom() {
|
||||
const asobi = 64;
|
||||
const current = this.isNaked
|
||||
? window.scrollY + window.innerHeight
|
||||
: this.$el.scrollTop + this.$el.offsetHeight;
|
||||
const max = this.isNaked
|
||||
? document.body.offsetHeight
|
||||
: this.$el.scrollHeight;
|
||||
return current > (max - asobi);
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
if (this.isNaked) {
|
||||
window.scroll(0, document.body.offsetHeight);
|
||||
} else {
|
||||
this.$el.scrollTop = this.$el.scrollHeight;
|
||||
}
|
||||
},
|
||||
|
||||
notify(message) {
|
||||
const n = document.createElement('p') as any;
|
||||
n.innerHTML = '%fa:arrow-circle-down%' + message;
|
||||
n.onclick = () => {
|
||||
this.scrollToBottom();
|
||||
n.parentNode.removeChild(n);
|
||||
};
|
||||
(this.$refs.notifications as any).appendChild(n);
|
||||
|
||||
setTimeout(() => {
|
||||
n.style.opacity = 0;
|
||||
setTimeout(() => n.parentNode.removeChild(n), 1000);
|
||||
}, 4000);
|
||||
},
|
||||
|
||||
onVisibilitychange() {
|
||||
if (document.hidden) return;
|
||||
this.messages.forEach(message => {
|
||||
if (message.userId !== (this as any).os.i.id && !message.isRead) {
|
||||
this.connection.send({
|
||||
type: 'read',
|
||||
id: message.id
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.mk-messaging-room
|
||||
display flex
|
||||
flex 1
|
||||
flex-direction column
|
||||
height 100%
|
||||
|
||||
> .stream
|
||||
width 100%
|
||||
max-width 600px
|
||||
margin 0 auto
|
||||
flex 1
|
||||
|
||||
> .init
|
||||
width 100%
|
||||
margin 0
|
||||
padding 16px 8px 8px 8px
|
||||
text-align center
|
||||
font-size 0.8em
|
||||
color rgba(0, 0, 0, 0.4)
|
||||
|
||||
[data-fa]
|
||||
margin-right 4px
|
||||
|
||||
> .empty
|
||||
width 100%
|
||||
margin 0
|
||||
padding 16px 8px 8px 8px
|
||||
text-align center
|
||||
font-size 0.8em
|
||||
color rgba(0, 0, 0, 0.4)
|
||||
|
||||
[data-fa]
|
||||
margin-right 4px
|
||||
|
||||
> .no-history
|
||||
display block
|
||||
margin 0
|
||||
padding 16px
|
||||
text-align center
|
||||
font-size 0.8em
|
||||
color rgba(0, 0, 0, 0.4)
|
||||
|
||||
[data-fa]
|
||||
margin-right 4px
|
||||
|
||||
> .more
|
||||
display block
|
||||
margin 16px auto
|
||||
padding 0 12px
|
||||
line-height 24px
|
||||
color #fff
|
||||
background rgba(0, 0, 0, 0.3)
|
||||
border-radius 12px
|
||||
|
||||
&:hover
|
||||
background rgba(0, 0, 0, 0.4)
|
||||
|
||||
&:active
|
||||
background rgba(0, 0, 0, 0.5)
|
||||
|
||||
&.fetching
|
||||
cursor wait
|
||||
|
||||
> [data-fa]
|
||||
margin-right 4px
|
||||
|
||||
> .message
|
||||
// something
|
||||
|
||||
> .date
|
||||
display block
|
||||
margin 8px 0
|
||||
text-align center
|
||||
|
||||
&:before
|
||||
content ''
|
||||
display block
|
||||
position absolute
|
||||
height 1px
|
||||
width 90%
|
||||
top 16px
|
||||
left 0
|
||||
right 0
|
||||
margin 0 auto
|
||||
background rgba(0, 0, 0, 0.1)
|
||||
|
||||
> span
|
||||
display inline-block
|
||||
margin 0
|
||||
padding 0 16px
|
||||
//font-weight bold
|
||||
line-height 32px
|
||||
color rgba(0, 0, 0, 0.3)
|
||||
background #fff
|
||||
|
||||
> footer
|
||||
position -webkit-sticky
|
||||
position sticky
|
||||
z-index 2
|
||||
bottom 0
|
||||
width 100%
|
||||
max-width 600px
|
||||
margin 0 auto
|
||||
padding 0
|
||||
background rgba(255, 255, 255, 0.95)
|
||||
background-clip content-box
|
||||
|
||||
> .notifications
|
||||
position absolute
|
||||
top -48px
|
||||
width 100%
|
||||
padding 8px 0
|
||||
text-align center
|
||||
|
||||
&:empty
|
||||
display none
|
||||
|
||||
> p
|
||||
display inline-block
|
||||
margin 0
|
||||
padding 0 12px 0 28px
|
||||
cursor pointer
|
||||
line-height 32px
|
||||
font-size 12px
|
||||
color $theme-color-foreground
|
||||
background $theme-color
|
||||
border-radius 16px
|
||||
transition opacity 1s ease
|
||||
|
||||
> [data-fa]
|
||||
position absolute
|
||||
top 0
|
||||
left 10px
|
||||
line-height 32px
|
||||
font-size 16px
|
||||
|
||||
</style>
|
463
src/client/app/common/views/components/messaging.vue
Normal file
463
src/client/app/common/views/components/messaging.vue
Normal file
@@ -0,0 +1,463 @@
|
||||
<template>
|
||||
<div class="mk-messaging" :data-compact="compact">
|
||||
<div class="search" v-if="!compact" :style="{ top: headerTop + 'px' }">
|
||||
<div class="form">
|
||||
<label for="search-input">%fa:search%</label>
|
||||
<input v-model="q" type="search" @input="search" @keydown="onSearchKeydown" placeholder="%i18n:common.tags.mk-messaging.search-user%"/>
|
||||
</div>
|
||||
<div class="result">
|
||||
<ol class="users" v-if="result.length > 0" ref="searchResult">
|
||||
<li v-for="(user, i) in result"
|
||||
@keydown.enter="navigate(user)"
|
||||
@keydown="onSearchResultKeydown(i)"
|
||||
@click="navigate(user)"
|
||||
tabindex="-1"
|
||||
>
|
||||
<img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=32`" alt=""/>
|
||||
<span class="name">{{ user.name }}</span>
|
||||
<span class="username">@{{ getAcct(user) }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history" v-if="messages.length > 0">
|
||||
<template>
|
||||
<a v-for="message in messages"
|
||||
class="user"
|
||||
:href="`/i/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
|
||||
:data-is-me="isMe(message)"
|
||||
:data-is-read="message.isRead"
|
||||
@click.prevent="navigate(isMe(message) ? message.recipient : message.user)"
|
||||
:key="message.id"
|
||||
>
|
||||
<div>
|
||||
<img class="avatar" :src="`${isMe(message) ? message.recipient.avatarUrl : message.user.avatarUrl}?thumbnail&size=64`" alt=""/>
|
||||
<header>
|
||||
<span class="name">{{ isMe(message) ? message.recipient.name : message.user.name }}</span>
|
||||
<span class="username">@{{ getAcct(isMe(message) ? message.recipient : message.user) }}</span>
|
||||
<mk-time :time="message.createdAt"/>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p class="text"><span class="me" v-if="isMe(message)">%i18n:common.tags.mk-messaging.you%:</span>{{ message.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
<p class="no-history" v-if="!fetching && messages.length == 0">%i18n:common.tags.mk-messaging.no-history%</p>
|
||||
<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import getAcct from '../../../../../common/user/get-acct';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
headerTop: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fetching: true,
|
||||
moreFetching: false,
|
||||
messages: [],
|
||||
q: null,
|
||||
result: [],
|
||||
connection: null,
|
||||
connectionId: null
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.connection = (this as any).os.streams.messagingIndexStream.getConnection();
|
||||
this.connectionId = (this as any).os.streams.messagingIndexStream.use();
|
||||
|
||||
this.connection.on('message', this.onMessage);
|
||||
this.connection.on('read', this.onRead);
|
||||
|
||||
(this as any).api('messaging/history').then(messages => {
|
||||
this.messages = messages;
|
||||
this.fetching = false;
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.connection.off('message', this.onMessage);
|
||||
this.connection.off('read', this.onRead);
|
||||
(this as any).os.streams.messagingIndexStream.dispose(this.connectionId);
|
||||
},
|
||||
methods: {
|
||||
getAcct,
|
||||
isMe(message) {
|
||||
return message.userId == (this as any).os.i.id;
|
||||
},
|
||||
onMessage(message) {
|
||||
this.messages = this.messages.filter(m => !(
|
||||
(m.recipientId == message.recipientId && m.userId == message.userId) ||
|
||||
(m.recipientId == message.userId && m.userId == message.recipientId)));
|
||||
|
||||
this.messages.unshift(message);
|
||||
},
|
||||
onRead(ids) {
|
||||
ids.forEach(id => {
|
||||
const found = this.messages.find(m => m.id == id);
|
||||
if (found) found.isRead = true;
|
||||
});
|
||||
},
|
||||
search() {
|
||||
if (this.q == '') {
|
||||
this.result = [];
|
||||
return;
|
||||
}
|
||||
(this as any).api('users/search', {
|
||||
query: this.q,
|
||||
max: 5
|
||||
}).then(users => {
|
||||
this.result = users;
|
||||
});
|
||||
},
|
||||
navigate(user) {
|
||||
this.$emit('navigate', user);
|
||||
},
|
||||
onSearchKeydown(e) {
|
||||
switch (e.which) {
|
||||
case 9: // [TAB]
|
||||
case 40: // [↓]
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(this.$refs.searchResult as any).childNodes[0].focus();
|
||||
break;
|
||||
}
|
||||
},
|
||||
onSearchResultKeydown(i, e) {
|
||||
const list = this.$refs.searchResult as any;
|
||||
|
||||
const cancel = () => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
switch (true) {
|
||||
case e.which == 27: // [ESC]
|
||||
cancel();
|
||||
(this.$refs.search as any).focus();
|
||||
break;
|
||||
|
||||
case e.which == 9 && e.shiftKey: // [TAB] + [Shift]
|
||||
case e.which == 38: // [↑]
|
||||
cancel();
|
||||
(list.childNodes[i].previousElementSibling || list.childNodes[this.result.length - 1]).focus();
|
||||
break;
|
||||
|
||||
case e.which == 9: // [TAB]
|
||||
case e.which == 40: // [↓]
|
||||
cancel();
|
||||
(list.childNodes[i].nextElementSibling || list.childNodes[0]).focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.mk-messaging
|
||||
|
||||
&[data-compact]
|
||||
font-size 0.8em
|
||||
|
||||
> .history
|
||||
> a
|
||||
&:last-child
|
||||
border-bottom none
|
||||
|
||||
&:not([data-is-me]):not([data-is-read])
|
||||
> div
|
||||
background-image none
|
||||
border-left solid 4px #3aa2dc
|
||||
|
||||
> div
|
||||
padding 16px
|
||||
|
||||
> header
|
||||
> .mk-time
|
||||
font-size 1em
|
||||
|
||||
> .avatar
|
||||
width 42px
|
||||
height 42px
|
||||
margin 0 12px 0 0
|
||||
|
||||
> .search
|
||||
display block
|
||||
position -webkit-sticky
|
||||
position sticky
|
||||
top 0
|
||||
left 0
|
||||
z-index 1
|
||||
width 100%
|
||||
background #fff
|
||||
box-shadow 0 0px 2px rgba(0, 0, 0, 0.2)
|
||||
|
||||
> .form
|
||||
padding 8px
|
||||
background #f7f7f7
|
||||
|
||||
> label
|
||||
display block
|
||||
position absolute
|
||||
top 0
|
||||
left 8px
|
||||
z-index 1
|
||||
height 100%
|
||||
width 38px
|
||||
pointer-events none
|
||||
|
||||
> [data-fa]
|
||||
display block
|
||||
position absolute
|
||||
top 0
|
||||
right 0
|
||||
bottom 0
|
||||
left 0
|
||||
width 1em
|
||||
line-height 56px
|
||||
margin auto
|
||||
color #555
|
||||
|
||||
> input
|
||||
margin 0
|
||||
padding 0 0 0 32px
|
||||
width 100%
|
||||
font-size 1em
|
||||
line-height 38px
|
||||
color #000
|
||||
outline none
|
||||
border solid 1px #eee
|
||||
border-radius 5px
|
||||
box-shadow none
|
||||
transition color 0.5s ease, border 0.5s ease
|
||||
|
||||
&:hover
|
||||
border solid 1px #ddd
|
||||
transition border 0.2s ease
|
||||
|
||||
&:focus
|
||||
color darken($theme-color, 20%)
|
||||
border solid 1px $theme-color
|
||||
transition color 0, border 0
|
||||
|
||||
> .result
|
||||
display block
|
||||
top 0
|
||||
left 0
|
||||
z-index 2
|
||||
width 100%
|
||||
margin 0
|
||||
padding 0
|
||||
background #fff
|
||||
|
||||
> .users
|
||||
margin 0
|
||||
padding 0
|
||||
list-style none
|
||||
|
||||
> li
|
||||
display inline-block
|
||||
z-index 1
|
||||
width 100%
|
||||
padding 8px 32px
|
||||
vertical-align top
|
||||
white-space nowrap
|
||||
overflow hidden
|
||||
color rgba(0, 0, 0, 0.8)
|
||||
text-decoration none
|
||||
transition none
|
||||
cursor pointer
|
||||
|
||||
&:hover
|
||||
&:focus
|
||||
color #fff
|
||||
background $theme-color
|
||||
|
||||
.name
|
||||
color #fff
|
||||
|
||||
.username
|
||||
color #fff
|
||||
|
||||
&:active
|
||||
color #fff
|
||||
background darken($theme-color, 10%)
|
||||
|
||||
.name
|
||||
color #fff
|
||||
|
||||
.username
|
||||
color #fff
|
||||
|
||||
.avatar
|
||||
vertical-align middle
|
||||
min-width 32px
|
||||
min-height 32px
|
||||
max-width 32px
|
||||
max-height 32px
|
||||
margin 0 8px 0 0
|
||||
border-radius 6px
|
||||
|
||||
.name
|
||||
margin 0 8px 0 0
|
||||
/*font-weight bold*/
|
||||
font-weight normal
|
||||
color rgba(0, 0, 0, 0.8)
|
||||
|
||||
.username
|
||||
font-weight normal
|
||||
color rgba(0, 0, 0, 0.3)
|
||||
|
||||
> .history
|
||||
|
||||
> a
|
||||
display block
|
||||
text-decoration none
|
||||
background #fff
|
||||
border-bottom solid 1px #eee
|
||||
|
||||
*
|
||||
pointer-events none
|
||||
user-select none
|
||||
|
||||
&:hover
|
||||
background #fafafa
|
||||
|
||||
> .avatar
|
||||
filter saturate(200%)
|
||||
|
||||
&:active
|
||||
background #eee
|
||||
|
||||
&[data-is-read]
|
||||
&[data-is-me]
|
||||
opacity 0.8
|
||||
|
||||
&:not([data-is-me]):not([data-is-read])
|
||||
> div
|
||||
background-image url("/assets/unread.svg")
|
||||
background-repeat no-repeat
|
||||
background-position 0 center
|
||||
|
||||
&:after
|
||||
content ""
|
||||
display block
|
||||
clear both
|
||||
|
||||
> div
|
||||
max-width 500px
|
||||
margin 0 auto
|
||||
padding 20px 30px
|
||||
|
||||
&:after
|
||||
content ""
|
||||
display block
|
||||
clear both
|
||||
|
||||
> header
|
||||
display flex
|
||||
align-items center
|
||||
margin-bottom 2px
|
||||
white-space nowrap
|
||||
overflow hidden
|
||||
|
||||
> .name
|
||||
margin 0
|
||||
padding 0
|
||||
overflow hidden
|
||||
text-overflow ellipsis
|
||||
font-size 1em
|
||||
color rgba(0, 0, 0, 0.9)
|
||||
font-weight bold
|
||||
transition all 0.1s ease
|
||||
|
||||
> .username
|
||||
margin 0 8px
|
||||
color rgba(0, 0, 0, 0.5)
|
||||
|
||||
> .mk-time
|
||||
margin 0 0 0 auto
|
||||
color rgba(0, 0, 0, 0.5)
|
||||
font-size 80%
|
||||
|
||||
> .avatar
|
||||
float left
|
||||
width 54px
|
||||
height 54px
|
||||
margin 0 16px 0 0
|
||||
border-radius 8px
|
||||
transition all 0.1s ease
|
||||
|
||||
> .body
|
||||
|
||||
> .text
|
||||
display block
|
||||
margin 0 0 0 0
|
||||
padding 0
|
||||
overflow hidden
|
||||
overflow-wrap break-word
|
||||
font-size 1.1em
|
||||
color rgba(0, 0, 0, 0.8)
|
||||
|
||||
.me
|
||||
color rgba(0, 0, 0, 0.4)
|
||||
|
||||
> .image
|
||||
display block
|
||||
max-width 100%
|
||||
max-height 512px
|
||||
|
||||
> .no-history
|
||||
margin 0
|
||||
padding 2em 1em
|
||||
text-align center
|
||||
color #999
|
||||
font-weight 500
|
||||
|
||||
> .fetching
|
||||
margin 0
|
||||
padding 16px
|
||||
text-align center
|
||||
color #aaa
|
||||
|
||||
> [data-fa]
|
||||
margin-right 4px
|
||||
|
||||
// TODO: element base media query
|
||||
@media (max-width 400px)
|
||||
> .search
|
||||
> .result
|
||||
> .users
|
||||
> li
|
||||
padding 8px 16px
|
||||
|
||||
> .history
|
||||
> a
|
||||
&:not([data-is-me]):not([data-is-read])
|
||||
> div
|
||||
background-image none
|
||||
border-left solid 4px #3aa2dc
|
||||
|
||||
> div
|
||||
padding 16px
|
||||
font-size 14px
|
||||
|
||||
> .avatar
|
||||
margin 0 12px 0 0
|
||||
|
||||
</style>
|
41
src/client/app/common/views/components/nav.vue
Normal file
41
src/client/app/common/views/components/nav.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<span class="mk-nav">
|
||||
<a :href="aboutUrl">%i18n:common.tags.mk-nav-links.about%</a>
|
||||
<i>・</i>
|
||||
<a :href="statsUrl">%i18n:common.tags.mk-nav-links.stats%</a>
|
||||
<i>・</i>
|
||||
<a :href="statusUrl">%i18n:common.tags.mk-nav-links.status%</a>
|
||||
<i>・</i>
|
||||
<a href="http://zawazawa.jp/misskey/">%i18n:common.tags.mk-nav-links.wiki%</a>
|
||||
<i>・</i>
|
||||
<a href="https://github.com/syuilo/misskey/blob/master/DONORS.md">%i18n:common.tags.mk-nav-links.donors%</a>
|
||||
<i>・</i>
|
||||
<a href="https://github.com/syuilo/misskey">%i18n:common.tags.mk-nav-links.repository%</a>
|
||||
<i>・</i>
|
||||
<a :href="devUrl">%i18n:common.tags.mk-nav-links.develop%</a>
|
||||
<i>・</i>
|
||||
<a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on %fa:B twitter%</a>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { docsUrl, statsUrl, statusUrl, devUrl, lang } from '../../../config';
|
||||
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
aboutUrl: `${docsUrl}/${lang}/about`,
|
||||
statsUrl,
|
||||
statusUrl,
|
||||
devUrl
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.mk-nav
|
||||
a
|
||||
color inherit
|
||||
</style>
|
324
src/client/app/common/views/components/othello.game.vue
Normal file
324
src/client/app/common/views/components/othello.game.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div class="root">
|
||||
<header><b>{{ blackUser.name }}</b>(黒) vs <b>{{ whiteUser.name }}</b>(白)</header>
|
||||
|
||||
<div style="overflow: hidden">
|
||||
<p class="turn" v-if="!iAmPlayer && !game.isEnded">{{ turnUser.name }}のターンです<mk-ellipsis/></p>
|
||||
<p class="turn" v-if="logPos != logs.length">{{ turnUser.name }}のターン</p>
|
||||
<p class="turn1" v-if="iAmPlayer && !game.isEnded && !isMyTurn">相手のターンです<mk-ellipsis/></p>
|
||||
<p class="turn2" v-if="iAmPlayer && !game.isEnded && isMyTurn" v-animate-css="{ classes: 'tada', iteration: 'infinite' }">あなたのターンです</p>
|
||||
<p class="result" v-if="game.isEnded && logPos == logs.length">
|
||||
<template v-if="game.winner"><b>{{ game.winner.name }}</b>の勝ち{{ game.settings.isLlotheo ? ' (ロセオ)' : '' }}</template>
|
||||
<template v-else>引き分け</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="board" :style="{ 'grid-template-rows': `repeat(${ game.settings.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.settings.map[0].length }, 1fr)` }">
|
||||
<div v-for="(stone, i) in o.board"
|
||||
:class="{ empty: stone == null, none: o.map[i] == 'null', isEnded: game.isEnded, myTurn: !game.isEnded && isMyTurn, can: turnUser ? o.canPut(turnUser.id == blackUser.id, i) : null, prev: o.prevPos == i }"
|
||||
@click="set(i)"
|
||||
:title="'[' + (o.transformPosToXy(i)[0] + 1) + ', ' + (o.transformPosToXy(i)[1] + 1) + '] (' + i + ')'"
|
||||
>
|
||||
<img v-if="stone === true" :src="`${blackUser.avatarUrl}?thumbnail&size=128`" alt="">
|
||||
<img v-if="stone === false" :src="`${whiteUser.avatarUrl}?thumbnail&size=128`" alt="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="status"><b>{{ logPos }}ターン目</b> 黒:{{ o.blackCount }} 白:{{ o.whiteCount }} 合計:{{ o.blackCount + o.whiteCount }}</p>
|
||||
|
||||
<div class="player" v-if="game.isEnded">
|
||||
<el-button-group>
|
||||
<el-button type="primary" @click="logPos = 0" :disabled="logPos == 0">%fa:angle-double-left%</el-button>
|
||||
<el-button type="primary" @click="logPos--" :disabled="logPos == 0">%fa:angle-left%</el-button>
|
||||
</el-button-group>
|
||||
<span>{{ logPos }} / {{ logs.length }}</span>
|
||||
<el-button-group>
|
||||
<el-button type="primary" @click="logPos++" :disabled="logPos == logs.length">%fa:angle-right%</el-button>
|
||||
<el-button type="primary" @click="logPos = logs.length" :disabled="logPos == logs.length">%fa:angle-double-right%</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import * as CRC32 from 'crc-32';
|
||||
import Othello, { Color } from '../../../../../common/othello/core';
|
||||
import { url } from '../../../config';
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['initGame', 'connection'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
game: null,
|
||||
o: null as Othello,
|
||||
logs: [],
|
||||
logPos: 0,
|
||||
pollingClock: null
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
iAmPlayer(): boolean {
|
||||
if (!(this as any).os.isSignedIn) return false;
|
||||
return this.game.user1Id == (this as any).os.i.id || this.game.user2Id == (this as any).os.i.id;
|
||||
},
|
||||
myColor(): Color {
|
||||
if (!this.iAmPlayer) return null;
|
||||
if (this.game.user1Id == (this as any).os.i.id && this.game.black == 1) return true;
|
||||
if (this.game.user2Id == (this as any).os.i.id && this.game.black == 2) return true;
|
||||
return false;
|
||||
},
|
||||
opColor(): Color {
|
||||
if (!this.iAmPlayer) return null;
|
||||
return this.myColor === true ? false : true;
|
||||
},
|
||||
blackUser(): any {
|
||||
return this.game.black == 1 ? this.game.user1 : this.game.user2;
|
||||
},
|
||||
whiteUser(): any {
|
||||
return this.game.black == 1 ? this.game.user2 : this.game.user1;
|
||||
},
|
||||
turnUser(): any {
|
||||
if (this.o.turn === true) {
|
||||
return this.game.black == 1 ? this.game.user1 : this.game.user2;
|
||||
} else if (this.o.turn === false) {
|
||||
return this.game.black == 1 ? this.game.user2 : this.game.user1;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
isMyTurn(): boolean {
|
||||
if (this.turnUser == null) return null;
|
||||
return this.turnUser.id == (this as any).os.i.id;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
logPos(v) {
|
||||
if (!this.game.isEnded) return;
|
||||
this.o = new Othello(this.game.settings.map, {
|
||||
isLlotheo: this.game.settings.isLlotheo,
|
||||
canPutEverywhere: this.game.settings.canPutEverywhere,
|
||||
loopedBoard: this.game.settings.loopedBoard
|
||||
});
|
||||
this.logs.forEach((log, i) => {
|
||||
if (i < v) {
|
||||
this.o.put(log.color, log.pos);
|
||||
}
|
||||
});
|
||||
this.$forceUpdate();
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.game = this.initGame;
|
||||
|
||||
this.o = new Othello(this.game.settings.map, {
|
||||
isLlotheo: this.game.settings.isLlotheo,
|
||||
canPutEverywhere: this.game.settings.canPutEverywhere,
|
||||
loopedBoard: this.game.settings.loopedBoard
|
||||
});
|
||||
|
||||
this.game.logs.forEach(log => {
|
||||
this.o.put(log.color, log.pos);
|
||||
});
|
||||
|
||||
this.logs = this.game.logs;
|
||||
this.logPos = this.logs.length;
|
||||
|
||||
// 通信を取りこぼしてもいいように定期的にポーリングさせる
|
||||
if (this.game.isStarted && !this.game.isEnded) {
|
||||
this.pollingClock = setInterval(() => {
|
||||
const crc32 = CRC32.str(this.logs.map(x => x.pos.toString()).join(''));
|
||||
this.connection.send({
|
||||
type: 'check',
|
||||
crc32
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.connection.on('set', this.onSet);
|
||||
this.connection.on('rescue', this.onRescue);
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.connection.off('set', this.onSet);
|
||||
this.connection.off('rescue', this.onRescue);
|
||||
|
||||
clearInterval(this.pollingClock);
|
||||
},
|
||||
|
||||
methods: {
|
||||
set(pos) {
|
||||
if (this.game.isEnded) return;
|
||||
if (!this.iAmPlayer) return;
|
||||
if (!this.isMyTurn) return;
|
||||
if (!this.o.canPut(this.myColor, pos)) return;
|
||||
|
||||
this.o.put(this.myColor, pos);
|
||||
|
||||
// サウンドを再生する
|
||||
if ((this as any).os.isEnableSounds) {
|
||||
const sound = new Audio(`${url}/assets/othello-put-me.mp3`);
|
||||
sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1;
|
||||
sound.play();
|
||||
}
|
||||
|
||||
this.connection.send({
|
||||
type: 'set',
|
||||
pos
|
||||
});
|
||||
|
||||
this.checkEnd();
|
||||
|
||||
this.$forceUpdate();
|
||||
},
|
||||
|
||||
onSet(x) {
|
||||
this.logs.push(x);
|
||||
this.logPos++;
|
||||
this.o.put(x.color, x.pos);
|
||||
this.checkEnd();
|
||||
this.$forceUpdate();
|
||||
|
||||
// サウンドを再生する
|
||||
if ((this as any).os.isEnableSounds && x.color != this.myColor) {
|
||||
const sound = new Audio(`${url}/assets/othello-put-you.mp3`);
|
||||
sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1;
|
||||
sound.play();
|
||||
}
|
||||
},
|
||||
|
||||
checkEnd() {
|
||||
this.game.isEnded = this.o.isEnded;
|
||||
if (this.game.isEnded) {
|
||||
if (this.o.winner === true) {
|
||||
this.game.winnerId = this.game.black == 1 ? this.game.user1Id : this.game.user2Id;
|
||||
this.game.winner = this.game.black == 1 ? this.game.user1 : this.game.user2;
|
||||
} else if (this.o.winner === false) {
|
||||
this.game.winnerId = this.game.black == 1 ? this.game.user2Id : this.game.user1Id;
|
||||
this.game.winner = this.game.black == 1 ? this.game.user2 : this.game.user1;
|
||||
} else {
|
||||
this.game.winnerId = null;
|
||||
this.game.winner = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 正しいゲーム情報が送られてきたとき
|
||||
onRescue(game) {
|
||||
this.game = game;
|
||||
|
||||
this.o = new Othello(this.game.settings.map, {
|
||||
isLlotheo: this.game.settings.isLlotheo,
|
||||
canPutEverywhere: this.game.settings.canPutEverywhere,
|
||||
loopedBoard: this.game.settings.loopedBoard
|
||||
});
|
||||
|
||||
this.game.logs.forEach(log => {
|
||||
this.o.put(log.color, log.pos, true);
|
||||
});
|
||||
|
||||
this.logs = this.game.logs;
|
||||
this.logPos = this.logs.length;
|
||||
|
||||
this.checkEnd();
|
||||
this.$forceUpdate();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.root
|
||||
text-align center
|
||||
|
||||
> header
|
||||
padding 8px
|
||||
border-bottom dashed 1px #c4cdd4
|
||||
|
||||
> .board
|
||||
display grid
|
||||
grid-gap 4px
|
||||
width 350px
|
||||
height 350px
|
||||
margin 0 auto
|
||||
|
||||
> div
|
||||
background transparent
|
||||
border-radius 6px
|
||||
overflow hidden
|
||||
|
||||
*
|
||||
pointer-events none
|
||||
user-select none
|
||||
|
||||
&.empty
|
||||
border solid 2px #eee
|
||||
|
||||
&.empty.can
|
||||
background #eee
|
||||
|
||||
&.empty.myTurn
|
||||
border-color #ddd
|
||||
|
||||
&.can
|
||||
background #eee
|
||||
cursor pointer
|
||||
|
||||
&:hover
|
||||
border-color darken($theme-color, 10%)
|
||||
background $theme-color
|
||||
|
||||
&:active
|
||||
background darken($theme-color, 10%)
|
||||
|
||||
&.prev
|
||||
box-shadow 0 0 0 4px rgba($theme-color, 0.7)
|
||||
|
||||
&.isEnded
|
||||
border-color #ddd
|
||||
|
||||
&.none
|
||||
border-color transparent !important
|
||||
|
||||
> img
|
||||
display block
|
||||
width 100%
|
||||
height 100%
|
||||
|
||||
> .graph
|
||||
display grid
|
||||
grid-template-columns repeat(61, 1fr)
|
||||
width 300px
|
||||
height 38px
|
||||
margin 0 auto 16px auto
|
||||
|
||||
> div
|
||||
&:not(:empty)
|
||||
background #ccc
|
||||
|
||||
> div:first-child
|
||||
background #333
|
||||
|
||||
> div:last-child
|
||||
background #ccc
|
||||
|
||||
> .status
|
||||
margin 0
|
||||
padding 16px 0
|
||||
|
||||
> .player
|
||||
padding-bottom 32px
|
||||
|
||||
> span
|
||||
display inline-block
|
||||
margin 0 8px
|
||||
min-width 70px
|
||||
</style>
|
42
src/client/app/common/views/components/othello.gameroom.vue
Normal file
42
src/client/app/common/views/components/othello.gameroom.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div>
|
||||
<x-room v-if="!g.isStarted" :game="g" :connection="connection"/>
|
||||
<x-game v-else :init-game="g" :connection="connection"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XGame from './othello.game.vue';
|
||||
import XRoom from './othello.room.vue';
|
||||
import { OthelloGameStream } from '../../scripts/streaming/othello-game';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XGame,
|
||||
XRoom
|
||||
},
|
||||
props: ['game'],
|
||||
data() {
|
||||
return {
|
||||
connection: null,
|
||||
g: null
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.g = this.game;
|
||||
this.connection = new OthelloGameStream((this as any).os, (this as any).os.i, this.game);
|
||||
this.connection.on('started', this.onStarted);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.connection.off('started', this.onStarted);
|
||||
this.connection.close();
|
||||
},
|
||||
methods: {
|
||||
onStarted(game) {
|
||||
Object.assign(this.g, game);
|
||||
this.$forceUpdate();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
297
src/client/app/common/views/components/othello.room.vue
Normal file
297
src/client/app/common/views/components/othello.room.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div class="root">
|
||||
<header><b>{{ game.user1.name }}</b> vs <b>{{ game.user2.name }}</b></header>
|
||||
|
||||
<div>
|
||||
<p>ゲームの設定</p>
|
||||
|
||||
<el-card class="map">
|
||||
<div slot="header">
|
||||
<el-select :class="$style.mapSelect" v-model="mapName" placeholder="マップを選択" @change="onMapChange">
|
||||
<el-option label="ランダム" :value="null"/>
|
||||
<el-option-group v-for="c in mapCategories" :key="c" :label="c">
|
||||
<el-option v-for="m in maps" v-if="m.category == c" :key="m.name" :label="m.name" :value="m.name">
|
||||
<span style="float: left">{{ m.name }}</span>
|
||||
<span style="float: right; color: #8492a6; font-size: 13px" v-if="m.author">(by <i>{{ m.author }}</i>)</span>
|
||||
</el-option>
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</div>
|
||||
<div :class="$style.board" v-if="game.settings.map != null" :style="{ 'grid-template-rows': `repeat(${ game.settings.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.settings.map[0].length }, 1fr)` }">
|
||||
<div v-for="(x, i) in game.settings.map.join('')"
|
||||
:data-none="x == ' '"
|
||||
@click="onPixelClick(i, x)"
|
||||
>
|
||||
<template v-if="x == 'b'">%fa:circle%</template>
|
||||
<template v-if="x == 'w'">%fa:circle R%</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="bw">
|
||||
<div slot="header">
|
||||
<span>先手/後手</span>
|
||||
</div>
|
||||
<el-radio v-model="game.settings.bw" label="random" @change="updateSettings">ランダム</el-radio>
|
||||
<el-radio v-model="game.settings.bw" :label="1" @change="updateSettings">{{ game.user1.name }}が黒</el-radio>
|
||||
<el-radio v-model="game.settings.bw" :label="2" @change="updateSettings">{{ game.user2.name }}が黒</el-radio>
|
||||
</el-card>
|
||||
|
||||
<el-card class="rules">
|
||||
<div slot="header">
|
||||
<span>ルール</span>
|
||||
</div>
|
||||
<mk-switch v-model="game.settings.isLlotheo" @change="updateSettings" text="石の少ない方が勝ち(ロセオ)"/>
|
||||
<mk-switch v-model="game.settings.loopedBoard" @change="updateSettings" text="ループマップ"/>
|
||||
<mk-switch v-model="game.settings.canPutEverywhere" @change="updateSettings" text="どこでも置けるモード"/>
|
||||
</el-card>
|
||||
|
||||
<el-card class="bot-form" v-if="form">
|
||||
<div slot="header">
|
||||
<span>Botの設定</span>
|
||||
</div>
|
||||
<el-alert v-for="message in messages"
|
||||
:title="message.text"
|
||||
:type="message.type"
|
||||
:key="message.id"
|
||||
/>
|
||||
<template v-for="item in form">
|
||||
<mk-switch v-if="item.type == 'button'" v-model="item.value" :key="item.id" :text="item.label" @change="onChangeForm($event, item)">{{ item.desc || '' }}</mk-switch>
|
||||
|
||||
<el-card v-if="item.type == 'radio'" :key="item.id">
|
||||
<div slot="header">
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<el-radio v-for="(r, i) in item.items" :key="item.id + ':' + i" v-model="item.value" :label="r.value" @change="onChangeForm($event, item)">{{ r.label }}</el-radio>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="item.type == 'textbox'" :key="item.id">
|
||||
<div slot="header">
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<el-input v-model="item.value" @change="onChangeForm($event, item)"/>
|
||||
</el-card>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p class="status">
|
||||
<template v-if="isAccepted && isOpAccepted">ゲームは数秒後に開始されます<mk-ellipsis/></template>
|
||||
<template v-if="isAccepted && !isOpAccepted">相手の準備が完了するのを待っています<mk-ellipsis/></template>
|
||||
<template v-if="!isAccepted && isOpAccepted">あなたの準備が完了するのを待っています</template>
|
||||
<template v-if="!isAccepted && !isOpAccepted">準備中<mk-ellipsis/></template>
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<el-button @click="exit">キャンセル</el-button>
|
||||
<el-button type="primary" @click="accept" v-if="!isAccepted">準備完了</el-button>
|
||||
<el-button type="primary" @click="cancel" v-if="isAccepted">準備続行</el-button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import * as maps from '../../../../../common/othello/maps';
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['game', 'connection'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
o: null,
|
||||
isLlotheo: false,
|
||||
mapName: maps.eighteight.name,
|
||||
maps: maps,
|
||||
form: null,
|
||||
messages: []
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
mapCategories(): string[] {
|
||||
const categories = Object.entries(maps).map(x => x[1].category);
|
||||
return categories.filter((item, pos) => categories.indexOf(item) == pos);
|
||||
},
|
||||
isAccepted(): boolean {
|
||||
if (this.game.user1Id == (this as any).os.i.id && this.game.user1Accepted) return true;
|
||||
if (this.game.user2Id == (this as any).os.i.id && this.game.user2Accepted) return true;
|
||||
return false;
|
||||
},
|
||||
isOpAccepted(): boolean {
|
||||
if (this.game.user1Id != (this as any).os.i.id && this.game.user1Accepted) return true;
|
||||
if (this.game.user2Id != (this as any).os.i.id && this.game.user2Accepted) return true;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.connection.on('change-accepts', this.onChangeAccepts);
|
||||
this.connection.on('update-settings', this.onUpdateSettings);
|
||||
this.connection.on('init-form', this.onInitForm);
|
||||
this.connection.on('message', this.onMessage);
|
||||
|
||||
if (this.game.user1Id != (this as any).os.i.id && this.game.settings.form1) this.form = this.game.settings.form1;
|
||||
if (this.game.user2Id != (this as any).os.i.id && this.game.settings.form2) this.form = this.game.settings.form2;
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.connection.off('change-accepts', this.onChangeAccepts);
|
||||
this.connection.off('update-settings', this.onUpdateSettings);
|
||||
this.connection.off('init-form', this.onInitForm);
|
||||
this.connection.off('message', this.onMessage);
|
||||
},
|
||||
|
||||
methods: {
|
||||
exit() {
|
||||
|
||||
},
|
||||
|
||||
accept() {
|
||||
this.connection.send({
|
||||
type: 'accept'
|
||||
});
|
||||
},
|
||||
|
||||
cancel() {
|
||||
this.connection.send({
|
||||
type: 'cancel-accept'
|
||||
});
|
||||
},
|
||||
|
||||
onChangeAccepts(accepts) {
|
||||
this.game.user1Accepted = accepts.user1;
|
||||
this.game.user2Accepted = accepts.user2;
|
||||
this.$forceUpdate();
|
||||
},
|
||||
|
||||
updateSettings() {
|
||||
this.connection.send({
|
||||
type: 'update-settings',
|
||||
settings: this.game.settings
|
||||
});
|
||||
},
|
||||
|
||||
onUpdateSettings(settings) {
|
||||
this.game.settings = settings;
|
||||
if (this.game.settings.map == null) {
|
||||
this.mapName = null;
|
||||
} else {
|
||||
const foundMap = Object.entries(maps).find(x => x[1].data.join('') == this.game.settings.map.join(''));
|
||||
this.mapName = foundMap ? foundMap[1].name : '-Custom-';
|
||||
}
|
||||
},
|
||||
|
||||
onInitForm(x) {
|
||||
if (x.userId == (this as any).os.i.id) return;
|
||||
this.form = x.form;
|
||||
},
|
||||
|
||||
onMessage(x) {
|
||||
if (x.userId == (this as any).os.i.id) return;
|
||||
this.messages.unshift(x.message);
|
||||
},
|
||||
|
||||
onChangeForm(v, item) {
|
||||
this.connection.send({
|
||||
type: 'update-form',
|
||||
id: item.id,
|
||||
value: v
|
||||
});
|
||||
},
|
||||
|
||||
onMapChange(v) {
|
||||
if (v == null) {
|
||||
this.game.settings.map = null;
|
||||
} else {
|
||||
this.game.settings.map = Object.entries(maps).find(x => x[1].name == v)[1].data;
|
||||
}
|
||||
this.$forceUpdate();
|
||||
this.updateSettings();
|
||||
},
|
||||
|
||||
onPixelClick(pos, pixel) {
|
||||
const x = pos % this.game.settings.map[0].length;
|
||||
const y = Math.floor(pos / this.game.settings.map[0].length);
|
||||
const newPixel =
|
||||
pixel == ' ' ? '-' :
|
||||
pixel == '-' ? 'b' :
|
||||
pixel == 'b' ? 'w' :
|
||||
' ';
|
||||
const line = this.game.settings.map[y].split('');
|
||||
line[x] = newPixel;
|
||||
this.$set(this.game.settings.map, y, line.join(''));
|
||||
this.$forceUpdate();
|
||||
this.updateSettings();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.root
|
||||
text-align center
|
||||
background #f9f9f9
|
||||
|
||||
> header
|
||||
padding 8px
|
||||
border-bottom dashed 1px #c4cdd4
|
||||
|
||||
> div
|
||||
padding 0 16px
|
||||
|
||||
> .map
|
||||
> .bw
|
||||
> .rules
|
||||
> .bot-form
|
||||
max-width 400px
|
||||
margin 0 auto 16px auto
|
||||
|
||||
> footer
|
||||
position sticky
|
||||
bottom 0
|
||||
padding 16px
|
||||
background rgba(255, 255, 255, 0.9)
|
||||
border-top solid 1px #c4cdd4
|
||||
|
||||
> .status
|
||||
margin 0 0 16px 0
|
||||
</style>
|
||||
|
||||
<style lang="stylus" module>
|
||||
.mapSelect
|
||||
width 100%
|
||||
|
||||
.board
|
||||
display grid
|
||||
grid-gap 4px
|
||||
width 300px
|
||||
height 300px
|
||||
margin 0 auto
|
||||
|
||||
> div
|
||||
background transparent
|
||||
border solid 2px #ddd
|
||||
border-radius 6px
|
||||
overflow hidden
|
||||
cursor pointer
|
||||
|
||||
*
|
||||
pointer-events none
|
||||
user-select none
|
||||
width 100%
|
||||
height 100%
|
||||
|
||||
&[data-none]
|
||||
border-color transparent
|
||||
|
||||
</style>
|
||||
|
||||
<style lang="stylus">
|
||||
.el-alert__content
|
||||
position initial !important
|
||||
</style>
|
311
src/client/app/common/views/components/othello.vue
Normal file
311
src/client/app/common/views/components/othello.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div class="mk-othello">
|
||||
<div v-if="game">
|
||||
<x-gameroom :game="game"/>
|
||||
</div>
|
||||
<div class="matching" v-else-if="matching">
|
||||
<h1><b>{{ matching.name }}</b>を待っています<mk-ellipsis/></h1>
|
||||
<div class="cancel">
|
||||
<el-button round @click="cancel">キャンセル</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="index" v-else>
|
||||
<h1>Misskey %fa:circle%thell%fa:circle R%</h1>
|
||||
<p>他のMisskeyユーザーとオセロで対戦しよう</p>
|
||||
<div class="play">
|
||||
<el-button round>フリーマッチ(準備中)</el-button>
|
||||
<el-button type="primary" round @click="match">指名</el-button>
|
||||
<details>
|
||||
<summary>遊び方</summary>
|
||||
<div>
|
||||
<p>オセロは、相手と交互に石をボードに置いてゆき、相手の石を挟んでひっくり返しながら、最終的に残った石が多い方が勝ちというボードゲームです。</p>
|
||||
<dl>
|
||||
<dt><b>フリーマッチ</b></dt>
|
||||
<dd>ランダムなユーザーと対戦するモードです。</dd>
|
||||
<dt><b>指名</b></dt>
|
||||
<dd>指定したユーザーと対戦するモードです。</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<section v-if="invitations.length > 0">
|
||||
<h2>対局の招待があります!:</h2>
|
||||
<div class="invitation" v-for="i in invitations" tabindex="-1" @click="accept(i)">
|
||||
<img :src="`${i.parent.avatarUrl}?thumbnail&size=32`" alt="">
|
||||
<span class="name"><b>{{ i.parent.name }}</b></span>
|
||||
<span class="username">@{{ i.parent.username }}</span>
|
||||
<mk-time :time="i.createdAt"/>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="myGames.length > 0">
|
||||
<h2>自分の対局</h2>
|
||||
<a class="game" v-for="g in myGames" tabindex="-1" @click.prevent="go(g)" :href="`/othello/${g.id}`">
|
||||
<img :src="`${g.user1.avatarUrl}?thumbnail&size=32`" alt="">
|
||||
<img :src="`${g.user2.avatarUrl}?thumbnail&size=32`" alt="">
|
||||
<span><b>{{ g.user1.name }}</b> vs <b>{{ g.user2.name }}</b></span>
|
||||
<span class="state">{{ g.isEnded ? '終了' : '進行中' }}</span>
|
||||
</a>
|
||||
</section>
|
||||
<section v-if="games.length > 0">
|
||||
<h2>みんなの対局</h2>
|
||||
<a class="game" v-for="g in games" tabindex="-1" @click.prevent="go(g)" :href="`/othello/${g.id}`">
|
||||
<img :src="`${g.user1.avatarUrl}?thumbnail&size=32`" alt="">
|
||||
<img :src="`${g.user2.avatarUrl}?thumbnail&size=32`" alt="">
|
||||
<span><b>{{ g.user1.name }}</b> vs <b>{{ g.user2.name }}</b></span>
|
||||
<span class="state">{{ g.isEnded ? '終了' : '進行中' }}</span>
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XGameroom from './othello.gameroom.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XGameroom
|
||||
},
|
||||
props: ['initGame'],
|
||||
data() {
|
||||
return {
|
||||
game: null,
|
||||
games: [],
|
||||
gamesFetching: true,
|
||||
gamesMoreFetching: false,
|
||||
myGames: [],
|
||||
matching: null,
|
||||
invitations: [],
|
||||
connection: null,
|
||||
connectionId: null,
|
||||
pingClock: null
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
game(g) {
|
||||
this.$emit('gamed', g);
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.initGame) {
|
||||
this.game = this.initGame;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.connection = (this as any).os.streams.othelloStream.getConnection();
|
||||
this.connectionId = (this as any).os.streams.othelloStream.use();
|
||||
|
||||
this.connection.on('matched', this.onMatched);
|
||||
this.connection.on('invited', this.onInvited);
|
||||
|
||||
(this as any).api('othello/games', {
|
||||
my: true
|
||||
}).then(games => {
|
||||
this.myGames = games;
|
||||
});
|
||||
|
||||
(this as any).api('othello/games').then(games => {
|
||||
this.games = games;
|
||||
this.gamesFetching = false;
|
||||
});
|
||||
|
||||
(this as any).api('othello/invitations').then(invitations => {
|
||||
this.invitations = this.invitations.concat(invitations);
|
||||
});
|
||||
|
||||
this.pingClock = setInterval(() => {
|
||||
if (this.matching) {
|
||||
this.connection.send({
|
||||
type: 'ping',
|
||||
id: this.matching.id
|
||||
});
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.connection.off('matched', this.onMatched);
|
||||
this.connection.off('invited', this.onInvited);
|
||||
(this as any).os.streams.othelloStream.dispose(this.connectionId);
|
||||
|
||||
clearInterval(this.pingClock);
|
||||
},
|
||||
methods: {
|
||||
go(game) {
|
||||
(this as any).api('othello/games/show', {
|
||||
gameId: game.id
|
||||
}).then(game => {
|
||||
this.matching = null;
|
||||
this.game = game;
|
||||
});
|
||||
},
|
||||
match() {
|
||||
(this as any).apis.input({
|
||||
title: 'ユーザー名を入力してください'
|
||||
}).then(username => {
|
||||
(this as any).api('users/show', {
|
||||
username
|
||||
}).then(user => {
|
||||
(this as any).api('othello/match', {
|
||||
userId: user.id
|
||||
}).then(res => {
|
||||
if (res == null) {
|
||||
this.matching = user;
|
||||
} else {
|
||||
this.game = res;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
this.matching = null;
|
||||
(this as any).api('othello/match/cancel');
|
||||
},
|
||||
accept(invitation) {
|
||||
(this as any).api('othello/match', {
|
||||
userId: invitation.parent.id
|
||||
}).then(game => {
|
||||
if (game) {
|
||||
this.matching = null;
|
||||
this.game = game;
|
||||
}
|
||||
});
|
||||
},
|
||||
onMatched(game) {
|
||||
this.matching = null;
|
||||
this.game = game;
|
||||
},
|
||||
onInvited(invite) {
|
||||
this.invitations.unshift(invite);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.mk-othello
|
||||
color #677f84
|
||||
background #fff
|
||||
|
||||
> .matching
|
||||
> h1
|
||||
margin 0
|
||||
padding 24px
|
||||
font-size 20px
|
||||
text-align center
|
||||
font-weight normal
|
||||
|
||||
> .cancel
|
||||
margin 0 auto
|
||||
padding 24px 0 0 0
|
||||
max-width 200px
|
||||
text-align center
|
||||
border-top dashed 1px #c4cdd4
|
||||
|
||||
> .index
|
||||
> h1
|
||||
margin 0
|
||||
padding 24px
|
||||
font-size 24px
|
||||
text-align center
|
||||
font-weight normal
|
||||
color #fff
|
||||
background linear-gradient(to bottom, #8bca3e, #d6cf31)
|
||||
|
||||
& + p
|
||||
margin 0
|
||||
padding 12px
|
||||
margin-bottom 12px
|
||||
text-align center
|
||||
font-size 14px
|
||||
border-bottom solid 1px #d3d9dc
|
||||
|
||||
> .play
|
||||
margin 0 auto
|
||||
padding 0 16px
|
||||
max-width 500px
|
||||
text-align center
|
||||
|
||||
> details
|
||||
margin 8px 0
|
||||
|
||||
> div
|
||||
padding 16px
|
||||
font-size 14px
|
||||
text-align left
|
||||
background #f5f5f5
|
||||
border-radius 8px
|
||||
|
||||
> section
|
||||
margin 0 auto
|
||||
padding 0 16px 16px 16px
|
||||
max-width 500px
|
||||
border-top solid 1px #d3d9dc
|
||||
|
||||
> h2
|
||||
margin 0
|
||||
padding 16px 0 8px 0
|
||||
font-size 16px
|
||||
font-weight bold
|
||||
|
||||
.invitation
|
||||
margin 8px 0
|
||||
padding 8px
|
||||
border solid 1px #e1e5e8
|
||||
border-radius 6px
|
||||
cursor pointer
|
||||
|
||||
*
|
||||
pointer-events none
|
||||
user-select none
|
||||
|
||||
&:focus
|
||||
border-color $theme-color
|
||||
|
||||
&:hover
|
||||
background #f5f5f5
|
||||
|
||||
&:active
|
||||
background #eee
|
||||
|
||||
> img
|
||||
vertical-align bottom
|
||||
border-radius 100%
|
||||
|
||||
> span
|
||||
margin 0 8px
|
||||
line-height 32px
|
||||
|
||||
.game
|
||||
display block
|
||||
margin 8px 0
|
||||
padding 8px
|
||||
color #677f84
|
||||
border solid 1px #e1e5e8
|
||||
border-radius 6px
|
||||
cursor pointer
|
||||
|
||||
*
|
||||
pointer-events none
|
||||
user-select none
|
||||
|
||||
&:focus
|
||||
border-color $theme-color
|
||||
|
||||
&:hover
|
||||
background #f5f5f5
|
||||
|
||||
&:active
|
||||
background #eee
|
||||
|
||||
> img
|
||||
vertical-align bottom
|
||||
border-radius 100%
|
||||
|
||||
> span
|
||||
margin 0 8px
|
||||
line-height 32px
|
||||
</style>
|
142
src/client/app/common/views/components/poll-editor.vue
Normal file
142
src/client/app/common/views/components/poll-editor.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="mk-poll-editor">
|
||||
<p class="caution" v-if="choices.length < 2">
|
||||
%fa:exclamation-triangle%%i18n:common.tags.mk-poll-editor.no-only-one-choice%
|
||||
</p>
|
||||
<ul ref="choices">
|
||||
<li v-for="(choice, i) in choices">
|
||||
<input :value="choice" @input="onInput(i, $event)" :placeholder="'%i18n:common.tags.mk-poll-editor.choice-n%'.replace('{}', i + 1)">
|
||||
<button @click="remove(i)" title="%i18n:common.tags.mk-poll-editor.remove%">
|
||||
%fa:times%
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="add" v-if="choices.length < 10" @click="add">%i18n:common.tags.mk-poll-editor.add%</button>
|
||||
<button class="destroy" @click="destroy" title="%i18n:common.tags.mk-poll-editor.destroy%">
|
||||
%fa:times%
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
choices: ['', '']
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
choices() {
|
||||
this.$emit('updated');
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onInput(i, e) {
|
||||
Vue.set(this.choices, i, e.target.value);
|
||||
},
|
||||
|
||||
add() {
|
||||
this.choices.push('');
|
||||
this.$nextTick(() => {
|
||||
(this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus();
|
||||
});
|
||||
},
|
||||
|
||||
remove(i) {
|
||||
this.choices = this.choices.filter((_, _i) => _i != i);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.$emit('destroyed');
|
||||
},
|
||||
|
||||
get() {
|
||||
return {
|
||||
choices: this.choices.filter(choice => choice != '')
|
||||
}
|
||||
},
|
||||
|
||||
set(data) {
|
||||
if (data.choices.length == 0) return;
|
||||
this.choices = data.choices;
|
||||
if (data.choices.length == 1) this.choices = this.choices.concat('');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.mk-poll-editor
|
||||
padding 8px
|
||||
|
||||
> .caution
|
||||
margin 0 0 8px 0
|
||||
font-size 0.8em
|
||||
color #f00
|
||||
|
||||
> [data-fa]
|
||||
margin-right 4px
|
||||
|
||||
> ul
|
||||
display block
|
||||
margin 0
|
||||
padding 0
|
||||
list-style none
|
||||
|
||||
> li
|
||||
display block
|
||||
margin 8px 0
|
||||
padding 0
|
||||
width 100%
|
||||
|
||||
&:first-child
|
||||
margin-top 0
|
||||
|
||||
&:last-child
|
||||
margin-bottom 0
|
||||
|
||||
> input
|
||||
padding 6px 8px
|
||||
width 300px
|
||||
font-size 14px
|
||||
border solid 1px rgba($theme-color, 0.1)
|
||||
border-radius 4px
|
||||
|
||||
&:hover
|
||||
border-color rgba($theme-color, 0.2)
|
||||
|
||||
&:focus
|
||||
border-color rgba($theme-color, 0.5)
|
||||
|
||||
> button
|
||||
padding 4px 8px
|
||||
color rgba($theme-color, 0.4)
|
||||
|
||||
&:hover
|
||||
color rgba($theme-color, 0.6)
|
||||
|
||||
&:active
|
||||
color darken($theme-color, 30%)
|
||||
|
||||
> .add
|
||||
margin 8px 0 0 0
|
||||
vertical-align top
|
||||
color $theme-color
|
||||
|
||||
> .destroy
|
||||
position absolute
|
||||
top 0
|
||||
right 0
|
||||
padding 4px 8px
|
||||
color rgba($theme-color, 0.4)
|
||||
|
||||
&:hover
|
||||
color rgba($theme-color, 0.6)
|
||||
|
||||
&:active
|
||||
color darken($theme-color, 30%)
|
||||
|
||||
</style>
|
124
src/client/app/common/views/components/poll.vue
Normal file
124
src/client/app/common/views/components/poll.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="mk-poll" :data-is-voted="isVoted">
|
||||
<ul>
|
||||
<li v-for="choice in poll.choices" :key="choice.id" @click="vote(choice.id)" :class="{ voted: choice.voted }" :title="!isVoted ? '%i18n:common.tags.mk-poll.vote-to%'.replace('{}', choice.text) : ''">
|
||||
<div class="backdrop" :style="{ 'width': (showResult ? (choice.votes / total * 100) : 0) + '%' }"></div>
|
||||
<span>
|
||||
<template v-if="choice.isVoted">%fa:check%</template>
|
||||
<span>{{ choice.text }}</span>
|
||||
<span class="votes" v-if="showResult">({{ '%i18n:common.tags.mk-poll.vote-count%'.replace('{}', choice.votes) }})</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="total > 0">
|
||||
<span>{{ '%i18n:common.tags.mk-poll.total-users%'.replace('{}', total) }}</span>
|
||||
<span>・</span>
|
||||
<a v-if="!isVoted" @click="toggleShowResult">{{ showResult ? '%i18n:common.tags.mk-poll.vote%' : '%i18n:common.tags.mk-poll.show-result%' }}</a>
|
||||
<span v-if="isVoted">%i18n:common.tags.mk-poll.voted%</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
export default Vue.extend({
|
||||
props: ['post'],
|
||||
data() {
|
||||
return {
|
||||
showResult: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
poll(): any {
|
||||
return this.post.poll;
|
||||
},
|
||||
total(): number {
|
||||
return this.poll.choices.reduce((a, b) => a + b.votes, 0);
|
||||
},
|
||||
isVoted(): boolean {
|
||||
return this.poll.choices.some(c => c.isVoted);
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.showResult = this.isVoted;
|
||||
},
|
||||
methods: {
|
||||
toggleShowResult() {
|
||||
this.showResult = !this.showResult;
|
||||
},
|
||||
vote(id) {
|
||||
if (this.poll.choices.some(c => c.isVoted)) return;
|
||||
(this as any).api('posts/polls/vote', {
|
||||
postId: this.post.id,
|
||||
choice: id
|
||||
}).then(() => {
|
||||
this.poll.choices.forEach(c => {
|
||||
if (c.id == id) {
|
||||
c.votes++;
|
||||
Vue.set(c, 'isVoted', true);
|
||||
}
|
||||
});
|
||||
this.showResult = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.mk-poll
|
||||
|
||||
> ul
|
||||
display block
|
||||
margin 0
|
||||
padding 0
|
||||
list-style none
|
||||
|
||||
> li
|
||||
display block
|
||||
margin 4px 0
|
||||
padding 4px 8px
|
||||
width 100%
|
||||
border solid 1px #eee
|
||||
border-radius 4px
|
||||
overflow hidden
|
||||
cursor pointer
|
||||
|
||||
&:hover
|
||||
background rgba(0, 0, 0, 0.05)
|
||||
|
||||
&:active
|
||||
background rgba(0, 0, 0, 0.1)
|
||||
|
||||
> .backdrop
|
||||
position absolute
|
||||
top 0
|
||||
left 0
|
||||
height 100%
|
||||
background $theme-color
|
||||
transition width 1s ease
|
||||
|
||||
> span
|
||||
> [data-fa]
|
||||
margin-right 4px
|
||||
|
||||
> .votes
|
||||
margin-left 4px
|
||||
|
||||
> p
|
||||
a
|
||||
color inherit
|
||||
|
||||
&[data-is-voted]
|
||||
> ul > li
|
||||
cursor default
|
||||
|
||||
&:hover
|
||||
background transparent
|
||||
|
||||
&:active
|
||||
background transparent
|
||||
|
||||
</style>
|
137
src/client/app/common/views/components/post-html.ts
Normal file
137
src/client/app/common/views/components/post-html.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import Vue from 'vue';
|
||||
import * as emojilib from 'emojilib';
|
||||
import getAcct from '../../../../../common/user/get-acct';
|
||||
import { url } from '../../../config';
|
||||
import MkUrl from './url.vue';
|
||||
|
||||
const flatten = list => list.reduce(
|
||||
(a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), []
|
||||
);
|
||||
|
||||
export default Vue.component('mk-post-html', {
|
||||
props: {
|
||||
ast: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
shouldBreak: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
i: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
render(createElement) {
|
||||
const els = flatten((this as any).ast.map(token => {
|
||||
switch (token.type) {
|
||||
case 'text':
|
||||
const text = token.content.replace(/(\r\n|\n|\r)/g, '\n');
|
||||
|
||||
if ((this as any).shouldBreak) {
|
||||
const x = text.split('\n')
|
||||
.map(t => t == '' ? [createElement('br')] : [createElement('span', t), createElement('br')]);
|
||||
x[x.length - 1].pop();
|
||||
return x;
|
||||
} else {
|
||||
return createElement('span', text.replace(/\n/g, ' '));
|
||||
}
|
||||
|
||||
case 'bold':
|
||||
return createElement('strong', token.bold);
|
||||
|
||||
case 'url':
|
||||
return createElement(MkUrl, {
|
||||
props: {
|
||||
url: token.content,
|
||||
target: '_blank'
|
||||
}
|
||||
});
|
||||
|
||||
case 'link':
|
||||
return createElement('a', {
|
||||
attrs: {
|
||||
class: 'link',
|
||||
href: token.url,
|
||||
target: '_blank',
|
||||
title: token.url
|
||||
}
|
||||
}, token.title);
|
||||
|
||||
case 'mention':
|
||||
return (createElement as any)('a', {
|
||||
attrs: {
|
||||
href: `${url}/@${getAcct(token)}`,
|
||||
target: '_blank',
|
||||
dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token)
|
||||
},
|
||||
directives: [{
|
||||
name: 'user-preview',
|
||||
value: token.content
|
||||
}]
|
||||
}, token.content);
|
||||
|
||||
case 'hashtag':
|
||||
return createElement('a', {
|
||||
attrs: {
|
||||
href: `${url}/search?q=${token.content}`,
|
||||
target: '_blank'
|
||||
}
|
||||
}, token.content);
|
||||
|
||||
case 'code':
|
||||
return createElement('pre', [
|
||||
createElement('code', {
|
||||
domProps: {
|
||||
innerHTML: token.html
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
case 'inline-code':
|
||||
return createElement('code', token.html);
|
||||
|
||||
case 'quote':
|
||||
const text2 = token.quote.replace(/(\r\n|\n|\r)/g, '\n');
|
||||
|
||||
if ((this as any).shouldBreak) {
|
||||
const x = text2.split('\n')
|
||||
.map(t => [createElement('span', t), createElement('br')]);
|
||||
x[x.length - 1].pop();
|
||||
return createElement('div', {
|
||||
attrs: {
|
||||
class: 'quote'
|
||||
}
|
||||
}, x);
|
||||
} else {
|
||||
return createElement('span', {
|
||||
attrs: {
|
||||
class: 'quote'
|
||||
}
|
||||
}, text2.replace(/\n/g, ' '));
|
||||
}
|
||||
|
||||
case 'emoji':
|
||||
const emoji = emojilib.lib[token.emoji];
|
||||
return createElement('span', emoji ? emoji.char : token.content);
|
||||
|
||||
default:
|
||||
console.log('unknown ast type:', token.type);
|
||||
}
|
||||
}));
|
||||
|
||||
const _els = [];
|
||||
els.forEach((el, i) => {
|
||||
if (el.tag == 'br') {
|
||||
if (els[i - 1].tag != 'div') {
|
||||
_els.push(el);
|
||||
}
|
||||
} else {
|
||||
_els.push(el);
|
||||
}
|
||||
});
|
||||
|
||||
return createElement('span', _els);
|
||||
}
|
||||
});
|
141
src/client/app/common/views/components/post-menu.vue
Normal file
141
src/client/app/common/views/components/post-menu.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="mk-post-menu">
|
||||
<div class="backdrop" ref="backdrop" @click="close"></div>
|
||||
<div class="popover" :class="{ compact }" ref="popover">
|
||||
<button v-if="post.userId == os.i.id" @click="pin">%i18n:common.tags.mk-post-menu.pin%</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import * as anime from 'animejs';
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['post', 'source', 'compact'],
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
const popover = this.$refs.popover as any;
|
||||
|
||||
const rect = this.source.getBoundingClientRect();
|
||||
const width = popover.offsetWidth;
|
||||
const height = popover.offsetHeight;
|
||||
|
||||
if (this.compact) {
|
||||
const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
|
||||
const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2);
|
||||
popover.style.left = (x - (width / 2)) + 'px';
|
||||
popover.style.top = (y - (height / 2)) + 'px';
|
||||
} else {
|
||||
const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
|
||||
const y = rect.top + window.pageYOffset + this.source.offsetHeight;
|
||||
popover.style.left = (x - (width / 2)) + 'px';
|
||||
popover.style.top = y + 'px';
|
||||
}
|
||||
|
||||
anime({
|
||||
targets: this.$refs.backdrop,
|
||||
opacity: 1,
|
||||
duration: 100,
|
||||
easing: 'linear'
|
||||
});
|
||||
|
||||
anime({
|
||||
targets: this.$refs.popover,
|
||||
opacity: 1,
|
||||
scale: [0.5, 1],
|
||||
duration: 500
|
||||
});
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
pin() {
|
||||
(this as any).api('i/pin', {
|
||||
postId: this.post.id
|
||||
}).then(() => {
|
||||
this.$destroy();
|
||||
});
|
||||
},
|
||||
|
||||
close() {
|
||||
(this.$refs.backdrop as any).style.pointerEvents = 'none';
|
||||
anime({
|
||||
targets: this.$refs.backdrop,
|
||||
opacity: 0,
|
||||
duration: 200,
|
||||
easing: 'linear'
|
||||
});
|
||||
|
||||
(this.$refs.popover as any).style.pointerEvents = 'none';
|
||||
anime({
|
||||
targets: this.$refs.popover,
|
||||
opacity: 0,
|
||||
scale: 0.5,
|
||||
duration: 200,
|
||||
easing: 'easeInBack',
|
||||
complete: () => this.$destroy()
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
$border-color = rgba(27, 31, 35, 0.15)
|
||||
|
||||
.mk-post-menu
|
||||
position initial
|
||||
|
||||
> .backdrop
|
||||
position fixed
|
||||
top 0
|
||||
left 0
|
||||
z-index 10000
|
||||
width 100%
|
||||
height 100%
|
||||
background rgba(0, 0, 0, 0.1)
|
||||
opacity 0
|
||||
|
||||
> .popover
|
||||
position absolute
|
||||
z-index 10001
|
||||
background #fff
|
||||
border 1px solid $border-color
|
||||
border-radius 4px
|
||||
box-shadow 0 3px 12px rgba(27, 31, 35, 0.15)
|
||||
transform scale(0.5)
|
||||
opacity 0
|
||||
|
||||
$balloon-size = 16px
|
||||
|
||||
&:not(.compact)
|
||||
margin-top $balloon-size
|
||||
transform-origin center -($balloon-size)
|
||||
|
||||
&:before
|
||||
content ""
|
||||
display block
|
||||
position absolute
|
||||
top -($balloon-size * 2)
|
||||
left s('calc(50% - %s)', $balloon-size)
|
||||
border-top solid $balloon-size transparent
|
||||
border-left solid $balloon-size transparent
|
||||
border-right solid $balloon-size transparent
|
||||
border-bottom solid $balloon-size $border-color
|
||||
|
||||
&:after
|
||||
content ""
|
||||
display block
|
||||
position absolute
|
||||
top -($balloon-size * 2) + 1.5px
|
||||
left s('calc(50% - %s)', $balloon-size)
|
||||
border-top solid $balloon-size transparent
|
||||
border-left solid $balloon-size transparent
|
||||
border-right solid $balloon-size transparent
|
||||
border-bottom solid $balloon-size #fff
|
||||
|
||||
> button
|
||||
display block
|
||||
padding 16px
|
||||
|
||||
</style>
|
28
src/client/app/common/views/components/reaction-icon.vue
Normal file
28
src/client/app/common/views/components/reaction-icon.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<span class="mk-reaction-icon">
|
||||
<img v-if="reaction == 'like'" src="/assets/reactions/like.png" alt="%i18n:common.reactions.like%">
|
||||
<img v-if="reaction == 'love'" src="/assets/reactions/love.png" alt="%i18n:common.reactions.love%">
|
||||
<img v-if="reaction == 'laugh'" src="/assets/reactions/laugh.png" alt="%i18n:common.reactions.laugh%">
|
||||
<img v-if="reaction == 'hmm'" src="/assets/reactions/hmm.png" alt="%i18n:common.reactions.hmm%">
|
||||
<img v-if="reaction == 'surprise'" src="/assets/reactions/surprise.png" alt="%i18n:common.reactions.surprise%">
|
||||
<img v-if="reaction == 'congrats'" src="/assets/reactions/congrats.png" alt="%i18n:common.reactions.congrats%">
|
||||
<img v-if="reaction == 'angry'" src="/assets/reactions/angry.png" alt="%i18n:common.reactions.angry%">
|
||||
<img v-if="reaction == 'confused'" src="/assets/reactions/confused.png" alt="%i18n:common.reactions.confused%">
|
||||
<img v-if="reaction == 'pudding'" src="/assets/reactions/pudding.png" alt="%i18n:common.reactions.pudding%">
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
export default Vue.extend({
|
||||
props: ['reaction']
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.mk-reaction-icon
|
||||
img
|
||||
vertical-align middle
|
||||
width 1em
|
||||
height 1em
|
||||
</style>
|
191
src/client/app/common/views/components/reaction-picker.vue
Normal file
191
src/client/app/common/views/components/reaction-picker.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="mk-reaction-picker">
|
||||
<div class="backdrop" ref="backdrop" @click="close"></div>
|
||||
<div class="popover" :class="{ compact }" ref="popover">
|
||||
<p v-if="!compact">{{ title }}</p>
|
||||
<div>
|
||||
<button @click="react('like')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="1" title="%i18n:common.reactions.like%"><mk-reaction-icon reaction='like'/></button>
|
||||
<button @click="react('love')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="2" title="%i18n:common.reactions.love%"><mk-reaction-icon reaction='love'/></button>
|
||||
<button @click="react('laugh')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="3" title="%i18n:common.reactions.laugh%"><mk-reaction-icon reaction='laugh'/></button>
|
||||
<button @click="react('hmm')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" title="%i18n:common.reactions.hmm%"><mk-reaction-icon reaction='hmm'/></button>
|
||||
<button @click="react('surprise')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" title="%i18n:common.reactions.surprise%"><mk-reaction-icon reaction='surprise'/></button>
|
||||
<button @click="react('congrats')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" title="%i18n:common.reactions.congrats%"><mk-reaction-icon reaction='congrats'/></button>
|
||||
<button @click="react('angry')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" title="%i18n:common.reactions.angry%"><mk-reaction-icon reaction='angry'/></button>
|
||||
<button @click="react('confused')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" title="%i18n:common.reactions.confused%"><mk-reaction-icon reaction='confused'/></button>
|
||||
<button @click="react('pudding')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" title="%i18n:common.reactions.pudding%"><mk-reaction-icon reaction='pudding'/></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import * as anime from 'animejs';
|
||||
|
||||
const placeholder = '%i18n:common.tags.mk-reaction-picker.choose-reaction%';
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['post', 'source', 'compact', 'cb'],
|
||||
data() {
|
||||
return {
|
||||
title: placeholder
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
const popover = this.$refs.popover as any;
|
||||
|
||||
const rect = this.source.getBoundingClientRect();
|
||||
const width = popover.offsetWidth;
|
||||
const height = popover.offsetHeight;
|
||||
|
||||
if (this.compact) {
|
||||
const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
|
||||
const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2);
|
||||
popover.style.left = (x - (width / 2)) + 'px';
|
||||
popover.style.top = (y - (height / 2)) + 'px';
|
||||
} else {
|
||||
const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
|
||||
const y = rect.top + window.pageYOffset + this.source.offsetHeight;
|
||||
popover.style.left = (x - (width / 2)) + 'px';
|
||||
popover.style.top = y + 'px';
|
||||
}
|
||||
|
||||
anime({
|
||||
targets: this.$refs.backdrop,
|
||||
opacity: 1,
|
||||
duration: 100,
|
||||
easing: 'linear'
|
||||
});
|
||||
|
||||
anime({
|
||||
targets: this.$refs.popover,
|
||||
opacity: 1,
|
||||
scale: [0.5, 1],
|
||||
duration: 500
|
||||
});
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
react(reaction) {
|
||||
(this as any).api('posts/reactions/create', {
|
||||
postId: this.post.id,
|
||||
reaction: reaction
|
||||
}).then(() => {
|
||||
if (this.cb) this.cb();
|
||||
this.$destroy();
|
||||
});
|
||||
},
|
||||
onMouseover(e) {
|
||||
this.title = e.target.title;
|
||||
},
|
||||
onMouseout(e) {
|
||||
this.title = placeholder;
|
||||
},
|
||||
close() {
|
||||
(this.$refs.backdrop as any).style.pointerEvents = 'none';
|
||||
anime({
|
||||
targets: this.$refs.backdrop,
|
||||
opacity: 0,
|
||||
duration: 200,
|
||||
easing: 'linear'
|
||||
});
|
||||
|
||||
(this.$refs.popover as any).style.pointerEvents = 'none';
|
||||
anime({
|
||||
targets: this.$refs.popover,
|
||||
opacity: 0,
|
||||
scale: 0.5,
|
||||
duration: 200,
|
||||
easing: 'easeInBack',
|
||||
complete: () => this.$destroy()
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
$border-color = rgba(27, 31, 35, 0.15)
|
||||
|
||||
.mk-reaction-picker
|
||||
position initial
|
||||
|
||||
> .backdrop
|
||||
position fixed
|
||||
top 0
|
||||
left 0
|
||||
z-index 10000
|
||||
width 100%
|
||||
height 100%
|
||||
background rgba(0, 0, 0, 0.1)
|
||||
opacity 0
|
||||
|
||||
> .popover
|
||||
position absolute
|
||||
z-index 10001
|
||||
background #fff
|
||||
border 1px solid $border-color
|
||||
border-radius 4px
|
||||
box-shadow 0 3px 12px rgba(27, 31, 35, 0.15)
|
||||
transform scale(0.5)
|
||||
opacity 0
|
||||
|
||||
$balloon-size = 16px
|
||||
|
||||
&:not(.compact)
|
||||
margin-top $balloon-size
|
||||
transform-origin center -($balloon-size)
|
||||
|
||||
&:before
|
||||
content ""
|
||||
display block
|
||||
position absolute
|
||||
top -($balloon-size * 2)
|
||||
left s('calc(50% - %s)', $balloon-size)
|
||||
border-top solid $balloon-size transparent
|
||||
border-left solid $balloon-size transparent
|
||||
border-right solid $balloon-size transparent
|
||||
border-bottom solid $balloon-size $border-color
|
||||
|
||||
&:after
|
||||
content ""
|
||||
display block
|
||||
position absolute
|
||||
top -($balloon-size * 2) + 1.5px
|
||||
left s('calc(50% - %s)', $balloon-size)
|
||||
border-top solid $balloon-size transparent
|
||||
border-left solid $balloon-size transparent
|
||||
border-right solid $balloon-size transparent
|
||||
border-bottom solid $balloon-size #fff
|
||||
|
||||
> p
|
||||
display block
|
||||
margin 0
|
||||
padding 8px 10px
|
||||
font-size 14px
|
||||
color #586069
|
||||
border-bottom solid 1px #e1e4e8
|
||||
|
||||
> div
|
||||
padding 4px
|
||||
width 240px
|
||||
text-align center
|
||||
|
||||
> button
|
||||
padding 0
|
||||
width 40px
|
||||
height 40px
|
||||
font-size 24px
|
||||
border-radius 2px
|
||||
|
||||
&:hover
|
||||
background #eee
|
||||
|
||||
&:active
|
||||
background $theme-color
|
||||
box-shadow inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15)
|
||||
|
||||
</style>
|
49
src/client/app/common/views/components/reactions-viewer.vue
Normal file
49
src/client/app/common/views/components/reactions-viewer.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="mk-reactions-viewer">
|
||||
<template v-if="reactions">
|
||||
<span v-if="reactions.like"><mk-reaction-icon reaction='like'/><span>{{ reactions.like }}</span></span>
|
||||
<span v-if="reactions.love"><mk-reaction-icon reaction='love'/><span>{{ reactions.love }}</span></span>
|
||||
<span v-if="reactions.laugh"><mk-reaction-icon reaction='laugh'/><span>{{ reactions.laugh }}</span></span>
|
||||
<span v-if="reactions.hmm"><mk-reaction-icon reaction='hmm'/><span>{{ reactions.hmm }}</span></span>
|
||||
<span v-if="reactions.surprise"><mk-reaction-icon reaction='surprise'/><span>{{ reactions.surprise }}</span></span>
|
||||
<span v-if="reactions.congrats"><mk-reaction-icon reaction='congrats'/><span>{{ reactions.congrats }}</span></span>
|
||||
<span v-if="reactions.angry"><mk-reaction-icon reaction='angry'/><span>{{ reactions.angry }}</span></span>
|
||||
<span v-if="reactions.confused"><mk-reaction-icon reaction='confused'/><span>{{ reactions.confused }}</span></span>
|
||||
<span v-if="reactions.pudding"><mk-reaction-icon reaction='pudding'/><span>{{ reactions.pudding }}</span></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
export default Vue.extend({
|
||||
props: ['post'],
|
||||
computed: {
|
||||
reactions(): number {
|
||||
return this.post.reactionCounts;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.mk-reactions-viewer
|
||||
border-top dashed 1px #eee
|
||||
border-bottom dashed 1px #eee
|
||||
margin 4px 0
|
||||
|
||||
&:empty
|
||||
display none
|
||||
|
||||
> span
|
||||
margin-right 8px
|
||||
|
||||
> .mk-reaction-icon
|
||||
font-size 1.4em
|
||||
|
||||
> span
|
||||
margin-left 4px
|
||||
font-size 1.2em
|
||||
color #444
|
||||
|
||||
</style>
|
142
src/client/app/common/views/components/signin.vue
Normal file
142
src/client/app/common/views/components/signin.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<form class="mk-signin" :class="{ signing }" @submit.prevent="onSubmit">
|
||||
<label class="user-name">
|
||||
<input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" placeholder="%i18n:common.tags.mk-signin.username%" autofocus required @change="onUsernameChange"/>%fa:at%
|
||||
</label>
|
||||
<label class="password">
|
||||
<input v-model="password" type="password" placeholder="%i18n:common.tags.mk-signin.password%" required/>%fa:lock%
|
||||
</label>
|
||||
<label class="token" v-if="user && user.account.twoFactorEnabled">
|
||||
<input v-model="token" type="number" placeholder="%i18n:common.tags.mk-signin.token%" required/>%fa:lock%
|
||||
</label>
|
||||
<button type="submit" :disabled="signing">{{ signing ? '%i18n:common.tags.mk-signin.signing-in%' : '%i18n:common.tags.mk-signin.signin%' }}</button>
|
||||
もしくは <a :href="`${apiUrl}/signin/twitter`">Twitterでログイン</a>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { apiUrl } from '../../../config';
|
||||
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
signing: false,
|
||||
user: null,
|
||||
username: '',
|
||||
password: '',
|
||||
token: '',
|
||||
apiUrl,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onUsernameChange() {
|
||||
(this as any).api('users/show', {
|
||||
username: this.username
|
||||
}).then(user => {
|
||||
this.user = user;
|
||||
});
|
||||
},
|
||||
onSubmit() {
|
||||
this.signing = true;
|
||||
|
||||
(this as any).api('signin', {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
token: this.user && this.user.account.twoFactorEnabled ? this.token : undefined
|
||||
}).then(() => {
|
||||
location.reload();
|
||||
}).catch(() => {
|
||||
alert('something happened');
|
||||
this.signing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.mk-signin
|
||||
&.signing
|
||||
&, *
|
||||
cursor wait !important
|
||||
|
||||
label
|
||||
display block
|
||||
margin 12px 0
|
||||
|
||||
[data-fa]
|
||||
display block
|
||||
pointer-events none
|
||||
position absolute
|
||||
bottom 0
|
||||
top 0
|
||||
left 0
|
||||
z-index 1
|
||||
margin auto
|
||||
padding 0 16px
|
||||
height 1em
|
||||
color #898786
|
||||
|
||||
input[type=text]
|
||||
input[type=password]
|
||||
input[type=number]
|
||||
user-select text
|
||||
display inline-block
|
||||
cursor auto
|
||||
padding 0 0 0 38px
|
||||
margin 0
|
||||
width 100%
|
||||
line-height 44px
|
||||
font-size 1em
|
||||
color rgba(0, 0, 0, 0.7)
|
||||
background #fff
|
||||
outline none
|
||||
border solid 1px #eee
|
||||
border-radius 4px
|
||||
|
||||
&:hover
|
||||
background rgba(255, 255, 255, 0.7)
|
||||
border-color #ddd
|
||||
|
||||
& + i
|
||||
color #797776
|
||||
|
||||
&:focus
|
||||
background #fff
|
||||
border-color #ccc
|
||||
|
||||
& + i
|
||||
color #797776
|
||||
|
||||
[type=submit]
|
||||
cursor pointer
|
||||
padding 16px
|
||||
margin -6px 0 0 0
|
||||
width 100%
|
||||
font-size 1.2em
|
||||
color rgba(0, 0, 0, 0.5)
|
||||
outline none
|
||||
border none
|
||||
border-radius 0
|
||||
background transparent
|
||||
transition all .5s ease
|
||||
|
||||
&:hover
|
||||
color $theme-color
|
||||
transition all .2s ease
|
||||
|
||||
&:focus
|
||||
color $theme-color
|
||||
transition all .2s ease
|
||||
|
||||
&:active
|
||||
color darken($theme-color, 30%)
|
||||
transition all .2s ease
|
||||
|
||||
&:disabled
|
||||
opacity 0.7
|
||||
|
||||
</style>
|
287
src/client/app/common/views/components/signup.vue
Normal file
287
src/client/app/common/views/components/signup.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<form class="mk-signup" @submit.prevent="onSubmit" autocomplete="off">
|
||||
<label class="username">
|
||||
<p class="caption">%fa:at%%i18n:common.tags.mk-signup.username%</p>
|
||||
<input v-model="username" type="text" pattern="^[a-zA-Z0-9_]{3,20}$" placeholder="a~z、A~Z、0~9、-" autocomplete="off" required @input="onChangeUsername"/>
|
||||
<p class="profile-page-url-preview" v-if="shouldShowProfileUrl">{{ `${url}/@${username}` }}</p>
|
||||
<p class="info" v-if="usernameState == 'wait'" style="color:#999">%fa:spinner .pulse .fw%%i18n:common.tags.mk-signup.checking%</p>
|
||||
<p class="info" v-if="usernameState == 'ok'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.available%</p>
|
||||
<p class="info" v-if="usernameState == 'unavailable'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.unavailable%</p>
|
||||
<p class="info" v-if="usernameState == 'error'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.error%</p>
|
||||
<p class="info" v-if="usernameState == 'invalid-format'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.invalid-format%</p>
|
||||
<p class="info" v-if="usernameState == 'min-range'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.too-short%</p>
|
||||
<p class="info" v-if="usernameState == 'max-range'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.too-long%</p>
|
||||
</label>
|
||||
<label class="password">
|
||||
<p class="caption">%fa:lock%%i18n:common.tags.mk-signup.password%</p>
|
||||
<input v-model="password" type="password" placeholder="%i18n:common.tags.mk-signup.password-placeholder%" autocomplete="off" required @input="onChangePassword"/>
|
||||
<div class="meter" v-show="passwordStrength != ''" :data-strength="passwordStrength">
|
||||
<div class="value" ref="passwordMetar"></div>
|
||||
</div>
|
||||
<p class="info" v-if="passwordStrength == 'low'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.weak-password%</p>
|
||||
<p class="info" v-if="passwordStrength == 'medium'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.normal-password%</p>
|
||||
<p class="info" v-if="passwordStrength == 'high'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.strong-password%</p>
|
||||
</label>
|
||||
<label class="retype-password">
|
||||
<p class="caption">%fa:lock%%i18n:common.tags.mk-signup.password%(%i18n:common.tags.mk-signup.retype%)</p>
|
||||
<input v-model="retypedPassword" type="password" placeholder="%i18n:common.tags.mk-signup.retype-placeholder%" autocomplete="off" required @input="onChangePasswordRetype"/>
|
||||
<p class="info" v-if="passwordRetypeState == 'match'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.password-matched%</p>
|
||||
<p class="info" v-if="passwordRetypeState == 'not-match'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.password-not-matched%</p>
|
||||
</label>
|
||||
<label class="recaptcha">
|
||||
<p class="caption"><template v-if="recaptchaed">%fa:toggle-on%</template><template v-if="!recaptchaed">%fa:toggle-off%</template>%i18n:common.tags.mk-signup.recaptcha%</p>
|
||||
<div class="g-recaptcha" data-callback="onRecaptchaed" data-expired-callback="onRecaptchaExpired" :data-sitekey="recaptchaSitekey"></div>
|
||||
</label>
|
||||
<label class="agree-tou">
|
||||
<input name="agree-tou" type="checkbox" autocomplete="off" required/>
|
||||
<p><a :href="touUrl" target="_blank">利用規約</a>に同意する</p>
|
||||
</label>
|
||||
<button type="submit">%i18n:common.tags.mk-signup.create%</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
const getPasswordStrength = require('syuilo-password-strength');
|
||||
import { url, docsUrl, lang, recaptchaSitekey } from '../../../config';
|
||||
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
retypedPassword: '',
|
||||
url,
|
||||
touUrl: `${docsUrl}/${lang}/tou`,
|
||||
recaptchaSitekey,
|
||||
recaptchaed: false,
|
||||
usernameState: null,
|
||||
passwordStrength: '',
|
||||
passwordRetypeState: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
shouldShowProfileUrl(): boolean {
|
||||
return (this.username != '' &&
|
||||
this.usernameState != 'invalid-format' &&
|
||||
this.usernameState != 'min-range' &&
|
||||
this.usernameState != 'max-range');
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onChangeUsername() {
|
||||
if (this.username == '') {
|
||||
this.usernameState = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const err =
|
||||
!this.username.match(/^[a-zA-Z0-9\-]+$/) ? 'invalid-format' :
|
||||
this.username.length < 3 ? 'min-range' :
|
||||
this.username.length > 20 ? 'max-range' :
|
||||
null;
|
||||
|
||||
if (err) {
|
||||
this.usernameState = err;
|
||||
return;
|
||||
}
|
||||
|
||||
this.usernameState = 'wait';
|
||||
|
||||
(this as any).api('username/available', {
|
||||
username: this.username
|
||||
}).then(result => {
|
||||
this.usernameState = result.available ? 'ok' : 'unavailable';
|
||||
}).catch(err => {
|
||||
this.usernameState = 'error';
|
||||
});
|
||||
},
|
||||
onChangePassword() {
|
||||
if (this.password == '') {
|
||||
this.passwordStrength = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const strength = getPasswordStrength(this.password);
|
||||
this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
|
||||
(this.$refs.passwordMetar as any).style.width = `${strength * 100}%`;
|
||||
},
|
||||
onChangePasswordRetype() {
|
||||
if (this.retypedPassword == '') {
|
||||
this.passwordRetypeState = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.passwordRetypeState = this.password == this.retypedPassword ? 'match' : 'not-match';
|
||||
},
|
||||
onSubmit() {
|
||||
(this as any).api('signup', {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
'g-recaptcha-response': (window as any).grecaptcha.getResponse()
|
||||
}).then(() => {
|
||||
(this as any).api('signin', {
|
||||
username: this.username,
|
||||
password: this.password
|
||||
}).then(() => {
|
||||
location.href = '/';
|
||||
});
|
||||
}).catch(() => {
|
||||
alert('%i18n:common.tags.mk-signup.some-error%');
|
||||
|
||||
(window as any).grecaptcha.reset();
|
||||
this.recaptchaed = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
created() {
|
||||
(window as any).onRecaptchaed = () => {
|
||||
this.recaptchaed = true;
|
||||
};
|
||||
|
||||
(window as any).onRecaptchaExpired = () => {
|
||||
this.recaptchaed = false;
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const head = document.getElementsByTagName('head')[0];
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('src', 'https://www.google.com/recaptcha/api.js');
|
||||
head.appendChild(script);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.mk-signup
|
||||
min-width 302px
|
||||
|
||||
label
|
||||
display block
|
||||
margin 0 0 16px 0
|
||||
|
||||
> .caption
|
||||
margin 0 0 4px 0
|
||||
color #828888
|
||||
font-size 0.95em
|
||||
|
||||
> [data-fa]
|
||||
margin-right 0.25em
|
||||
color #96adac
|
||||
|
||||
> .info
|
||||
display block
|
||||
margin 4px 0
|
||||
font-size 0.8em
|
||||
|
||||
> [data-fa]
|
||||
margin-right 0.3em
|
||||
|
||||
&.username
|
||||
.profile-page-url-preview
|
||||
display block
|
||||
margin 4px 8px 0 4px
|
||||
font-size 0.8em
|
||||
color #888
|
||||
|
||||
&:empty
|
||||
display none
|
||||
|
||||
&:not(:empty) + .info
|
||||
margin-top 0
|
||||
|
||||
&.password
|
||||
.meter
|
||||
display block
|
||||
margin-top 8px
|
||||
width 100%
|
||||
height 8px
|
||||
|
||||
&[data-strength='']
|
||||
display none
|
||||
|
||||
&[data-strength='low']
|
||||
> .value
|
||||
background #d73612
|
||||
|
||||
&[data-strength='medium']
|
||||
> .value
|
||||
background #d7ca12
|
||||
|
||||
&[data-strength='high']
|
||||
> .value
|
||||
background #61bb22
|
||||
|
||||
> .value
|
||||
display block
|
||||
width 0%
|
||||
height 100%
|
||||
background transparent
|
||||
border-radius 4px
|
||||
transition all 0.1s ease
|
||||
|
||||
[type=text], [type=password]
|
||||
user-select text
|
||||
display inline-block
|
||||
cursor auto
|
||||
padding 0 12px
|
||||
margin 0
|
||||
width 100%
|
||||
line-height 44px
|
||||
font-size 1em
|
||||
color #333 !important
|
||||
background #fff !important
|
||||
outline none
|
||||
border solid 1px rgba(0, 0, 0, 0.1)
|
||||
border-radius 4px
|
||||
box-shadow 0 0 0 114514px #fff inset
|
||||
transition all .3s ease
|
||||
|
||||
&:hover
|
||||
border-color rgba(0, 0, 0, 0.2)
|
||||
transition all .1s ease
|
||||
|
||||
&:focus
|
||||
color $theme-color !important
|
||||
border-color $theme-color
|
||||
box-shadow 0 0 0 1024px #fff inset, 0 0 0 4px rgba($theme-color, 10%)
|
||||
transition all 0s ease
|
||||
|
||||
&:disabled
|
||||
opacity 0.5
|
||||
|
||||
.agree-tou
|
||||
padding 4px
|
||||
border-radius 4px
|
||||
|
||||
&:hover
|
||||
background #f4f4f4
|
||||
|
||||
&:active
|
||||
background #eee
|
||||
|
||||
&, *
|
||||
cursor pointer
|
||||
|
||||
p
|
||||
display inline
|
||||
color #555
|
||||
|
||||
button
|
||||
margin 0
|
||||
padding 16px
|
||||
width 100%
|
||||
font-size 1em
|
||||
color #fff
|
||||
background $theme-color
|
||||
border-radius 3px
|
||||
|
||||
&:hover
|
||||
background lighten($theme-color, 5%)
|
||||
|
||||
&:active
|
||||
background darken($theme-color, 5%)
|
||||
|
||||
</style>
|
42
src/client/app/common/views/components/special-message.vue
Normal file
42
src/client/app/common/views/components/special-message.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="mk-special-message">
|
||||
<p v-if="m == 1 && d == 1">%i18n:common.tags.mk-special-message.new-year%</p>
|
||||
<p v-if="m == 12 && d == 25">%i18n:common.tags.mk-special-message.christmas%</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
now: new Date()
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
d(): number {
|
||||
return this.now.getDate();
|
||||
},
|
||||
m(): number {
|
||||
return this.now.getMonth() + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.mk-special-message
|
||||
&:empty
|
||||
display none
|
||||
|
||||
> p
|
||||
margin 0
|
||||
padding 4px
|
||||
text-align center
|
||||
font-size 14px
|
||||
font-weight bold
|
||||
text-transform uppercase
|
||||
color #fff
|
||||
background #ff1036
|
||||
|
||||
</style>
|
86
src/client/app/common/views/components/stream-indicator.vue
Normal file
86
src/client/app/common/views/components/stream-indicator.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="mk-stream-indicator">
|
||||
<p v-if=" stream.state == 'initializing' ">
|
||||
%fa:spinner .pulse%
|
||||
<span>%i18n:common.tags.mk-stream-indicator.connecting%<mk-ellipsis/></span>
|
||||
</p>
|
||||
<p v-if=" stream.state == 'reconnecting' ">
|
||||
%fa:spinner .pulse%
|
||||
<span>%i18n:common.tags.mk-stream-indicator.reconnecting%<mk-ellipsis/></span>
|
||||
</p>
|
||||
<p v-if=" stream.state == 'connected' ">
|
||||
%fa:check%
|
||||
<span>%i18n:common.tags.mk-stream-indicator.connected%</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import * as anime from 'animejs';
|
||||
|
||||
export default Vue.extend({
|
||||
computed: {
|
||||
stream() {
|
||||
return (this as any).os.stream;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
(this as any).os.stream.on('_connected_', this.onConnected);
|
||||
(this as any).os.stream.on('_disconnected_', this.onDisconnected);
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.stream.state == 'connected') {
|
||||
this.$el.style.opacity = '0';
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
(this as any).os.stream.off('_connected_', this.onConnected);
|
||||
(this as any).os.stream.off('_disconnected_', this.onDisconnected);
|
||||
},
|
||||
methods: {
|
||||
onConnected() {
|
||||
setTimeout(() => {
|
||||
anime({
|
||||
targets: this.$el,
|
||||
opacity: 0,
|
||||
easing: 'linear',
|
||||
duration: 200
|
||||
});
|
||||
}, 1000);
|
||||
},
|
||||
onDisconnected() {
|
||||
anime({
|
||||
targets: this.$el,
|
||||
opacity: 1,
|
||||
easing: 'linear',
|
||||
duration: 100
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.mk-stream-indicator
|
||||
pointer-events none
|
||||
position fixed
|
||||
z-index 16384
|
||||
bottom 8px
|
||||
right 8px
|
||||
margin 0
|
||||
padding 6px 12px
|
||||
font-size 0.9em
|
||||
color #fff
|
||||
background rgba(0, 0, 0, 0.8)
|
||||
border-radius 4px
|
||||
|
||||
> p
|
||||
display block
|
||||
margin 0
|
||||
|
||||
> [data-fa]
|
||||
margin-right 0.25em
|
||||
|
||||
</style>
|
190
src/client/app/common/views/components/switch.vue
Normal file
190
src/client/app/common/views/components/switch.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div
|
||||
class="mk-switch"
|
||||
:class="{ disabled, checked }"
|
||||
role="switch"
|
||||
:aria-checked="checked"
|
||||
:aria-disabled="disabled"
|
||||
@click="switchValue"
|
||||
@mouseover="mouseenter"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@change="handleChange"
|
||||
ref="input"
|
||||
:disabled="disabled"
|
||||
@keydown.enter="switchValue"
|
||||
>
|
||||
<span class="button">
|
||||
<span :style="{ transform }"></span>
|
||||
</span>
|
||||
<span class="label">
|
||||
<span :aria-hidden="!checked">{{ text }}</span>
|
||||
<p :aria-hidden="!checked">
|
||||
<slot></slot>
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
text: String
|
||||
},/*
|
||||
created() {
|
||||
if (!~[true, false].indexOf(this.value)) {
|
||||
this.$emit('input', false);
|
||||
}
|
||||
},*/
|
||||
computed: {
|
||||
checked(): boolean {
|
||||
return this.value;
|
||||
},
|
||||
transform(): string {
|
||||
return this.checked ? 'translate3d(20px, 0, 0)' : '';
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value() {
|
||||
(this.$el).style.transition = 'all 0.3s';
|
||||
(this.$refs.input as any).checked = this.checked;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
(this.$refs.input as any).checked = this.checked;
|
||||
},
|
||||
methods: {
|
||||
mouseenter() {
|
||||
(this.$el).style.transition = 'all 0s';
|
||||
},
|
||||
handleChange() {
|
||||
(this.$el).style.transition = 'all 0.3s';
|
||||
this.$emit('input', !this.checked);
|
||||
this.$emit('change', !this.checked);
|
||||
this.$nextTick(() => {
|
||||
// set input's checked property
|
||||
// in case parent refuses to change component's value
|
||||
(this.$refs.input as any).checked = this.checked;
|
||||
});
|
||||
},
|
||||
switchValue() {
|
||||
!this.disabled && this.handleChange();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.mk-switch
|
||||
display flex
|
||||
margin 12px 0
|
||||
cursor pointer
|
||||
transition all 0.3s
|
||||
|
||||
> *
|
||||
user-select none
|
||||
|
||||
&.disabled
|
||||
opacity 0.6
|
||||
cursor not-allowed
|
||||
|
||||
&.checked
|
||||
> .button
|
||||
background-color $theme-color
|
||||
border-color $theme-color
|
||||
|
||||
> .label
|
||||
> span
|
||||
color $theme-color
|
||||
|
||||
&:hover
|
||||
> .label
|
||||
> span
|
||||
color darken($theme-color, 10%)
|
||||
|
||||
> .button
|
||||
background darken($theme-color, 10%)
|
||||
border-color darken($theme-color, 10%)
|
||||
|
||||
&:hover
|
||||
> .label
|
||||
> span
|
||||
color #2e3338
|
||||
|
||||
> .button
|
||||
background #ced2da
|
||||
border-color #ced2da
|
||||
|
||||
> input
|
||||
position absolute
|
||||
width 0
|
||||
height 0
|
||||
opacity 0
|
||||
margin 0
|
||||
|
||||
&:focus + .button
|
||||
&:after
|
||||
content ""
|
||||
pointer-events none
|
||||
position absolute
|
||||
top -5px
|
||||
right -5px
|
||||
bottom -5px
|
||||
left -5px
|
||||
border 2px solid rgba($theme-color, 0.3)
|
||||
border-radius 14px
|
||||
|
||||
> .button
|
||||
display inline-block
|
||||
margin 0
|
||||
width 40px
|
||||
min-width 40px
|
||||
height 20px
|
||||
min-height 20px
|
||||
background #dcdfe6
|
||||
border 1px solid #dcdfe6
|
||||
outline none
|
||||
border-radius 10px
|
||||
transition inherit
|
||||
|
||||
> *
|
||||
position absolute
|
||||
top 1px
|
||||
left 1px
|
||||
border-radius 100%
|
||||
transition transform 0.3s
|
||||
width 16px
|
||||
height 16px
|
||||
background-color #fff
|
||||
|
||||
> .label
|
||||
margin-left 8px
|
||||
display block
|
||||
font-size 15px
|
||||
cursor pointer
|
||||
transition inherit
|
||||
|
||||
> span
|
||||
display block
|
||||
line-height 20px
|
||||
color #4a535a
|
||||
transition inherit
|
||||
|
||||
> p
|
||||
margin 0
|
||||
//font-size 90%
|
||||
color #9daab3
|
||||
|
||||
</style>
|
76
src/client/app/common/views/components/time.vue
Normal file
76
src/client/app/common/views/components/time.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<time class="mk-time">
|
||||
<span v-if=" mode == 'relative' ">{{ relative }}</span>
|
||||
<span v-if=" mode == 'absolute' ">{{ absolute }}</span>
|
||||
<span v-if=" mode == 'detail' ">{{ absolute }} ({{ relative }})</span>
|
||||
</time>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
time: {
|
||||
type: [Date, String],
|
||||
required: true
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'relative'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tickId: null,
|
||||
now: new Date()
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
_time(): Date {
|
||||
return typeof this.time == 'string' ? new Date(this.time) : this.time;
|
||||
},
|
||||
absolute(): string {
|
||||
const time = this._time;
|
||||
return (
|
||||
time.getFullYear() + '年' +
|
||||
(time.getMonth() + 1) + '月' +
|
||||
time.getDate() + '日' +
|
||||
' ' +
|
||||
time.getHours() + '時' +
|
||||
time.getMinutes() + '分');
|
||||
},
|
||||
relative(): string {
|
||||
const time = this._time;
|
||||
const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/;
|
||||
return (
|
||||
ago >= 31536000 ? '%i18n:common.time.years_ago%' .replace('{}', (~~(ago / 31536000)).toString()) :
|
||||
ago >= 2592000 ? '%i18n:common.time.months_ago%' .replace('{}', (~~(ago / 2592000)).toString()) :
|
||||
ago >= 604800 ? '%i18n:common.time.weeks_ago%' .replace('{}', (~~(ago / 604800)).toString()) :
|
||||
ago >= 86400 ? '%i18n:common.time.days_ago%' .replace('{}', (~~(ago / 86400)).toString()) :
|
||||
ago >= 3600 ? '%i18n:common.time.hours_ago%' .replace('{}', (~~(ago / 3600)).toString()) :
|
||||
ago >= 60 ? '%i18n:common.time.minutes_ago%'.replace('{}', (~~(ago / 60)).toString()) :
|
||||
ago >= 10 ? '%i18n:common.time.seconds_ago%'.replace('{}', (~~(ago % 60)).toString()) :
|
||||
ago >= 0 ? '%i18n:common.time.just_now%' :
|
||||
ago < 0 ? '%i18n:common.time.future%' :
|
||||
'%i18n:common.time.unknown%');
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.mode == 'relative' || this.mode == 'detail') {
|
||||
this.tick();
|
||||
this.tickId = setInterval(this.tick, 1000);
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
if (this.mode === 'relative' || this.mode === 'detail') {
|
||||
clearInterval(this.tickId);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
tick() {
|
||||
this.now = new Date();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
49
src/client/app/common/views/components/timer.vue
Normal file
49
src/client/app/common/views/components/timer.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<time class="mk-time">
|
||||
{{ hh }}:{{ mm }}:{{ ss }}
|
||||
</time>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
time: {
|
||||
type: [Date, String],
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tickId: null,
|
||||
hh: null,
|
||||
mm: null,
|
||||
ss: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
_time(): Date {
|
||||
return typeof this.time == 'string' ? new Date(this.time) : this.time;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.tick();
|
||||
this.tickId = setInterval(this.tick, 1000);
|
||||
},
|
||||
destroyed() {
|
||||
clearInterval(this.tickId);
|
||||
},
|
||||
methods: {
|
||||
tick() {
|
||||
const now = new Date().getTime();
|
||||
const start = this._time.getTime();
|
||||
const ago = Math.floor((now - start) / 1000);
|
||||
|
||||
this.hh = Math.floor(ago / (60 * 60)).toString().padStart(2, '0');
|
||||
this.mm = Math.floor(ago / 60).toString().padStart(2, '0');
|
||||
this.ss = (ago % 60).toString().padStart(2, '0');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
66
src/client/app/common/views/components/twitter-setting.vue
Normal file
66
src/client/app/common/views/components/twitter-setting.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="mk-twitter-setting">
|
||||
<p>%i18n:common.tags.mk-twitter-setting.description%<a :href="`${docsUrl}/link-to-twitter`" target="_blank">%i18n:common.tags.mk-twitter-setting.detail%</a></p>
|
||||
<p class="account" v-if="os.i.account.twitter" :title="`Twitter ID: ${os.i.account.twitter.userId}`">%i18n:common.tags.mk-twitter-setting.connected-to%: <a :href="`https://twitter.com/${os.i.account.twitter.screenName}`" target="_blank">@{{ os.i.account.twitter.screenName }}</a></p>
|
||||
<p>
|
||||
<a :href="`${apiUrl}/connect/twitter`" target="_blank" @click.prevent="connect">{{ os.i.account.twitter ? '%i18n:common.tags.mk-twitter-setting.reconnect%' : '%i18n:common.tags.mk-twitter-setting.connect%' }}</a>
|
||||
<span v-if="os.i.account.twitter"> or </span>
|
||||
<a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="os.i.account.twitter" @click.prevent="disconnect">%i18n:common.tags.mk-twitter-setting.disconnect%</a>
|
||||
</p>
|
||||
<p class="id" v-if="os.i.account.twitter">Twitter ID: {{ os.i.account.twitter.userId }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { apiUrl, docsUrl } from '../../../config';
|
||||
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
form: null,
|
||||
apiUrl,
|
||||
docsUrl
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$watch('os.i', () => {
|
||||
if ((this as any).os.i.account.twitter) {
|
||||
if (this.form) this.form.close();
|
||||
}
|
||||
}, {
|
||||
deep: true
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
connect() {
|
||||
this.form = window.open(apiUrl + '/connect/twitter',
|
||||
'twitter_connect_window',
|
||||
'height=570, width=520');
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
window.open(apiUrl + '/disconnect/twitter',
|
||||
'twitter_disconnect_window',
|
||||
'height=570, width=520');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.mk-twitter-setting
|
||||
color #4a535a
|
||||
|
||||
.account
|
||||
border solid 1px #e1e8ed
|
||||
border-radius 4px
|
||||
padding 16px
|
||||
|
||||
a
|
||||
font-weight bold
|
||||
color inherit
|
||||
|
||||
.id
|
||||
color #8899a6
|
||||
</style>
|
212
src/client/app/common/views/components/uploader.vue
Normal file
212
src/client/app/common/views/components/uploader.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="mk-uploader">
|
||||
<ol v-if="uploads.length > 0">
|
||||
<li v-for="ctx in uploads" :key="ctx.id">
|
||||
<div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div>
|
||||
<p class="name">%fa:spinner .pulse%{{ ctx.name }}</p>
|
||||
<p class="status">
|
||||
<span class="initing" v-if="ctx.progress == undefined">%i18n:common.tags.mk-uploader.waiting%<mk-ellipsis/></span>
|
||||
<span class="kb" v-if="ctx.progress != undefined">{{ String(Math.floor(ctx.progress.value / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progress.max / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span>
|
||||
<span class="percentage" v-if="ctx.progress != undefined">{{ Math.floor((ctx.progress.value / ctx.progress.max) * 100) }}</span>
|
||||
</p>
|
||||
<progress v-if="ctx.progress != undefined && ctx.progress.value != ctx.progress.max" :value="ctx.progress.value" :max="ctx.progress.max"></progress>
|
||||
<div class="progress initing" v-if="ctx.progress == undefined"></div>
|
||||
<div class="progress waiting" v-if="ctx.progress != undefined && ctx.progress.value == ctx.progress.max"></div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { apiUrl } from '../../../config';
|
||||
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
uploads: []
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
upload(file, folder) {
|
||||
if (folder && typeof folder == 'object') folder = folder.id;
|
||||
|
||||
const id = Math.random();
|
||||
|
||||
const ctx = {
|
||||
id: id,
|
||||
name: file.name || 'untitled',
|
||||
progress: undefined,
|
||||
img: undefined
|
||||
};
|
||||
|
||||
this.uploads.push(ctx);
|
||||
this.$emit('change', this.uploads);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e: any) => {
|
||||
ctx.img = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
const data = new FormData();
|
||||
data.append('i', (this as any).os.i.account.token);
|
||||
data.append('file', file);
|
||||
|
||||
if (folder) data.append('folderId', folder);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', apiUrl + '/drive/files/create', true);
|
||||
xhr.onload = (e: any) => {
|
||||
const driveFile = JSON.parse(e.target.response);
|
||||
|
||||
this.$emit('uploaded', driveFile);
|
||||
|
||||
this.uploads = this.uploads.filter(x => x.id != id);
|
||||
this.$emit('change', this.uploads);
|
||||
};
|
||||
|
||||
xhr.upload.onprogress = e => {
|
||||
if (e.lengthComputable) {
|
||||
if (ctx.progress == undefined) ctx.progress = {};
|
||||
ctx.progress.max = e.total;
|
||||
ctx.progress.value = e.loaded;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.send(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@import '~const.styl'
|
||||
|
||||
.mk-uploader
|
||||
overflow auto
|
||||
|
||||
&:empty
|
||||
display none
|
||||
|
||||
> ol
|
||||
display block
|
||||
margin 0
|
||||
padding 0
|
||||
list-style none
|
||||
|
||||
> li
|
||||
display block
|
||||
margin 8px 0 0 0
|
||||
padding 0
|
||||
height 36px
|
||||
box-shadow 0 -1px 0 rgba($theme-color, 0.1)
|
||||
border-top solid 8px transparent
|
||||
|
||||
&:first-child
|
||||
margin 0
|
||||
box-shadow none
|
||||
border-top none
|
||||
|
||||
> .img
|
||||
display block
|
||||
position absolute
|
||||
top 0
|
||||
left 0
|
||||
width 36px
|
||||
height 36px
|
||||
background-size cover
|
||||
background-position center center
|
||||
|
||||
> .name
|
||||
display block
|
||||
position absolute
|
||||
top 0
|
||||
left 44px
|
||||
margin 0
|
||||
padding 0
|
||||
max-width 256px
|
||||
font-size 0.8em
|
||||
color rgba($theme-color, 0.7)
|
||||
white-space nowrap
|
||||
text-overflow ellipsis
|
||||
overflow hidden
|
||||
|
||||
> [data-fa]
|
||||
margin-right 4px
|
||||
|
||||
> .status
|
||||
display block
|
||||
position absolute
|
||||
top 0
|
||||
right 0
|
||||
margin 0
|
||||
padding 0
|
||||
font-size 0.8em
|
||||
|
||||
> .initing
|
||||
color rgba($theme-color, 0.5)
|
||||
|
||||
> .kb
|
||||
color rgba($theme-color, 0.5)
|
||||
|
||||
> .percentage
|
||||
display inline-block
|
||||
width 48px
|
||||
text-align right
|
||||
|
||||
color rgba($theme-color, 0.7)
|
||||
|
||||
&:after
|
||||
content '%'
|
||||
|
||||
> progress
|
||||
display block
|
||||
position absolute
|
||||
bottom 0
|
||||
right 0
|
||||
margin 0
|
||||
width calc(100% - 44px)
|
||||
height 8px
|
||||
background transparent
|
||||
border none
|
||||
border-radius 4px
|
||||
overflow hidden
|
||||
|
||||
&::-webkit-progress-value
|
||||
background $theme-color
|
||||
|
||||
&::-webkit-progress-bar
|
||||
background rgba($theme-color, 0.1)
|
||||
|
||||
> .progress
|
||||
display block
|
||||
position absolute
|
||||
bottom 0
|
||||
right 0
|
||||
margin 0
|
||||
width calc(100% - 44px)
|
||||
height 8px
|
||||
border none
|
||||
border-radius 4px
|
||||
background linear-gradient(
|
||||
45deg,
|
||||
lighten($theme-color, 30%) 25%,
|
||||
$theme-color 25%,
|
||||
$theme-color 50%,
|
||||
lighten($theme-color, 30%) 50%,
|
||||
lighten($theme-color, 30%) 75%,
|
||||
$theme-color 75%,
|
||||
$theme-color
|
||||
)
|
||||
background-size 32px 32px
|
||||
animation bg 1.5s linear infinite
|
||||
|
||||
&.initing
|
||||
opacity 0.3
|
||||
|
||||
@keyframes bg
|
||||
from {background-position: 0 0;}
|
||||
to {background-position: -64px 32px;}
|
||||
|
||||
</style>
|
142
src/client/app/common/views/components/url-preview.vue
Normal file
142
src/client/app/common/views/components/url-preview.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<iframe v-if="youtubeId" type="text/html" height="250"
|
||||
:src="`https://www.youtube.com/embed/${youtubeId}?origin=${misskeyUrl}`"
|
||||
frameborder="0"/>
|
||||
<div v-else>
|
||||
<a class="mk-url-preview" :href="url" target="_blank" :title="url" v-if="!fetching">
|
||||
<div class="thumbnail" v-if="thumbnail" :style="`background-image: url(${thumbnail})`"></div>
|
||||
<article>
|
||||
<header>
|
||||
<h1>{{ title }}</h1>
|
||||
</header>
|
||||
<p>{{ description }}</p>
|
||||
<footer>
|
||||
<img class="icon" v-if="icon" :src="icon"/>
|
||||
<p>{{ sitename }}</p>
|
||||
</footer>
|
||||
</article>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { url as misskeyUrl } from '../../../config';
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['url'],
|
||||
data() {
|
||||
return {
|
||||
fetching: true,
|
||||
title: null,
|
||||
description: null,
|
||||
thumbnail: null,
|
||||
icon: null,
|
||||
sitename: null,
|
||||
youtubeId: null,
|
||||
misskeyUrl
|
||||
};
|
||||
},
|
||||
created() {
|
||||
const url = new URL(this.url);
|
||||
|
||||
if (url.hostname == 'www.youtube.com') {
|
||||
this.youtubeId = url.searchParams.get('v');
|
||||
} else if (url.hostname == 'youtu.be') {
|
||||
this.youtubeId = url.pathname;
|
||||
} else {
|
||||
fetch('/api:url?url=' + this.url).then(res => {
|
||||
res.json().then(info => {
|
||||
this.title = info.title;
|
||||
this.description = info.description;
|
||||
this.thumbnail = info.thumbnail;
|
||||
this.icon = info.icon;
|
||||
this.sitename = info.sitename;
|
||||
|
||||
this.fetching = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
iframe
|
||||
width 100%
|
||||
|
||||
.mk-url-preview
|
||||
display block
|
||||
font-size 16px
|
||||
border solid 1px #eee
|
||||
border-radius 4px
|
||||
overflow hidden
|
||||
|
||||
&:hover
|
||||
text-decoration none
|
||||
border-color #ddd
|
||||
|
||||
> article > header > h1
|
||||
text-decoration underline
|
||||
|
||||
> .thumbnail
|
||||
position absolute
|
||||
width 100px
|
||||
height 100%
|
||||
background-position center
|
||||
background-size cover
|
||||
|
||||
& + article
|
||||
left 100px
|
||||
width calc(100% - 100px)
|
||||
|
||||
> article
|
||||
padding 16px
|
||||
|
||||
> header
|
||||
margin-bottom 8px
|
||||
|
||||
> h1
|
||||
margin 0
|
||||
font-size 1em
|
||||
color #555
|
||||
|
||||
> p
|
||||
margin 0
|
||||
color #777
|
||||
font-size 0.8em
|
||||
|
||||
> footer
|
||||
margin-top 8px
|
||||
height 16px
|
||||
|
||||
> img
|
||||
display inline-block
|
||||
width 16px
|
||||
height 16px
|
||||
margin-right 4px
|
||||
vertical-align top
|
||||
|
||||
> p
|
||||
display inline-block
|
||||
margin 0
|
||||
color #666
|
||||
font-size 0.8em
|
||||
line-height 16px
|
||||
vertical-align top
|
||||
|
||||
@media (max-width 500px)
|
||||
font-size 8px
|
||||
border none
|
||||
|
||||
> .thumbnail
|
||||
width 70px
|
||||
|
||||
& + article
|
||||
left 70px
|
||||
width calc(100% - 70px)
|
||||
|
||||
> article
|
||||
padding 8px
|
||||
|
||||
</style>
|
66
src/client/app/common/views/components/url.vue
Normal file
66
src/client/app/common/views/components/url.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<a class="mk-url" :href="url" :target="target">
|
||||
<span class="schema">{{ schema }}//</span>
|
||||
<span class="hostname">{{ hostname }}</span>
|
||||
<span class="port" v-if="port != ''">:{{ port }}</span>
|
||||
<span class="pathname" v-if="pathname != ''">{{ pathname }}</span>
|
||||
<span class="query">{{ query }}</span>
|
||||
<span class="hash">{{ hash }}</span>
|
||||
%fa:external-link-square-alt%
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['url', 'target'],
|
||||
data() {
|
||||
return {
|
||||
schema: null,
|
||||
hostname: null,
|
||||
port: null,
|
||||
pathname: null,
|
||||
query: null,
|
||||
hash: null
|
||||
};
|
||||
},
|
||||
created() {
|
||||
const url = new URL(this.url);
|
||||
|
||||
this.schema = url.protocol;
|
||||
this.hostname = url.hostname;
|
||||
this.port = url.port;
|
||||
this.pathname = url.pathname;
|
||||
this.query = url.search;
|
||||
this.hash = url.hash;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.mk-url
|
||||
word-break break-all
|
||||
|
||||
> [data-fa]
|
||||
padding-left 2px
|
||||
font-size .9em
|
||||
font-weight 400
|
||||
font-style normal
|
||||
|
||||
> .schema
|
||||
opacity 0.5
|
||||
|
||||
> .hostname
|
||||
font-weight bold
|
||||
|
||||
> .pathname
|
||||
opacity 0.8
|
||||
|
||||
> .query
|
||||
opacity 0.5
|
||||
|
||||
> .hash
|
||||
font-style italic
|
||||
|
||||
</style>
|
118
src/client/app/common/views/components/welcome-timeline.vue
Normal file
118
src/client/app/common/views/components/welcome-timeline.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="mk-welcome-timeline">
|
||||
<div v-for="post in posts">
|
||||
<router-link class="avatar-anchor" :to="`/@${getAcct(post.user)}`" v-user-preview="post.user.id">
|
||||
<img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=96`" alt="avatar"/>
|
||||
</router-link>
|
||||
<div class="body">
|
||||
<header>
|
||||
<router-link class="name" :to="`/@${getAcct(post.user)}`" v-user-preview="post.user.id">{{ post.user.name }}</router-link>
|
||||
<span class="username">@{{ getAcct(post.user) }}</span>
|
||||
<div class="info">
|
||||
<router-link class="created-at" :to="`/@${getAcct(post.user)}/${post.id}`">
|
||||
<mk-time :time="post.createdAt"/>
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
<div class="text">
|
||||
<mk-post-html :ast="post.ast"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import getAcct from '../../../../../common/user/get-acct';
|
||||
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
fetching: true,
|
||||
posts: []
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.fetch();
|
||||
},
|
||||
methods: {
|
||||
getAcct,
|
||||
fetch(cb?) {
|
||||
this.fetching = true;
|
||||
(this as any).api('posts', {
|
||||
reply: false,
|
||||
repost: false,
|
||||
media: false,
|
||||
poll: false,
|
||||
bot: false
|
||||
}).then(posts => {
|
||||
this.posts = posts;
|
||||
this.fetching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.mk-welcome-timeline
|
||||
background #fff
|
||||
|
||||
> div
|
||||
padding 16px
|
||||
overflow-wrap break-word
|
||||
font-size .9em
|
||||
color #4C4C4C
|
||||
border-bottom 1px solid rgba(0, 0, 0, 0.05)
|
||||
|
||||
&:after
|
||||
content ""
|
||||
display block
|
||||
clear both
|
||||
|
||||
> .avatar-anchor
|
||||
display block
|
||||
float left
|
||||
position -webkit-sticky
|
||||
position sticky
|
||||
top 16px
|
||||
|
||||
> img
|
||||
display block
|
||||
width 42px
|
||||
height 42px
|
||||
border-radius 6px
|
||||
|
||||
> .body
|
||||
float right
|
||||
width calc(100% - 42px)
|
||||
padding-left 12px
|
||||
|
||||
> header
|
||||
display flex
|
||||
align-items center
|
||||
margin-bottom 4px
|
||||
white-space nowrap
|
||||
|
||||
> .name
|
||||
display block
|
||||
margin 0 .5em 0 0
|
||||
padding 0
|
||||
overflow hidden
|
||||
font-weight bold
|
||||
text-overflow ellipsis
|
||||
color #627079
|
||||
|
||||
> .username
|
||||
margin 0 .5em 0 0
|
||||
color #ccc
|
||||
|
||||
> .info
|
||||
margin-left auto
|
||||
font-size 0.9em
|
||||
|
||||
> .created-at
|
||||
color #c0c0c0
|
||||
|
||||
</style>
|
Reference in New Issue
Block a user