v12 (#5712)
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com> Co-authored-by: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
This commit is contained in:
parent
a5955c1123
commit
f6154dc0af
871 changed files with 26140 additions and 71950 deletions
29
src/client/components/acct.vue
Normal file
29
src/client/components/acct.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<span class="mk-acct" v-once>
|
||||
<span class="name">@{{ user.username }}</span>
|
||||
<span class="host" v-if="user.host || detail || $store.state.settings.showFullAcct">@{{ user.host || host }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { toUnicode } from 'punycode';
|
||||
import { host } from '../config';
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['user', 'detail'],
|
||||
data() {
|
||||
return {
|
||||
host: toUnicode(host),
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-acct {
|
||||
> .host {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
443
src/client/components/autocomplete.vue
Normal file
443
src/client/components/autocomplete.vue
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
<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" alt=""/>
|
||||
<span class="name">
|
||||
<mk-user-name :user="user" :key="user.id"/>
|
||||
</span>
|
||||
<span class="username">@{{ user | acct }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
<ol class="hashtags" ref="suggests" v-if="hashtags.length > 0">
|
||||
<li v-for="hashtag in hashtags" @click="complete(type, hashtag)" @keydown="onKeydown" tabindex="-1">
|
||||
<span class="name">{{ hashtag }}</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" v-if="emoji.isCustomEmoji"><img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
|
||||
<span class="emoji" v-else-if="!useOsDefaultEmojis"><img :src="emoji.url" :alt="emoji.emoji"/></span>
|
||||
<span class="emoji" v-else>{{ emoji.emoji }}</span>
|
||||
<span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span>
|
||||
<span class="alias" v-if="emoji.aliasOf">({{ emoji.aliasOf }})</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { emojilist } from '../../misc/emojilist';
|
||||
import contains from '../scripts/contains';
|
||||
import { twemojiSvgBase } from '../../misc/twemoji-base';
|
||||
import { getStaticImageUrl } from '../scripts/get-static-image-url';
|
||||
|
||||
type EmojiDef = {
|
||||
emoji: string;
|
||||
name: string;
|
||||
aliasOf?: string;
|
||||
url?: string;
|
||||
isCustomEmoji?: boolean;
|
||||
};
|
||||
|
||||
const lib = emojilist.filter(x => x.category !== 'flags');
|
||||
|
||||
const char2file = (char: string) => {
|
||||
let codes = Array.from(char).map(x => x.codePointAt(0).toString(16));
|
||||
if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f');
|
||||
codes = codes.filter(x => x && x.length);
|
||||
return codes.join('-');
|
||||
};
|
||||
|
||||
const emjdb: EmojiDef[] = lib.map(x => ({
|
||||
emoji: x.char,
|
||||
name: x.name,
|
||||
aliasOf: null,
|
||||
url: `${twemojiSvgBase}/${char2file(x.char)}.svg`
|
||||
}));
|
||||
|
||||
for (const x of lib) {
|
||||
if (x.keywords) {
|
||||
for (const k of x.keywords) {
|
||||
emjdb.push({
|
||||
emoji: x.char,
|
||||
name: k,
|
||||
aliasOf: x.name,
|
||||
url: `${twemojiSvgBase}/${char2file(x.char)}.svg`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emjdb.sort((a, b) => a.name.length - b.name.length);
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['type', 'q', 'textarea', 'complete', 'close', 'x', 'y'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
getStaticImageUrl,
|
||||
fetching: true,
|
||||
users: [],
|
||||
hashtags: [],
|
||||
emojis: [],
|
||||
select: -1,
|
||||
emojilist,
|
||||
emojiDb: [] as EmojiDef[]
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
items(): HTMLCollection {
|
||||
return (this.$refs.suggests as Element).children;
|
||||
},
|
||||
|
||||
useOsDefaultEmojis(): boolean {
|
||||
return this.$store.state.device.useOsDefaultEmojis;
|
||||
}
|
||||
},
|
||||
|
||||
updated() {
|
||||
//#region 位置調整
|
||||
if (this.x + this.$el.offsetWidth > window.innerWidth) {
|
||||
this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px';
|
||||
} else {
|
||||
this.$el.style.left = this.x + 'px';
|
||||
}
|
||||
|
||||
if (this.y + this.$el.offsetHeight > window.innerHeight) {
|
||||
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() {
|
||||
//#region Construct Emoji DB
|
||||
const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || [];
|
||||
const emojiDefinitions: EmojiDef[] = [];
|
||||
|
||||
for (const x of customEmojis) {
|
||||
emojiDefinitions.push({
|
||||
name: x.name,
|
||||
emoji: `:${x.name}:`,
|
||||
url: x.url,
|
||||
isCustomEmoji: true
|
||||
});
|
||||
|
||||
if (x.aliases) {
|
||||
for (const alias of x.aliases) {
|
||||
emojiDefinitions.push({
|
||||
name: alias,
|
||||
aliasOf: x.name,
|
||||
emoji: `:${x.name}:`,
|
||||
url: x.url,
|
||||
isCustomEmoji: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emojiDefinitions.sort((a, b) => a.name.length - b.name.length);
|
||||
|
||||
this.emojiDb = emojiDefinitions.concat(emjdb);
|
||||
//#endregion
|
||||
|
||||
this.textarea.addEventListener('keydown', this.onKeydown);
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll('*'))) {
|
||||
el.addEventListener('mousedown', this.onMousedown);
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.exec();
|
||||
|
||||
this.$watch('q', () => {
|
||||
this.$nextTick(() => {
|
||||
this.exec();
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.textarea.removeEventListener('keydown', this.onKeydown);
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll('*'))) {
|
||||
el.removeEventListener('mousedown', this.onMousedown);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
exec() {
|
||||
this.select = -1;
|
||||
if (this.$refs.suggests) {
|
||||
for (const el of Array.from(this.items)) {
|
||||
el.removeAttribute('data-selected');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.type == 'user') {
|
||||
const cacheKey = `autocomplete:user:${this.q}`;
|
||||
const cache = sessionStorage.getItem(cacheKey);
|
||||
if (cache) {
|
||||
const users = JSON.parse(cache);
|
||||
this.users = users;
|
||||
this.fetching = false;
|
||||
} else {
|
||||
this.$root.api('users/search', {
|
||||
query: this.q,
|
||||
limit: 10,
|
||||
detail: false
|
||||
}).then(users => {
|
||||
this.users = users;
|
||||
this.fetching = false;
|
||||
|
||||
// キャッシュ
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(users));
|
||||
});
|
||||
}
|
||||
} else if (this.type == 'hashtag') {
|
||||
if (this.q == null || this.q == '') {
|
||||
this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]');
|
||||
this.fetching = false;
|
||||
} else {
|
||||
const cacheKey = `autocomplete:hashtag:${this.q}`;
|
||||
const cache = sessionStorage.getItem(cacheKey);
|
||||
if (cache) {
|
||||
const hashtags = JSON.parse(cache);
|
||||
this.hashtags = hashtags;
|
||||
this.fetching = false;
|
||||
} else {
|
||||
this.$root.api('hashtags/search', {
|
||||
query: this.q,
|
||||
limit: 30
|
||||
}).then(hashtags => {
|
||||
this.hashtags = hashtags;
|
||||
this.fetching = false;
|
||||
|
||||
// キャッシュ
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(hashtags));
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (this.type == 'emoji') {
|
||||
if (this.q == null || this.q == '') {
|
||||
this.emojis = this.emojiDb.filter(x => x.isCustomEmoji && !x.aliasOf).sort((a, b) => {
|
||||
var textA = a.name.toUpperCase();
|
||||
var textB = b.name.toUpperCase();
|
||||
return (textA < textB) ? -1 : (textA > textB) ? 1 : 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const matched = [];
|
||||
const max = 30;
|
||||
|
||||
this.emojiDb.some(x => {
|
||||
if (x.name.startsWith(this.q) && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
|
||||
return matched.length == max;
|
||||
});
|
||||
if (matched.length < max) {
|
||||
this.emojiDb.some(x => {
|
||||
if (x.name.startsWith(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
|
||||
return matched.length == max;
|
||||
});
|
||||
}
|
||||
if (matched.length < max) {
|
||||
this.emojiDb.some(x => {
|
||||
if (x.name.includes(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
|
||||
return matched.length == max;
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
for (const el of Array.from(this.items)) {
|
||||
el.removeAttribute('data-selected');
|
||||
}
|
||||
|
||||
this.items[this.select].setAttribute('data-selected', 'true');
|
||||
(this.items[this.select] as any).focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-autocomplete {
|
||||
position: fixed;
|
||||
z-index: 65535;
|
||||
max-width: 100%;
|
||||
margin-top: calc(1em + 8px);
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
border: solid 1px rgba(#000, 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: flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
font-size: 0.9em;
|
||||
cursor: default;
|
||||
|
||||
&, * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
* {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--yrnqrguo);
|
||||
}
|
||||
|
||||
&[data-selected='true'] {
|
||||
background: var(--accent);
|
||||
|
||||
&, * {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken);
|
||||
|
||||
&, * {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .users > li {
|
||||
|
||||
.avatar {
|
||||
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: var(--autocompleteItemText);
|
||||
}
|
||||
|
||||
.username {
|
||||
color: var(--autocompleteItemTextSub);
|
||||
}
|
||||
}
|
||||
|
||||
> .hashtags > li {
|
||||
|
||||
.name {
|
||||
color: var(--autocompleteItemText);
|
||||
}
|
||||
}
|
||||
|
||||
> .emojis > li {
|
||||
|
||||
.emoji {
|
||||
display: inline-block;
|
||||
margin: 0 4px 0 0;
|
||||
width: 24px;
|
||||
|
||||
> img {
|
||||
width: 24px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--autocompleteItemText);
|
||||
}
|
||||
|
||||
.alias {
|
||||
margin: 0 0 0 8px;
|
||||
color: var(--autocompleteItemTextSub);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
116
src/client/components/avatar.vue
Normal file
116
src/client/components/avatar.vue
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<template>
|
||||
<span class="mk-avatar" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick">
|
||||
<span class="inner" :style="icon"></span>
|
||||
</span>
|
||||
<span class="mk-avatar" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick">
|
||||
<span class="inner" :style="icon"></span>
|
||||
</span>
|
||||
<router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id">
|
||||
<span class="inner" :style="icon"></span>
|
||||
</router-link>
|
||||
<router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview">
|
||||
<span class="inner" :style="icon"></span>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { getStaticImageUrl } from '../scripts/get-static-image-url';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
target: {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
disableLink: {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
disablePreview: {
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cat(): boolean {
|
||||
return this.user.isCat;
|
||||
},
|
||||
url(): string {
|
||||
return this.$store.state.device.disableShowingAnimatedImages
|
||||
? getStaticImageUrl(this.user.avatarUrl)
|
||||
: this.user.avatarUrl;
|
||||
},
|
||||
icon(): any {
|
||||
return {
|
||||
backgroundColor: this.user.avatarColor,
|
||||
backgroundImage: `url(${this.url})`,
|
||||
};
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'user.avatarColor'() {
|
||||
this.$el.style.color = this.user.avatarColor;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.user.avatarColor) {
|
||||
this.$el.style.color = this.user.avatarColor;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onClick(e) {
|
||||
this.$emit('click', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-avatar {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
flex-shrink: 0;
|
||||
border-radius: 100%;
|
||||
line-height: 16px;
|
||||
|
||||
&.cat {
|
||||
&:before, &:after {
|
||||
background: #df548f;
|
||||
border: solid 4px currentColor;
|
||||
box-sizing: border-box;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
&:before {
|
||||
border-radius: 0 75% 75%;
|
||||
transform: rotate(37.5deg) skew(30deg);
|
||||
}
|
||||
|
||||
&:after {
|
||||
border-radius: 75% 0 75% 75%;
|
||||
transform: rotate(-37.5deg) skew(-30deg);
|
||||
}
|
||||
}
|
||||
|
||||
.inner {
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
border-radius: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
src/client/components/avatars.vue
Normal file
27
src/client/components/avatars.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<div>
|
||||
<mk-avatar v-for="user in us" :user="user" :key="user.id" style="width:32px;height:32px;"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
userIds: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
us: []
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
this.us = await this.$root.api('users/show', {
|
||||
userIds: this.userIds
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
34
src/client/components/code-core.vue
Normal file
34
src/client/components/code-core.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<x-prism :inline="inline" :language="prismLang">{{ code }}</x-prism>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import 'prismjs';
|
||||
import 'prismjs/themes/prism-okaidia.css';
|
||||
import XPrism from 'vue-prism-component';
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XPrism
|
||||
},
|
||||
props: {
|
||||
code: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
lang: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
prismLang() {
|
||||
return Prism.languages[this.lang] ? this.lang : 'js';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
26
src/client/components/code.vue
Normal file
26
src/client/components/code.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<x-code :code="code" :lang="lang" :inline="inline"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XCode: () => import('./code-core.vue').then(m => m.default)
|
||||
},
|
||||
props: {
|
||||
code: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
lang: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
73
src/client/components/cw-button.vue
Normal file
73
src/client/components/cw-button.vue
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<button class="nrvgflfuaxwgkxoynpnumyookecqrrvh _button" @click="toggle">
|
||||
<b>{{ value ? this.$t('_cw.hide') : this.$t('_cw.show') }}</b>
|
||||
<span v-if="!value">{{ this.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import { length } from 'stringz';
|
||||
import { concat } from '../../prelude/array';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
note: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
label(): string {
|
||||
return concat([
|
||||
this.note.text ? [this.$t('_cw.chars', { count: length(this.note.text) })] : [],
|
||||
this.note.files && this.note.files.length !== 0 ? [this.$t('_cw.files', { count: this.note.files.length }) ] : [],
|
||||
this.note.poll != null ? [this.$t('_cw.poll')] : []
|
||||
] as string[][]).join(' / ');
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
length,
|
||||
|
||||
toggle() {
|
||||
this.$emit('input', !this.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nrvgflfuaxwgkxoynpnumyookecqrrvh {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.7em;
|
||||
color: var(--cwFg);
|
||||
background: var(--cwBg);
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: var(--cwHoverBg);
|
||||
}
|
||||
|
||||
> span {
|
||||
margin-left: 4px;
|
||||
|
||||
&:before {
|
||||
content: '(';
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: ')';
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
94
src/client/components/date-separated-list.vue
Normal file
94
src/client/components/date-separated-list.vue
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<template>
|
||||
<sequential-entrance class="sqadhkmv" ref="list" :direction="direction">
|
||||
<template v-for="(item, i) in items">
|
||||
<slot :item="item" :i="i"></slot>
|
||||
<div class="separator" :key="item.id + '_date'" :data-index="i" v-if="i != items.length - 1 && new Date(item.createdAt).getDate() != new Date(items[i + 1].createdAt).getDate()">
|
||||
<p class="date">
|
||||
<span><fa class="icon" :icon="faAngleUp"/>{{ getDateText(item.createdAt) }}</span>
|
||||
<span>{{ getDateText(items[i + 1].createdAt) }}<fa class="icon" :icon="faAngleDown"/></span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</sequential-entrance>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faAngleUp, faAngleDown
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
getDateText(time: string) {
|
||||
const date = new Date(time).getDate();
|
||||
const month = new Date(time).getMonth() + 1;
|
||||
return this.$t('monthAndDay', {
|
||||
month: month.toString(),
|
||||
day: date.toString()
|
||||
});
|
||||
},
|
||||
|
||||
focus() {
|
||||
this.$refs.list.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sqadhkmv {
|
||||
> .separator {
|
||||
text-align: center;
|
||||
|
||||
> .date {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
border-radius: 64px;
|
||||
background: var(--dateLabelBg);
|
||||
color: var(--dateLabelFg);
|
||||
|
||||
> span {
|
||||
&:first-child {
|
||||
margin-right: 8px;
|
||||
|
||||
> .icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: 8px;
|
||||
|
||||
> .icon {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
320
src/client/components/dialog.vue
Normal file
320
src/client/components/dialog.vue
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
<template>
|
||||
<div class="mk-dialog" :class="{ iconOnly }">
|
||||
<transition name="bg-fade" appear>
|
||||
<div class="bg" ref="bg" @click="onBgClick" v-if="show"></div>
|
||||
</transition>
|
||||
<transition name="dialog" appear @after-leave="() => { destroyDom(); }">
|
||||
<div class="main" ref="main" v-if="show">
|
||||
<template v-if="type == 'signin'">
|
||||
<mk-signin/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="icon" v-if="icon">
|
||||
<fa :icon="icon"/>
|
||||
</div>
|
||||
<div class="icon" v-else-if="!input && !select && !user" :class="type">
|
||||
<fa :icon="faCheck" v-if="type === 'success'"/>
|
||||
<fa :icon="faTimesCircle" v-if="type === 'error'"/>
|
||||
<fa :icon="faExclamationTriangle" v-if="type === 'warning'"/>
|
||||
<fa :icon="faInfoCircle" v-if="type === 'info'"/>
|
||||
<fa :icon="faQuestionCircle" v-if="type === 'question'"/>
|
||||
<fa :icon="faSpinner" pulse v-if="type === 'waiting'"/>
|
||||
</div>
|
||||
<header v-if="title" v-html="title"></header>
|
||||
<header v-if="title == null && user">{{ $t('enterUsername') }}</header>
|
||||
<div class="body" v-if="text" v-html="text"></div>
|
||||
<mk-input v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></mk-input>
|
||||
<mk-input v-if="user" v-model="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></mk-input>
|
||||
<mk-select v-if="select" v-model="selectedValue" autofocus>
|
||||
<template v-if="select.items">
|
||||
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
|
||||
<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
|
||||
</optgroup>
|
||||
</template>
|
||||
</mk-select>
|
||||
<div class="buttons" v-if="!iconOnly && (showOkButton || showCancelButton) && !actions">
|
||||
<mk-button inline @click="ok" v-if="showOkButton" primary :autofocus="!input && !select && !user" :disabled="!canOk">{{ (showCancelButton || input || select || user) ? $t('ok') : $t('gotIt') }}</mk-button>
|
||||
<mk-button inline @click="cancel" v-if="showCancelButton || input || select || user">{{ $t('cancel') }}</mk-button>
|
||||
</div>
|
||||
<div class="buttons" v-if="actions">
|
||||
<mk-button v-for="action in actions" inline @click="() => { action.callback(); close(); }" :primary="action.primary" :key="action.text">{{ action.text }}</mk-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faSpinner, faInfoCircle, faExclamationTriangle, faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faTimesCircle, faQuestionCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import MkButton from './ui/button.vue';
|
||||
import MkInput from './ui/input.vue';
|
||||
import MkSelect from './ui/select.vue';
|
||||
import parseAcct from '../../misc/acct/parse';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
MkInput,
|
||||
MkSelect,
|
||||
},
|
||||
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'info'
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
input: {
|
||||
required: false
|
||||
},
|
||||
select: {
|
||||
required: false
|
||||
},
|
||||
user: {
|
||||
required: false
|
||||
},
|
||||
icon: {
|
||||
required: false
|
||||
},
|
||||
actions: {
|
||||
required: false
|
||||
},
|
||||
showOkButton: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showCancelButton: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
cancelableByBgClick: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
iconOnly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
autoClose: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
show: true,
|
||||
inputValue: this.input && this.input.default ? this.input.default : null,
|
||||
userInputValue: null,
|
||||
selectedValue: this.select ? this.select.default ? this.select.default : this.select.items ? this.select.items[0].value : this.select.groupedItems[0].items[0].value : null,
|
||||
canOk: true,
|
||||
faTimesCircle, faQuestionCircle, faSpinner, faInfoCircle, faExclamationTriangle, faCheck
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
userInputValue() {
|
||||
if (this.user) {
|
||||
this.$root.api('users/show', parseAcct(this.userInputValue)).then(u => {
|
||||
this.canOk = u != null;
|
||||
}).catch(() => {
|
||||
this.canOk = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.user) this.canOk = false;
|
||||
|
||||
if (this.autoClose) {
|
||||
setTimeout(() => {
|
||||
this.close();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.onKeydown);
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keydown', this.onKeydown);
|
||||
},
|
||||
|
||||
methods: {
|
||||
async ok() {
|
||||
if (!this.canOk) return;
|
||||
if (!this.showOkButton) return;
|
||||
|
||||
if (this.user) {
|
||||
const user = await this.$root.api('users/show', parseAcct(this.userInputValue));
|
||||
if (user) {
|
||||
this.$emit('ok', user);
|
||||
this.close();
|
||||
}
|
||||
} else {
|
||||
const result =
|
||||
this.input ? this.inputValue :
|
||||
this.select ? this.selectedValue :
|
||||
true;
|
||||
this.$emit('ok', result);
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
|
||||
cancel() {
|
||||
this.$emit('cancel');
|
||||
this.close();
|
||||
},
|
||||
|
||||
close() {
|
||||
if (!this.show) return;
|
||||
this.show = false;
|
||||
this.$el.style.pointerEvents = 'none';
|
||||
(this.$refs.bg as any).style.pointerEvents = 'none';
|
||||
(this.$refs.main as any).style.pointerEvents = 'none';
|
||||
},
|
||||
|
||||
onBgClick() {
|
||||
if (this.cancelableByBgClick) {
|
||||
this.cancel();
|
||||
}
|
||||
},
|
||||
|
||||
onKeydown(e) {
|
||||
if (e.which === 27) { // ESC
|
||||
this.cancel();
|
||||
}
|
||||
},
|
||||
|
||||
onInputKeydown(e) {
|
||||
if (e.which === 13) { // Enter
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dialog-enter-active, .dialog-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s !important;
|
||||
}
|
||||
.dialog-enter, .dialog-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.bg-fade-enter-active, .bg-fade-leave-active {
|
||||
transition: opacity 0.3s !important;
|
||||
}
|
||||
.bg-fade-enter, .bg-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mk-dialog {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
z-index: 30000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.iconOnly > .main {
|
||||
min-width: 0;
|
||||
width: initial;
|
||||
}
|
||||
|
||||
> .bg {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
}
|
||||
|
||||
> .main {
|
||||
display: block;
|
||||
position: fixed;
|
||||
margin: auto;
|
||||
padding: 32px;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
box-sizing: border-box;
|
||||
width: calc(100% - 32px);
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
|
||||
> .icon {
|
||||
font-size: 32px;
|
||||
|
||||
&.success {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: #ec4137;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: #ecb637;
|
||||
}
|
||||
|
||||
> * {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
& + header {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
> header {
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
|
||||
& + .body {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .body {
|
||||
margin: 16px 0 0 0;
|
||||
}
|
||||
|
||||
> .buttons {
|
||||
margin-top: 16px;
|
||||
|
||||
> * {
|
||||
margin: 0 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
188
src/client/components/drive-file-thumbnail.vue
Normal file
188
src/client/components/drive-file-thumbnail.vue
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<template>
|
||||
<div class="zdjebgpv" :class="{ detail }" ref="thumbnail" :style="`background-color: ${ background }`">
|
||||
<img
|
||||
:src="file.url"
|
||||
:alt="file.name"
|
||||
:title="file.name"
|
||||
@load="onThumbnailLoaded"
|
||||
v-if="detail && is === 'image'"/>
|
||||
<video
|
||||
:src="file.url"
|
||||
ref="volumectrl"
|
||||
preload="metadata"
|
||||
controls
|
||||
v-else-if="detail && is === 'video'"/>
|
||||
<img :src="file.thumbnailUrl" alt="" @load="onThumbnailLoaded" :style="`object-fit: ${ fit }`" v-else-if="isThumbnailAvailable"/>
|
||||
<fa :icon="faFileImage" class="icon" v-else-if="is === 'image'"/>
|
||||
<fa :icon="faFileVideo" class="icon" v-else-if="is === 'video'"/>
|
||||
|
||||
<audio
|
||||
:src="file.url"
|
||||
ref="volumectrl"
|
||||
preload="metadata"
|
||||
controls
|
||||
v-else-if="detail && is === 'audio'"/>
|
||||
<fa :icon="faMusic" class="icon" v-else-if="is === 'audio' || is === 'midi'"/>
|
||||
|
||||
<fa :icon="faFileCsv" class="icon" v-else-if="is === 'csv'"/>
|
||||
<fa :icon="faFilePdf" class="icon" v-else-if="is === 'pdf'"/>
|
||||
<fa :icon="faFileAlt" class="icon" v-else-if="is === 'textfile'"/>
|
||||
<fa :icon="faFileArchive" class="icon" v-else-if="is === 'archive'"/>
|
||||
<fa :icon="faFile" class="icon" v-else/>
|
||||
|
||||
<fa :icon="faFilm" class="icon-sub" v-if="!detail && isThumbnailAvailable && is === 'video'"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import {
|
||||
faFile,
|
||||
faFileAlt,
|
||||
faFileImage,
|
||||
faMusic,
|
||||
faFileVideo,
|
||||
faFileCsv,
|
||||
faFilePdf,
|
||||
faFileArchive,
|
||||
faFilm
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
file: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
fit: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'cover'
|
||||
},
|
||||
detail: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isContextmenuShowing: false,
|
||||
isDragging: false,
|
||||
|
||||
faFile,
|
||||
faFileAlt,
|
||||
faFileImage,
|
||||
faMusic,
|
||||
faFileVideo,
|
||||
faFileCsv,
|
||||
faFilePdf,
|
||||
faFileArchive,
|
||||
faFilm
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
is(): 'image' | 'video' | 'midi' | 'audio' | 'csv' | 'pdf' | 'textfile' | 'archive' | 'unknown' {
|
||||
if (this.file.type.startsWith('image/')) return 'image';
|
||||
if (this.file.type.startsWith('video/')) return 'video';
|
||||
if (this.file.type === 'audio/midi') return 'midi';
|
||||
if (this.file.type.startsWith('audio/')) return 'audio';
|
||||
if (this.file.type.endsWith('/csv')) return 'csv';
|
||||
if (this.file.type.endsWith('/pdf')) return 'pdf';
|
||||
if (this.file.type.startsWith('text/')) return 'textfile';
|
||||
if ([
|
||||
"application/zip",
|
||||
"application/x-cpio",
|
||||
"application/x-bzip",
|
||||
"application/x-bzip2",
|
||||
"application/java-archive",
|
||||
"application/x-rar-compressed",
|
||||
"application/x-tar",
|
||||
"application/gzip",
|
||||
"application/x-7z-compressed"
|
||||
].some(e => e === this.file.type)) return 'archive';
|
||||
return 'unknown';
|
||||
},
|
||||
isThumbnailAvailable(): boolean {
|
||||
return this.file.thumbnailUrl
|
||||
? (this.is === 'image' || this.is === 'video')
|
||||
: false;
|
||||
},
|
||||
background(): string {
|
||||
return this.file.properties.avgColor || 'transparent';
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const audioTag = this.$refs.volumectrl as HTMLAudioElement;
|
||||
if (audioTag) audioTag.volume = this.$store.state.device.mediaVolume;
|
||||
},
|
||||
methods: {
|
||||
onThumbnailLoaded() {
|
||||
if (this.file.properties.avgColor) {
|
||||
this.$refs.thumbnail.style.backgroundColor = 'transparent';
|
||||
}
|
||||
},
|
||||
volumechange() {
|
||||
const audioTag = this.$refs.volumectrl as HTMLAudioElement;
|
||||
this.$store.commit('device/set', { key: 'mediaVolume', value: audioTag.volume });
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.zdjebgpv {
|
||||
display: flex;
|
||||
|
||||
> img,
|
||||
> .icon {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> .icon-sub {
|
||||
position: absolute;
|
||||
width: 30%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
right: 4%;
|
||||
bottom: 4%;
|
||||
}
|
||||
|
||||
> * {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&:not(.detail) {
|
||||
> img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
height: 65%;
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
> video,
|
||||
> audio {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.detail {
|
||||
> .icon {
|
||||
height: 100px;
|
||||
width: 100px;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
> *:not(.icon) {
|
||||
max-height: 300px;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
53
src/client/components/drive-window.vue
Normal file
53
src/client/components/drive-window.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<template>
|
||||
<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="selected.length === 0" @ok="ok()">
|
||||
<template #header>{{ multiple ? $t('selectFiles') : $t('selectFile') }}<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length | number }})</span></template>
|
||||
<div>
|
||||
<x-drive :multiple="multiple" @change-selection="onChangeSelection" :select-mode="true"/>
|
||||
</div>
|
||||
</x-window>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import XDrive from './drive.vue';
|
||||
import XWindow from './window.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XDrive,
|
||||
XWindow,
|
||||
},
|
||||
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selected: []
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
ok() {
|
||||
this.$emit('selected', this.selected);
|
||||
this.$refs.window.close();
|
||||
},
|
||||
|
||||
onChangeSelection(files) {
|
||||
this.selected = files;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
368
src/client/components/drive.file.vue
Normal file
368
src/client/components/drive.file.vue
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
<template>
|
||||
<div class="ncvczrfv"
|
||||
:data-is-selected="isSelected"
|
||||
@click="onClick"
|
||||
draggable="true"
|
||||
@dragstart="onDragstart"
|
||||
@dragend="onDragend"
|
||||
:title="title"
|
||||
>
|
||||
<div class="label" v-if="$store.state.i.avatarId == file.id">
|
||||
<img src="/assets/label.svg"/>
|
||||
<p>{{ $t('avatar') }}</p>
|
||||
</div>
|
||||
<div class="label" v-if="$store.state.i.bannerId == file.id">
|
||||
<img src="/assets/label.svg"/>
|
||||
<p>{{ $t('banner') }}</p>
|
||||
</div>
|
||||
<div class="label red" v-if="file.isSensitive">
|
||||
<img src="/assets/label-red.svg"/>
|
||||
<p>{{ $t('nsfw') }}</p>
|
||||
</div>
|
||||
|
||||
<x-file-thumbnail class="thumbnail" :file="file" fit="contain"/>
|
||||
|
||||
<p class="name">
|
||||
<span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
|
||||
<span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import copyToClipboard from '../scripts/copy-to-clipboard';
|
||||
//import updateAvatar from '../api/update-avatar';
|
||||
//import updateBanner from '../api/update-banner';
|
||||
import XFileThumbnail from './drive-file-thumbnail.vue';
|
||||
import { faDownload, faLink, faICursor, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
props: {
|
||||
file: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
selectMode: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
|
||||
components: {
|
||||
XFileThumbnail
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isDragging: false
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
browser(): any {
|
||||
return this.$parent;
|
||||
},
|
||||
isSelected(): boolean {
|
||||
return this.browser.selectedFiles.some(f => f.id == this.file.id);
|
||||
},
|
||||
title(): string {
|
||||
return `${this.file.name}\n${this.file.type} ${Vue.filter('bytes')(this.file.size)}`;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClick(ev) {
|
||||
if (this.selectMode) {
|
||||
this.browser.chooseFile(this.file);
|
||||
} else {
|
||||
this.$root.menu({
|
||||
items: [{
|
||||
type: 'item',
|
||||
text: this.$t('rename'),
|
||||
icon: faICursor,
|
||||
action: this.rename
|
||||
}, {
|
||||
type: 'item',
|
||||
text: this.file.isSensitive ? this.$t('unmarkAsSensitive') : this.$t('markAsSensitive'),
|
||||
icon: this.file.isSensitive ? faEye : faEyeSlash,
|
||||
action: this.toggleSensitive
|
||||
}, null, {
|
||||
type: 'item',
|
||||
text: this.$t('copyUrl'),
|
||||
icon: faLink,
|
||||
action: this.copyUrl
|
||||
}, {
|
||||
type: 'a',
|
||||
href: this.file.url,
|
||||
target: '_blank',
|
||||
text: this.$t('download'),
|
||||
icon: faDownload,
|
||||
download: this.file.name
|
||||
}, null, {
|
||||
type: 'item',
|
||||
text: this.$t('delete'),
|
||||
icon: faTrashAlt,
|
||||
action: this.deleteFile
|
||||
}, null, {
|
||||
type: 'nest',
|
||||
text: this.$t('contextmenu.else-files'),
|
||||
menu: [{
|
||||
type: 'item',
|
||||
text: this.$t('contextmenu.set-as-avatar'),
|
||||
action: this.setAsAvatar
|
||||
}, {
|
||||
type: 'item',
|
||||
text: this.$t('contextmenu.set-as-banner'),
|
||||
action: this.setAsBanner
|
||||
}]
|
||||
}],
|
||||
source: ev.currentTarget || ev.target,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onDragstart(e) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('mk_drive_file', JSON.stringify(this.file));
|
||||
this.isDragging = true;
|
||||
|
||||
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
|
||||
// (=あなたの子供が、ドラッグを開始しましたよ)
|
||||
this.browser.isDragSource = true;
|
||||
},
|
||||
|
||||
onDragend(e) {
|
||||
this.isDragging = false;
|
||||
this.browser.isDragSource = false;
|
||||
},
|
||||
|
||||
onThumbnailLoaded() {
|
||||
if (this.file.properties.avgColor) {
|
||||
anime({
|
||||
targets: this.$refs.thumbnail,
|
||||
backgroundColor: 'transparent', // TODO fade
|
||||
duration: 100,
|
||||
easing: 'linear'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
rename() {
|
||||
this.$root.dialog({
|
||||
title: this.$t('contextmenu.rename-file'),
|
||||
input: {
|
||||
placeholder: this.$t('contextmenu.input-new-file-name'),
|
||||
default: this.file.name,
|
||||
allowEmpty: false
|
||||
}
|
||||
}).then(({ canceled, result: name }) => {
|
||||
if (canceled) return;
|
||||
this.$root.api('drive/files/update', {
|
||||
fileId: this.file.id,
|
||||
name: name
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
toggleSensitive() {
|
||||
this.$root.api('drive/files/update', {
|
||||
fileId: this.file.id,
|
||||
isSensitive: !this.file.isSensitive
|
||||
});
|
||||
},
|
||||
|
||||
copyUrl() {
|
||||
copyToClipboard(this.file.url);
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
},
|
||||
|
||||
setAsAvatar() {
|
||||
updateAvatar(this.$root)(this.file);
|
||||
},
|
||||
|
||||
setAsBanner() {
|
||||
updateBanner(this.$root)(this.file);
|
||||
},
|
||||
|
||||
addApp() {
|
||||
alert('not implemented yet');
|
||||
},
|
||||
|
||||
async deleteFile() {
|
||||
const { canceled } = await this.$root.dialog({
|
||||
type: 'warning',
|
||||
text: this.$t('driveFileDeleteConfirm', { name: this.file.name }),
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
this.$root.api('drive/files/delete', {
|
||||
fileId: this.file.id
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ncvczrfv {
|
||||
position: relative;
|
||||
padding: 8px 0 0 0;
|
||||
min-height: 180px;
|
||||
border-radius: 4px;
|
||||
|
||||
&, * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(#000, 0.05);
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #0b65a5;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #c12113;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(#000, 0.1);
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #0b588c;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #ce2212;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-is-selected] {
|
||||
background: var(--accent);
|
||||
|
||||
&:hover {
|
||||
background: var(--accentLighten);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken);
|
||||
}
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .name {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
> .label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background: #0c7ac9;
|
||||
}
|
||||
|
||||
&:before {
|
||||
top: 0;
|
||||
left: 57px;
|
||||
width: 28px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
top: 57px;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #c12113;
|
||||
}
|
||||
}
|
||||
|
||||
> img {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
> p {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 19px;
|
||||
left: -28px;
|
||||
width: 120px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 28px;
|
||||
color: #fff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
margin: auto;
|
||||
color: var(--driveFileIcon);
|
||||
}
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 0.8em;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
color: var(--fg);
|
||||
overflow: hidden;
|
||||
|
||||
> .ext {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
281
src/client/components/drive.folder.vue
Normal file
281
src/client/components/drive.folder.vue
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
<template>
|
||||
<div class="rghtznwe"
|
||||
:data-draghover="draghover"
|
||||
@click="onClick"
|
||||
@mouseover="onMouseover"
|
||||
@mouseout="onMouseout"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@dragenter.prevent="onDragenter"
|
||||
@dragleave="onDragleave"
|
||||
@drop.prevent.stop="onDrop"
|
||||
draggable="true"
|
||||
@dragstart="onDragstart"
|
||||
@dragend="onDragend"
|
||||
:title="title"
|
||||
>
|
||||
<p class="name">
|
||||
<template v-if="hover"><fa :icon="faFolderOpen" fixed-width/></template>
|
||||
<template v-if="!hover"><fa :icon="faFolder" fixed-width/></template>
|
||||
{{ folder.name }}
|
||||
</p>
|
||||
<p class="upload" v-if="$store.state.settings.uploadFolder == folder.id">
|
||||
{{ $t('upload-folder') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faFolder, faFolderOpen } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
props: {
|
||||
folder: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
hover: false,
|
||||
draghover: false,
|
||||
isDragging: false,
|
||||
faFolder, faFolderOpen
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
browser(): any {
|
||||
return this.$parent;
|
||||
},
|
||||
title(): string {
|
||||
return this.folder.name;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
this.browser.move(this.folder);
|
||||
},
|
||||
|
||||
onMouseover() {
|
||||
this.hover = true;
|
||||
},
|
||||
|
||||
onMouseout() {
|
||||
this.hover = false
|
||||
},
|
||||
|
||||
onDragover(e) {
|
||||
// 自分自身がドラッグされている場合
|
||||
if (this.isDragging) {
|
||||
// 自分自身にはドロップさせない
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const isFile = e.dataTransfer.items[0].kind == 'file';
|
||||
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
|
||||
const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder';
|
||||
|
||||
if (isFile || isDriveFile || isDriveFolder) {
|
||||
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
|
||||
} else {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
onDragenter() {
|
||||
if (!this.isDragging) this.draghover = true;
|
||||
},
|
||||
|
||||
onDragleave() {
|
||||
this.draghover = false;
|
||||
},
|
||||
|
||||
onDrop(e) {
|
||||
this.draghover = false;
|
||||
|
||||
// ファイルだったら
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(e.dataTransfer.files)) {
|
||||
this.browser.upload(file, this.folder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = e.dataTransfer.getData('mk_drive_file');
|
||||
if (driveFile != null && driveFile != '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
this.browser.removeFile(file.id);
|
||||
this.$root.api('drive/files/update', {
|
||||
fileId: file.id,
|
||||
folderId: this.folder.id
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ドライブのフォルダ
|
||||
const driveFolder = e.dataTransfer.getData('mk_drive_folder');
|
||||
if (driveFolder != null && driveFolder != '') {
|
||||
const folder = JSON.parse(driveFolder);
|
||||
|
||||
// 移動先が自分自身ならreject
|
||||
if (folder.id == this.folder.id) return;
|
||||
|
||||
this.browser.removeFolder(folder.id);
|
||||
this.$root.api('drive/folders/update', {
|
||||
folderId: folder.id,
|
||||
parentId: this.folder.id
|
||||
}).then(() => {
|
||||
// noop
|
||||
}).catch(err => {
|
||||
switch (err) {
|
||||
case 'detected-circular-definition':
|
||||
this.$root.dialog({
|
||||
title: this.$t('unable-to-process'),
|
||||
text: this.$t('circular-reference-detected')
|
||||
});
|
||||
break;
|
||||
default:
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('unhandled-error')
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
},
|
||||
|
||||
onDragstart(e) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('mk_drive_folder', JSON.stringify(this.folder));
|
||||
this.isDragging = true;
|
||||
|
||||
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
|
||||
// (=あなたの子供が、ドラッグを開始しましたよ)
|
||||
this.browser.isDragSource = true;
|
||||
},
|
||||
|
||||
onDragend() {
|
||||
this.isDragging = false;
|
||||
this.browser.isDragSource = false;
|
||||
},
|
||||
|
||||
go() {
|
||||
this.browser.move(this.folder.id);
|
||||
},
|
||||
|
||||
newWindow() {
|
||||
this.browser.newWindow(this.folder);
|
||||
},
|
||||
|
||||
rename() {
|
||||
this.$root.dialog({
|
||||
title: this.$t('contextmenu.rename-folder'),
|
||||
input: {
|
||||
placeholder: this.$t('contextmenu.input-new-folder-name'),
|
||||
default: this.folder.name
|
||||
}
|
||||
}).then(({ canceled, result: name }) => {
|
||||
if (canceled) return;
|
||||
this.$root.api('drive/folders/update', {
|
||||
folderId: this.folder.id,
|
||||
name: name
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
deleteFolder() {
|
||||
this.$root.api('drive/folders/delete', {
|
||||
folderId: this.folder.id
|
||||
}).then(() => {
|
||||
if (this.$store.state.settings.uploadFolder === this.folder.id) {
|
||||
this.$store.dispatch('settings/set', {
|
||||
key: 'uploadFolder',
|
||||
value: null
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
switch(err.id) {
|
||||
case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
title: this.$t('unable-to-delete'),
|
||||
text: this.$t('has-child-files-or-folders')
|
||||
});
|
||||
break;
|
||||
default:
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('unable-to-delete')
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setAsUploadFolder() {
|
||||
this.$store.dispatch('settings/set', {
|
||||
key: 'uploadFolder',
|
||||
value: this.folder.id
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rghtznwe {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
height: 64px;
|
||||
background: var(--driveFolderBg);
|
||||
border-radius: 4px;
|
||||
|
||||
&, * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&[data-draghover] {
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
border: 2px dashed var(--focus);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .name {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: var(--desktopDriveFolderFg);
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 4px;
|
||||
margin-left: 2px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
> .upload {
|
||||
margin: 4px 4px;
|
||||
font-size: 0.8em;
|
||||
text-align: right;
|
||||
color: var(--desktopDriveFolderFg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
139
src/client/components/drive.nav-folder.vue
Normal file
139
src/client/components/drive.nav-folder.vue
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<template>
|
||||
<div class="drylbebk"
|
||||
:data-draghover="draghover"
|
||||
@click="onClick"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@dragenter="onDragenter"
|
||||
@dragleave="onDragleave"
|
||||
@drop.stop="onDrop"
|
||||
>
|
||||
<i v-if="folder == null"><fa :icon="faCloud"/></i>
|
||||
<span>{{ folder == null ? $t('drive') : folder.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faCloud } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
props: {
|
||||
folder: {
|
||||
type: Object,
|
||||
required: false,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
hover: false,
|
||||
draghover: false,
|
||||
faCloud
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
browser(): any {
|
||||
return this.$parent;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClick() {
|
||||
this.browser.move(this.folder);
|
||||
},
|
||||
|
||||
onMouseover() {
|
||||
this.hover = true;
|
||||
},
|
||||
|
||||
onMouseout() {
|
||||
this.hover = false;
|
||||
},
|
||||
|
||||
onDragover(e) {
|
||||
// このフォルダがルートかつカレントディレクトリならドロップ禁止
|
||||
if (this.folder == null && this.browser.folder == null) {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
|
||||
const isFile = e.dataTransfer.items[0].kind == 'file';
|
||||
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
|
||||
const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder';
|
||||
|
||||
if (isFile || isDriveFile || isDriveFolder) {
|
||||
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
|
||||
} else {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
onDragenter() {
|
||||
if (this.folder || this.browser.folder) this.draghover = true;
|
||||
},
|
||||
|
||||
onDragleave() {
|
||||
if (this.folder || this.browser.folder) this.draghover = false;
|
||||
},
|
||||
|
||||
onDrop(e) {
|
||||
this.draghover = false;
|
||||
|
||||
// ファイルだったら
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(e.dataTransfer.files)) {
|
||||
this.browser.upload(file, this.folder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = e.dataTransfer.getData('mk_drive_file');
|
||||
if (driveFile != null && driveFile != '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
this.browser.removeFile(file.id);
|
||||
this.$root.api('drive/files/update', {
|
||||
fileId: file.id,
|
||||
folderId: this.folder ? this.folder.id : null
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ドライブのフォルダ
|
||||
const driveFolder = e.dataTransfer.getData('mk_drive_folder');
|
||||
if (driveFolder != null && driveFolder != '') {
|
||||
const folder = JSON.parse(driveFolder);
|
||||
// 移動先が自分自身ならreject
|
||||
if (this.folder && folder.id == this.folder.id) return;
|
||||
this.browser.removeFolder(folder.id);
|
||||
this.$root.api('drive/folders/update', {
|
||||
folderId: folder.id,
|
||||
parentId: this.folder ? this.folder.id : null
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drylbebk {
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&[data-draghover] {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
664
src/client/components/drive.vue
Normal file
664
src/client/components/drive.vue
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
<template>
|
||||
<div class="yfudmmck">
|
||||
<nav>
|
||||
<div class="path" @contextmenu.prevent.stop="() => {}">
|
||||
<x-nav-folder :class="{ current: folder == null }"/>
|
||||
<template v-for="folder in hierarchyFolders">
|
||||
<span class="separator"><fa :icon="faAngleRight"/></span>
|
||||
<x-nav-folder :folder="folder" :key="folder.id"/>
|
||||
</template>
|
||||
<span class="separator" v-if="folder != null"><fa :icon="faAngleRight"/></span>
|
||||
<span class="folder current" v-if="folder != null">{{ folder.name }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="main" :class="{ uploading: uploadings.length > 0, fetching }"
|
||||
ref="main"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@dragenter="onDragenter"
|
||||
@dragleave="onDragleave"
|
||||
@drop.prevent.stop="onDrop"
|
||||
>
|
||||
<div class="contents" ref="contents">
|
||||
<div class="folders" ref="foldersContainer" v-if="folders.length > 0">
|
||||
<x-folder v-for="folder in folders" :key="folder.id" class="folder" :folder="folder"/>
|
||||
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
|
||||
<div class="padding" v-for="n in 16"></div>
|
||||
<mk-button v-if="moreFolders">{{ $t('@.load-more') }}</mk-button>
|
||||
</div>
|
||||
<div class="files" ref="filesContainer" v-if="files.length > 0">
|
||||
<x-file v-for="file in files" :key="file.id" class="file" :file="file" :select-mode="selectMode"/>
|
||||
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
|
||||
<div class="padding" v-for="n in 16"></div>
|
||||
<mk-button v-if="moreFiles" @click="fetchMoreFiles">{{ $t('@.load-more') }}</mk-button>
|
||||
</div>
|
||||
<div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching">
|
||||
<p v-if="draghover">{{ $t('empty-draghover') }}</p>
|
||||
<p v-if="!draghover && folder == null"><strong>{{ $t('emptyDrive') }}</strong><br/>{{ $t('empty-drive-description') }}</p>
|
||||
<p v-if="!draghover && folder != null">{{ $t('emptyFolder') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<mk-loading v-if="fetching"/>
|
||||
</div>
|
||||
<div class="dropzone" v-if="draghover"></div>
|
||||
<x-uploader ref="uploader" @change="onChangeUploaderUploads" @uploaded="onUploaderUploaded"/>
|
||||
<input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faAngleRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import XNavFolder from './drive.nav-folder.vue';
|
||||
import XFolder from './drive.folder.vue';
|
||||
import XFile from './drive.file.vue';
|
||||
import XUploader from './uploader.vue';
|
||||
import MkButton from './ui/button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XNavFolder,
|
||||
XFolder,
|
||||
XFile,
|
||||
XUploader,
|
||||
MkButton,
|
||||
},
|
||||
|
||||
props: {
|
||||
initFolder: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
selectMode: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
/**
|
||||
* 現在の階層(フォルダ)
|
||||
* * null でルートを表す
|
||||
*/
|
||||
folder: null,
|
||||
|
||||
files: [],
|
||||
folders: [],
|
||||
moreFiles: false,
|
||||
moreFolders: false,
|
||||
hierarchyFolders: [],
|
||||
selectedFiles: [],
|
||||
uploadings: [],
|
||||
connection: null,
|
||||
|
||||
/**
|
||||
* ドロップされようとしているか
|
||||
*/
|
||||
draghover: false,
|
||||
|
||||
/**
|
||||
* 自信の所有するアイテムがドラッグをスタートさせたか
|
||||
* (自分自身の階層にドロップできないようにするためのフラグ)
|
||||
*/
|
||||
isDragSource: false,
|
||||
|
||||
fetching: true,
|
||||
|
||||
faAngleRight
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
folder() {
|
||||
this.$emit('cd', this.folder);
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.connection = this.$root.stream.useSharedConnection('drive');
|
||||
|
||||
this.connection.on('fileCreated', this.onStreamDriveFileCreated);
|
||||
this.connection.on('fileUpdated', this.onStreamDriveFileUpdated);
|
||||
this.connection.on('fileDeleted', this.onStreamDriveFileDeleted);
|
||||
this.connection.on('folderCreated', this.onStreamDriveFolderCreated);
|
||||
this.connection.on('folderUpdated', this.onStreamDriveFolderUpdated);
|
||||
this.connection.on('folderDeleted', this.onStreamDriveFolderDeleted);
|
||||
|
||||
if (this.initFolder) {
|
||||
this.move(this.initFolder);
|
||||
} else {
|
||||
this.fetch();
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.connection.dispose();
|
||||
},
|
||||
|
||||
methods: {
|
||||
onStreamDriveFileCreated(file) {
|
||||
this.addFile(file, true);
|
||||
},
|
||||
|
||||
onStreamDriveFileUpdated(file) {
|
||||
const current = this.folder ? this.folder.id : null;
|
||||
if (current != file.folderId) {
|
||||
this.removeFile(file);
|
||||
} else {
|
||||
this.addFile(file, true);
|
||||
}
|
||||
},
|
||||
|
||||
onStreamDriveFileDeleted(fileId) {
|
||||
this.removeFile(fileId);
|
||||
},
|
||||
|
||||
onStreamDriveFolderCreated(folder) {
|
||||
this.addFolder(folder, true);
|
||||
},
|
||||
|
||||
onStreamDriveFolderUpdated(folder) {
|
||||
const current = this.folder ? this.folder.id : null;
|
||||
if (current != folder.parentId) {
|
||||
this.removeFolder(folder);
|
||||
} else {
|
||||
this.addFolder(folder, true);
|
||||
}
|
||||
},
|
||||
|
||||
onStreamDriveFolderDeleted(folderId) {
|
||||
this.removeFolder(folderId);
|
||||
},
|
||||
|
||||
onChangeUploaderUploads(uploads) {
|
||||
this.uploadings = uploads;
|
||||
},
|
||||
|
||||
onUploaderUploaded(file) {
|
||||
this.addFile(file, true);
|
||||
},
|
||||
|
||||
onDragover(e): any {
|
||||
// ドラッグ元が自分自身の所有するアイテムだったら
|
||||
if (this.isDragSource) {
|
||||
// 自分自身にはドロップさせない
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const isFile = e.dataTransfer.items[0].kind == 'file';
|
||||
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
|
||||
const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder';
|
||||
|
||||
if (isFile || isDriveFile || isDriveFolder) {
|
||||
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
|
||||
} else {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
onDragenter(e) {
|
||||
if (!this.isDragSource) this.draghover = true;
|
||||
},
|
||||
|
||||
onDragleave(e) {
|
||||
this.draghover = false;
|
||||
},
|
||||
|
||||
onDrop(e): any {
|
||||
this.draghover = false;
|
||||
|
||||
// ドロップされてきたものがファイルだったら
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(e.dataTransfer.files)) {
|
||||
this.upload(file, this.folder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = e.dataTransfer.getData('mk_drive_file');
|
||||
if (driveFile != null && driveFile != '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
if (this.files.some(f => f.id == file.id)) return;
|
||||
this.removeFile(file.id);
|
||||
this.$root.api('drive/files/update', {
|
||||
fileId: file.id,
|
||||
folderId: this.folder ? this.folder.id : null
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ドライブのフォルダ
|
||||
const driveFolder = e.dataTransfer.getData('mk_drive_folder');
|
||||
if (driveFolder != null && driveFolder != '') {
|
||||
const folder = JSON.parse(driveFolder);
|
||||
|
||||
// 移動先が自分自身ならreject
|
||||
if (this.folder && folder.id == this.folder.id) return false;
|
||||
if (this.folders.some(f => f.id == folder.id)) return false;
|
||||
this.removeFolder(folder.id);
|
||||
this.$root.api('drive/folders/update', {
|
||||
folderId: folder.id,
|
||||
parentId: this.folder ? this.folder.id : null
|
||||
}).then(() => {
|
||||
// noop
|
||||
}).catch(err => {
|
||||
switch (err) {
|
||||
case 'detected-circular-definition':
|
||||
this.$root.dialog({
|
||||
title: this.$t('unable-to-process'),
|
||||
text: this.$t('circular-reference-detected')
|
||||
});
|
||||
break;
|
||||
default:
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('unhandled-error')
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
},
|
||||
|
||||
selectLocalFile() {
|
||||
(this.$refs.fileInput as any).click();
|
||||
},
|
||||
|
||||
urlUpload() {
|
||||
this.$root.dialog({
|
||||
title: this.$t('url-upload'),
|
||||
input: {
|
||||
placeholder: this.$t('url-of-file')
|
||||
}
|
||||
}).then(({ canceled, result: url }) => {
|
||||
if (canceled) return;
|
||||
this.$root.api('drive/files/upload_from_url', {
|
||||
url: url,
|
||||
folderId: this.folder ? this.folder.id : undefined
|
||||
});
|
||||
|
||||
this.$root.dialog({
|
||||
title: this.$t('url-upload-requested'),
|
||||
text: this.$t('may-take-time')
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
createFolder() {
|
||||
this.$root.dialog({
|
||||
title: this.$t('create-folder'),
|
||||
input: {
|
||||
placeholder: this.$t('folder-name')
|
||||
}
|
||||
}).then(({ canceled, result: name }) => {
|
||||
if (canceled) return;
|
||||
this.$root.api('drive/folders/create', {
|
||||
name: name,
|
||||
parentId: this.folder ? this.folder.id : undefined
|
||||
}).then(folder => {
|
||||
this.addFolder(folder, true);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
onChangeFileInput() {
|
||||
for (const file of Array.from((this.$refs.fileInput as any).files)) {
|
||||
this.upload(file, this.folder);
|
||||
}
|
||||
},
|
||||
|
||||
upload(file, folder) {
|
||||
if (folder && typeof folder == 'object') folder = folder.id;
|
||||
(this.$refs.uploader as any).upload(file, folder);
|
||||
},
|
||||
|
||||
chooseFile(file) {
|
||||
const isAlreadySelected = this.selectedFiles.some(f => f.id == file.id);
|
||||
if (this.multiple) {
|
||||
if (isAlreadySelected) {
|
||||
this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id);
|
||||
} else {
|
||||
this.selectedFiles.push(file);
|
||||
}
|
||||
this.$emit('change-selection', this.selectedFiles);
|
||||
} else {
|
||||
if (isAlreadySelected) {
|
||||
this.$emit('selected', file);
|
||||
} else {
|
||||
this.selectedFiles = [file];
|
||||
this.$emit('change-selection', [file]);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
move(target) {
|
||||
if (target == null) {
|
||||
this.goRoot();
|
||||
return;
|
||||
} else if (typeof target == 'object') {
|
||||
target = target.id;
|
||||
}
|
||||
|
||||
this.fetching = true;
|
||||
|
||||
this.$root.api('drive/folders/show', {
|
||||
folderId: target
|
||||
}).then(folder => {
|
||||
this.folder = folder;
|
||||
this.hierarchyFolders = [];
|
||||
|
||||
const dive = folder => {
|
||||
this.hierarchyFolders.unshift(folder);
|
||||
if (folder.parent) dive(folder.parent);
|
||||
};
|
||||
|
||||
if (folder.parent) dive(folder.parent);
|
||||
|
||||
this.$emit('open-folder', folder);
|
||||
this.fetch();
|
||||
});
|
||||
},
|
||||
|
||||
addFolder(folder, unshift = false) {
|
||||
const current = this.folder ? this.folder.id : null;
|
||||
if (current != folder.parentId) return;
|
||||
|
||||
if (this.folders.some(f => f.id == folder.id)) {
|
||||
const exist = this.folders.map(f => f.id).indexOf(folder.id);
|
||||
Vue.set(this.folders, exist, folder);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unshift) {
|
||||
this.folders.unshift(folder);
|
||||
} else {
|
||||
this.folders.push(folder);
|
||||
}
|
||||
},
|
||||
|
||||
addFile(file, unshift = false) {
|
||||
const current = this.folder ? this.folder.id : null;
|
||||
if (current != file.folderId) return;
|
||||
|
||||
if (this.files.some(f => f.id == file.id)) {
|
||||
const exist = this.files.map(f => f.id).indexOf(file.id);
|
||||
Vue.set(this.files, exist, file);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unshift) {
|
||||
this.files.unshift(file);
|
||||
} else {
|
||||
this.files.push(file);
|
||||
}
|
||||
},
|
||||
|
||||
removeFolder(folder) {
|
||||
if (typeof folder == 'object') folder = folder.id;
|
||||
this.folders = this.folders.filter(f => f.id != folder);
|
||||
},
|
||||
|
||||
removeFile(file) {
|
||||
if (typeof file == 'object') file = file.id;
|
||||
this.files = this.files.filter(f => f.id != file);
|
||||
},
|
||||
|
||||
appendFile(file) {
|
||||
this.addFile(file);
|
||||
},
|
||||
|
||||
appendFolder(folder) {
|
||||
this.addFolder(folder);
|
||||
},
|
||||
|
||||
prependFile(file) {
|
||||
this.addFile(file, true);
|
||||
},
|
||||
|
||||
prependFolder(folder) {
|
||||
this.addFolder(folder, true);
|
||||
},
|
||||
|
||||
goRoot() {
|
||||
// 既にrootにいるなら何もしない
|
||||
if (this.folder == null) return;
|
||||
|
||||
this.folder = null;
|
||||
this.hierarchyFolders = [];
|
||||
this.$emit('move-root');
|
||||
this.fetch();
|
||||
},
|
||||
|
||||
fetch() {
|
||||
this.folders = [];
|
||||
this.files = [];
|
||||
this.moreFolders = false;
|
||||
this.moreFiles = false;
|
||||
this.fetching = true;
|
||||
|
||||
let fetchedFolders = null;
|
||||
let fetchedFiles = null;
|
||||
|
||||
const foldersMax = 30;
|
||||
const filesMax = 30;
|
||||
|
||||
// フォルダ一覧取得
|
||||
this.$root.api('drive/folders', {
|
||||
folderId: this.folder ? this.folder.id : null,
|
||||
limit: foldersMax + 1
|
||||
}).then(folders => {
|
||||
if (folders.length == foldersMax + 1) {
|
||||
this.moreFolders = true;
|
||||
folders.pop();
|
||||
}
|
||||
fetchedFolders = folders;
|
||||
complete();
|
||||
});
|
||||
|
||||
// ファイル一覧取得
|
||||
this.$root.api('drive/files', {
|
||||
folderId: this.folder ? this.folder.id : null,
|
||||
type: this.type,
|
||||
limit: filesMax + 1
|
||||
}).then(files => {
|
||||
if (files.length == filesMax + 1) {
|
||||
this.moreFiles = true;
|
||||
files.pop();
|
||||
}
|
||||
fetchedFiles = files;
|
||||
complete();
|
||||
});
|
||||
|
||||
let flag = false;
|
||||
const complete = () => {
|
||||
if (flag) {
|
||||
for (const x of fetchedFolders) this.appendFolder(x);
|
||||
for (const x of fetchedFiles) this.appendFile(x);
|
||||
this.fetching = false;
|
||||
} else {
|
||||
flag = true;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
fetchMoreFiles() {
|
||||
this.fetching = true;
|
||||
|
||||
const max = 30;
|
||||
|
||||
// ファイル一覧取得
|
||||
this.$root.api('drive/files', {
|
||||
folderId: this.folder ? this.folder.id : null,
|
||||
type: this.type,
|
||||
untilId: this.files[this.files.length - 1].id,
|
||||
limit: max + 1
|
||||
}).then(files => {
|
||||
if (files.length == max + 1) {
|
||||
this.moreFiles = true;
|
||||
files.pop();
|
||||
} else {
|
||||
this.moreFiles = false;
|
||||
}
|
||||
for (const x of files) this.appendFile(x);
|
||||
this.fetching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.yfudmmck {
|
||||
> nav {
|
||||
display: block;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
font-size: 0.9em;
|
||||
box-shadow: 0 1px 0 var(--divider);
|
||||
|
||||
&, * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> .path {
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
line-height: 38px;
|
||||
white-space: nowrap;
|
||||
|
||||
> * {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
line-height: 38px;
|
||||
cursor: pointer;
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.current {
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.separator {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
|
||||
> [data-icon] {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .main {
|
||||
padding: 8px 0;
|
||||
overflow: auto;
|
||||
|
||||
&, * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&.fetching {
|
||||
cursor: wait !important;
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> .contents {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
height: calc(100% - 38px - 100px);
|
||||
}
|
||||
|
||||
> .contents {
|
||||
|
||||
> .folders,
|
||||
> .files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> .folder,
|
||||
> .file {
|
||||
flex-grow: 1;
|
||||
width: 144px;
|
||||
margin: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
> .padding {
|
||||
flex-grow: 1;
|
||||
pointer-events: none;
|
||||
width: 144px + 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .dropzone {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 38px;
|
||||
width: 100%;
|
||||
height: calc(100% - 38px);
|
||||
border: dashed 2px var(--focus);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> .mk-uploader {
|
||||
height: 100px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
> input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
34
src/client/components/ellipsis.vue
Normal file
34
src/client/components/ellipsis.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<span class="mk-ellipsis">
|
||||
<span>.</span><span>.</span><span>.</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" 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>
|
||||
268
src/client/components/emoji-picker.vue
Normal file
268
src/client/components/emoji-picker.vue
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
<template>
|
||||
<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }">
|
||||
<div class="omfetrab">
|
||||
<header>
|
||||
<button v-for="category in categories"
|
||||
class="_button"
|
||||
:title="category.text"
|
||||
@click="go(category)"
|
||||
:class="{ active: category.isActive }"
|
||||
:key="category.text"
|
||||
>
|
||||
<fa :icon="category.icon" fixed-width/>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="emojis">
|
||||
<template v-if="categories[0].isActive">
|
||||
<header class="category"><fa :icon="faHistory" fixed-width/> {{ $t('recentUsedEmojis') }}</header>
|
||||
<div class="list">
|
||||
<button v-for="(emoji, i) in ($store.state.device.recentEmojis || [])"
|
||||
class="_button"
|
||||
:title="emoji.name"
|
||||
@click="chosen(emoji)"
|
||||
:key="i"
|
||||
>
|
||||
<mk-emoji v-if="emoji.char != null" :emoji="emoji.char"/>
|
||||
<img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<header class="category"><fa :icon="categories.find(x => x.isActive).icon" fixed-width/> {{ categories.find(x => x.isActive).text }}</header>
|
||||
<template v-if="categories.find(x => x.isActive).name">
|
||||
<div class="list">
|
||||
<button v-for="emoji in emojilist.filter(e => e.category === categories.find(x => x.isActive).name)"
|
||||
class="_button"
|
||||
:title="emoji.name"
|
||||
@click="chosen(emoji)"
|
||||
:key="emoji.name"
|
||||
>
|
||||
<mk-emoji :emoji="emoji.char"/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="(key, i) in Object.keys(customEmojis)" :key="i">
|
||||
<header class="sub" v-if="key">{{ key }}</header>
|
||||
<div class="list">
|
||||
<button v-for="emoji in customEmojis[key]"
|
||||
class="_button"
|
||||
:title="emoji.name"
|
||||
@click="chosen(emoji)"
|
||||
:key="emoji.name"
|
||||
>
|
||||
<img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</x-popup>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import { emojilist } from '../../misc/emojilist';
|
||||
import { getStaticImageUrl } from '../scripts/get-static-image-url';
|
||||
import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faHistory } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faHeart, faFlag, faLaugh } from '@fortawesome/free-regular-svg-icons';
|
||||
import { groupByX } from '../../prelude/array';
|
||||
import XPopup from './popup.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XPopup,
|
||||
},
|
||||
|
||||
props: {
|
||||
source: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
emojilist,
|
||||
getStaticImageUrl,
|
||||
customEmojis: {},
|
||||
faGlobe, faHistory,
|
||||
categories: [{
|
||||
text: this.$t('customEmoji'),
|
||||
icon: faAsterisk,
|
||||
isActive: true
|
||||
}, {
|
||||
name: 'people',
|
||||
text: this.$t('people'),
|
||||
icon: faLaugh,
|
||||
isActive: false
|
||||
}, {
|
||||
name: 'animals_and_nature',
|
||||
text: this.$t('animals-and-nature'),
|
||||
icon: faLeaf,
|
||||
isActive: false
|
||||
}, {
|
||||
name: 'food_and_drink',
|
||||
text: this.$t('food-and-drink'),
|
||||
icon: faUtensils,
|
||||
isActive: false
|
||||
}, {
|
||||
name: 'activity',
|
||||
text: this.$t('activity'),
|
||||
icon: faFutbol,
|
||||
isActive: false
|
||||
}, {
|
||||
name: 'travel_and_places',
|
||||
text: this.$t('travel-and-places'),
|
||||
icon: faCity,
|
||||
isActive: false
|
||||
}, {
|
||||
name: 'objects',
|
||||
text: this.$t('objects'),
|
||||
icon: faDice,
|
||||
isActive: false
|
||||
}, {
|
||||
name: 'symbols',
|
||||
text: this.$t('symbols'),
|
||||
icon: faHeart,
|
||||
isActive: false
|
||||
}, {
|
||||
name: 'flags',
|
||||
text: this.$t('flags'),
|
||||
icon: faFlag,
|
||||
isActive: false
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
let local = (this.$root.getMetaSync() || { emojis: [] }).emojis || [];
|
||||
local = groupByX(local, (x: any) => x.category || '');
|
||||
this.customEmojis = local;
|
||||
},
|
||||
|
||||
methods: {
|
||||
go(category: any) {
|
||||
this.goCategory(category.name);
|
||||
},
|
||||
|
||||
goCategory(name: string) {
|
||||
let matched = false;
|
||||
for (const c of this.categories) {
|
||||
c.isActive = c.name === name;
|
||||
if (c.isActive) {
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
this.categories[0].isActive = true;
|
||||
}
|
||||
},
|
||||
|
||||
chosen(emoji: any) {
|
||||
const getKey = (emoji: any) => emoji.char || `:${emoji.name}:`;
|
||||
let recents = this.$store.state.device.recentEmojis || [];
|
||||
recents = recents.filter((e: any) => getKey(e) !== getKey(emoji));
|
||||
recents.unshift(emoji)
|
||||
this.$store.commit('device/set', { key: 'recentEmojis', value: recents.splice(0, 16) });
|
||||
this.$emit('chosen', getKey(emoji));
|
||||
},
|
||||
|
||||
close() {
|
||||
this.$refs.popup.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.omfetrab {
|
||||
width: 350px;
|
||||
|
||||
> header {
|
||||
display: flex;
|
||||
|
||||
> button {
|
||||
flex: 1;
|
||||
padding: 10px 0;
|
||||
font-size: 16px;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--textHighlighted);
|
||||
transition: color 0s;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--accent);
|
||||
transition: color 0s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .emojis {
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
> header.category {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
padding: 8px;
|
||||
background: var(--panel);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
header.sub {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
div.list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
|
||||
> button {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 0;
|
||||
padding-bottom: 100%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> * {
|
||||
transform: scale(1.2);
|
||||
transition: transform 0s;
|
||||
}
|
||||
}
|
||||
|
||||
> * {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
font-size: 28px;
|
||||
transition: transform 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
132
src/client/components/emoji.vue
Normal file
132
src/client/components/emoji.vue
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<template>
|
||||
<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt"/>
|
||||
<img v-else-if="char && !useOsDefaultEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt"/>
|
||||
<span v-else-if="char && useOsDefaultEmojis">{{ char }}</span>
|
||||
<span v-else>:{{ name }}:</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { getStaticImageUrl } from '../scripts/get-static-image-url';
|
||||
import { twemojiSvgBase } from '../../misc/twemoji-base';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
emoji: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
normal: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
noStyle: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
customEmojis: {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
isReaction: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
url: null,
|
||||
char: null,
|
||||
customEmoji: null
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
alt(): string {
|
||||
return this.customEmoji ? `:${this.customEmoji.name}:` : this.char;
|
||||
},
|
||||
|
||||
useOsDefaultEmojis(): boolean {
|
||||
return this.$store.state.device.useOsDefaultEmojis && !this.isReaction;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
customEmojis() {
|
||||
if (this.name) {
|
||||
const customEmoji = this.customEmojis.find(x => x.name == this.name);
|
||||
if (customEmoji) {
|
||||
this.customEmoji = customEmoji;
|
||||
this.url = this.$store.state.device.disableShowingAnimatedImages
|
||||
? getStaticImageUrl(customEmoji.url)
|
||||
: customEmoji.url;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.name) {
|
||||
const customEmoji = this.customEmojis.find(x => x.name == this.name);
|
||||
if (customEmoji) {
|
||||
this.customEmoji = customEmoji;
|
||||
this.url = this.$store.state.device.disableShowingAnimatedImages
|
||||
? getStaticImageUrl(customEmoji.url)
|
||||
: customEmoji.url;
|
||||
} else {
|
||||
//const emoji = lib[this.name];
|
||||
//if (emoji) {
|
||||
// this.char = emoji.char;
|
||||
//}
|
||||
}
|
||||
} else {
|
||||
this.char = this.emoji;
|
||||
}
|
||||
|
||||
if (this.char) {
|
||||
let codes = Array.from(this.char).map(x => x.codePointAt(0).toString(16));
|
||||
if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f');
|
||||
codes = codes.filter(x => x && x.length);
|
||||
|
||||
this.url = `${twemojiSvgBase}/${codes.join('-')}.svg`;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-emoji {
|
||||
height: 1.25em;
|
||||
vertical-align: -0.25em;
|
||||
|
||||
&.custom {
|
||||
height: 2.5em;
|
||||
vertical-align: middle;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
&.normal {
|
||||
height: 1.25em;
|
||||
vertical-align: -0.25em;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.noStyle {
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
42
src/client/components/error.vue
Normal file
42
src/client/components/error.vue
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<template>
|
||||
<div class="wjqjnyhzogztorhrdgcpqlkxhkmuetgj _panel">
|
||||
<p><fa :icon="faExclamationTriangle"/> {{ $t('error') }}</p>
|
||||
<mk-button @click="() => $emit('retry')" class="button">{{ $t('retry') }}</mk-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import MkButton from './ui/button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
components: {
|
||||
MkButton,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
faExclamationTriangle
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wjqjnyhzogztorhrdgcpqlkxhkmuetgj {
|
||||
max-width: 350px;
|
||||
margin: 0 auto;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
|
||||
> p {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
> .button {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
29
src/client/components/file-type-icon.vue
Normal file
29
src/client/components/file-type-icon.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<span class="mk-file-type-icon">
|
||||
<template v-if="kind == 'image'"><fa :icon="faFileImage"/></template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faFileImage } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
faFileImage
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
kind(): string {
|
||||
return this.type.split('/')[0];
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
162
src/client/components/follow-button.vue
Normal file
162
src/client/components/follow-button.vue
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<template>
|
||||
<button class="wfliddvnhxvyusikowhxozkyxyenqxqr _button"
|
||||
:class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou }"
|
||||
@click="onClick"
|
||||
:disabled="wait"
|
||||
>
|
||||
<template v-if="!wait">
|
||||
<fa v-if="hasPendingFollowRequestFromYou && user.isLocked" :icon="faHourglassHalf"/>
|
||||
<fa v-else-if="hasPendingFollowRequestFromYou && !user.isLocked" :icon="faSpinner" pulse/>
|
||||
<fa v-else-if="isFollowing" :icon="faMinus"/>
|
||||
<fa v-else-if="!isFollowing && user.isLocked" :icon="faPlus"/>
|
||||
<fa v-else-if="!isFollowing && !user.isLocked" :icon="faPlus"/>
|
||||
</template>
|
||||
<template v-else><fa :icon="faSpinner" pulse fixed-width/></template>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import { faSpinner, faPlus, faMinus, faHourglassHalf } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isFollowing: this.user.isFollowing,
|
||||
hasPendingFollowRequestFromYou: this.user.hasPendingFollowRequestFromYou,
|
||||
wait: false,
|
||||
connection: null,
|
||||
faSpinner, faPlus, faMinus, faHourglassHalf
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.connection = this.$root.stream.useSharedConnection('main');
|
||||
|
||||
this.connection.on('follow', this.onFollowChange);
|
||||
this.connection.on('unfollow', this.onFollowChange);
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.connection.dispose();
|
||||
},
|
||||
|
||||
methods: {
|
||||
onFollowChange(user) {
|
||||
if (user.id == this.user.id) {
|
||||
this.isFollowing = user.isFollowing;
|
||||
this.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou;
|
||||
}
|
||||
},
|
||||
|
||||
async onClick() {
|
||||
this.wait = true;
|
||||
|
||||
try {
|
||||
if (this.isFollowing) {
|
||||
const { canceled } = await this.$root.dialog({
|
||||
type: 'warning',
|
||||
text: this.$t('unfollowConfirm', { name: this.user.name || this.user.username }),
|
||||
showCancelButton: true
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
await this.$root.api('following/delete', {
|
||||
userId: this.user.id
|
||||
});
|
||||
} else {
|
||||
if (this.hasPendingFollowRequestFromYou) {
|
||||
await this.$root.api('following/requests/cancel', {
|
||||
userId: this.user.id
|
||||
});
|
||||
} else if (this.user.isLocked) {
|
||||
await this.$root.api('following/create', {
|
||||
userId: this.user.id
|
||||
});
|
||||
this.hasPendingFollowRequestFromYou = true;
|
||||
} else {
|
||||
await this.$root.api('following/create', {
|
||||
userId: this.user.id
|
||||
});
|
||||
this.hasPendingFollowRequestFromYou = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.wait = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wfliddvnhxvyusikowhxozkyxyenqxqr {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
border: solid 1px var(--accent);
|
||||
padding: 0;
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
font-size: 16px;
|
||||
border-radius: 100%;
|
||||
background: #fff;
|
||||
|
||||
&:focus {
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
border: 2px solid var(--focus);
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
//background: mix($primary, #fff, 20);
|
||||
}
|
||||
|
||||
&:active {
|
||||
//background: mix($primary, #fff, 40);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
|
||||
&:hover {
|
||||
background: var(--accentLighten);
|
||||
border-color: var(--accentLighten);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken);
|
||||
border-color: var(--accentDarken);
|
||||
}
|
||||
}
|
||||
|
||||
&.wait {
|
||||
cursor: wait !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
33
src/client/components/formula-core.vue
Normal file
33
src/client/components/formula-core.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
|
||||
<template>
|
||||
<div v-if="block" v-html="compiledFormula"></div>
|
||||
<span v-else v-html="compiledFormula"></span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import * as katex from 'katex';
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
formula: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
compiledFormula(): any {
|
||||
return katex.renderToString(this.formula, {
|
||||
throwOnError: false
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "../../../node_modules/katex/dist/katex.min.css";
|
||||
</style>
|
||||
22
src/client/components/formula.vue
Normal file
22
src/client/components/formula.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<x-formula :formula="formula" :block="block" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XFormula: () => import('./formula-core.vue').then(m => m.default)
|
||||
},
|
||||
props: {
|
||||
formula: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
71
src/client/components/google.vue
Normal file
71
src/client/components/google.vue
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<template>
|
||||
<div class="mk-google">
|
||||
<input type="search" v-model="query" :placeholder="q">
|
||||
<button @click="search"><fa icon="search"/> {{ $t('@.search') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
props: ['q'],
|
||||
data() {
|
||||
return {
|
||||
query: null
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.query = this.q;
|
||||
},
|
||||
methods: {
|
||||
search() {
|
||||
const engine = this.$store.state.settings.webSearchEngine ||
|
||||
'https://www.google.com/?#q={{query}}';
|
||||
const url = engine.replace('{{query}}', this.query)
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-google {
|
||||
display: flex;
|
||||
margin: 8px 0;
|
||||
|
||||
> input {
|
||||
flex-shrink: 1;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
color: var(--googleSearchFg);
|
||||
background: var(--googleSearchBg);
|
||||
border: solid 1px var(--googleSearchBorder);
|
||||
border-radius: 4px 0 0 4px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--googleSearchHoverBorder);
|
||||
}
|
||||
}
|
||||
|
||||
> button {
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
border: solid 1px var(--googleSearchBorder);
|
||||
border-left: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--googleSearchHoverButton);
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 2px 4px rgba(#000, 0.15) inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
25
src/client/components/index.ts
Normal file
25
src/client/components/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import mfm from './misskey-flavored-markdown.vue';
|
||||
import acct from './acct.vue';
|
||||
import avatar from './avatar.vue';
|
||||
import emoji from './emoji.vue';
|
||||
import userName from './user-name.vue';
|
||||
import ellipsis from './ellipsis.vue';
|
||||
import time from './time.vue';
|
||||
import url from './url.vue';
|
||||
import loading from './loading.vue';
|
||||
import SequentialEntrance from './sequential-entrance.vue';
|
||||
import error from './error.vue';
|
||||
|
||||
Vue.component('mfm', mfm);
|
||||
Vue.component('mk-acct', acct);
|
||||
Vue.component('mk-avatar', avatar);
|
||||
Vue.component('mk-emoji', emoji);
|
||||
Vue.component('mk-user-name', userName);
|
||||
Vue.component('mk-ellipsis', ellipsis);
|
||||
Vue.component('mk-time', time);
|
||||
Vue.component('mk-url', url);
|
||||
Vue.component('mk-loading', loading);
|
||||
Vue.component('mk-error', error);
|
||||
Vue.component('sequential-entrance', SequentialEntrance);
|
||||
30
src/client/components/loading.vue
Normal file
30
src/client/components/loading.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<div class="yxspomdl">
|
||||
<fa :icon="faSpinner" pulse fixed-width class="icon"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
faSpinner
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.yxspomdl {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
|
||||
> .icon {
|
||||
font-size: 32px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
109
src/client/components/media-banner.vue
Normal file
109
src/client/components/media-banner.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<div class="mk-media-banner">
|
||||
<div class="sensitive" v-if="media.isSensitive && hide" @click="hide = false">
|
||||
<span class="icon"><fa :icon="faExclamationTriangle"/></span>
|
||||
<b>{{ $t('sensitive') }}</b>
|
||||
<span>{{ $t('clickToShow') }}</span>
|
||||
</div>
|
||||
<div class="audio" v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'">
|
||||
<audio class="audio"
|
||||
:src="media.url"
|
||||
:title="media.name"
|
||||
controls
|
||||
ref="audio"
|
||||
@volumechange="volumechange"
|
||||
preload="metadata" />
|
||||
</div>
|
||||
<a class="download" v-else
|
||||
:href="media.url"
|
||||
:title="media.name"
|
||||
:download="media.name"
|
||||
>
|
||||
<span class="icon"><fa icon="download"/></span>
|
||||
<b>{{ media.name }}</b>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
props: {
|
||||
media: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hide: true,
|
||||
faExclamationTriangle
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const audioTag = this.$refs.audio as HTMLAudioElement;
|
||||
if (audioTag) audioTag.volume = this.$store.state.device.mediaVolume;
|
||||
},
|
||||
methods: {
|
||||
volumechange() {
|
||||
const audioTag = this.$refs.audio as HTMLAudioElement;
|
||||
this.$store.commit('device/set', { key: 'mediaVolume', value: audioTag.volume });
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-media-banner {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
> .download,
|
||||
> .sensitive {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
> * {
|
||||
display: block;
|
||||
}
|
||||
|
||||
> b {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-right: .2em;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
}
|
||||
|
||||
> .download {
|
||||
background: var(--noteAttachedFile);
|
||||
}
|
||||
|
||||
> .sensitive {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> .audio {
|
||||
.audio {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
113
src/client/components/media-image.vue
Normal file
113
src/client/components/media-image.vue
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<template>
|
||||
<div class="qjewsnkgzzxlxtzncydssfbgjibiehcy" v-if="image.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false">
|
||||
<div>
|
||||
<b><fa :icon="faExclamationTriangle"/> {{ $t('sensitive') }}</b>
|
||||
<span>{{ $t('clickToShow') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="gqnyydlzavusgskkfvwvjiattxdzsqlf" v-else
|
||||
:href="image.url"
|
||||
:style="style"
|
||||
:title="image.name"
|
||||
@click.prevent="onClick"
|
||||
>
|
||||
<div v-if="image.type === 'image/gif'">GIF</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import { getStaticImageUrl } from '../scripts/get-static-image-url';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
props: {
|
||||
image: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
raw: {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hide: true,
|
||||
faExclamationTriangle
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
style(): any {
|
||||
let url = `url(${
|
||||
this.$store.state.device.disableShowingAnimatedImages
|
||||
? getStaticImageUrl(this.image.thumbnailUrl)
|
||||
: this.image.thumbnailUrl
|
||||
})`;
|
||||
|
||||
if (this.$store.state.device.loadRemoteMedia) {
|
||||
url = null;
|
||||
} else if (this.raw || this.$store.state.device.loadRawImages) {
|
||||
url = `url(${this.image.url})`;
|
||||
}
|
||||
|
||||
return {
|
||||
'background-color': this.image.properties.avgColor || 'transparent',
|
||||
'background-image': url
|
||||
};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
window.open(this.image.url, '_blank');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.gqnyydlzavusgskkfvwvjiattxdzsqlf {
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
> div {
|
||||
background-color: var(--fg);
|
||||
border-radius: 6px;
|
||||
color: var(--secondary);
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
left: 12px;
|
||||
opacity: .5;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
top: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.qjewsnkgzzxlxtzncydssfbgjibiehcy {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
|
||||
> div {
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
|
||||
> * {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
130
src/client/components/media-list.vue
Normal file
130
src/client/components/media-list.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<template>
|
||||
<div class="mk-media-list">
|
||||
<template v-for="media in mediaList.filter(media => !previewable(media))">
|
||||
<x-banner :media="media" :key="media.id"/>
|
||||
</template>
|
||||
<div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container">
|
||||
<div :data-count="mediaList.filter(media => previewable(media)).length" ref="grid">
|
||||
<template v-for="media in mediaList">
|
||||
<x-video :video="media" :key="media.id" v-if="media.type.startsWith('video')"/>
|
||||
<x-image :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XBanner from './media-banner.vue';
|
||||
import XImage from './media-image.vue';
|
||||
import XVideo from './media-video.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XBanner,
|
||||
XImage,
|
||||
XVideo,
|
||||
},
|
||||
props: {
|
||||
mediaList: {
|
||||
required: true
|
||||
},
|
||||
raw: {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
//#region for Safari bug
|
||||
if (this.$refs.grid) {
|
||||
this.$refs.grid.style.height = this.$refs.grid.clientHeight ? `${this.$refs.grid.clientHeight}px`
|
||||
: '287px';
|
||||
}
|
||||
//#endregion
|
||||
},
|
||||
methods: {
|
||||
previewable(file) {
|
||||
return file.type.startsWith('video') || file.type.startsWith('image');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-media-list {
|
||||
> .gird-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
padding-top: 56.25% // 16:9;
|
||||
}
|
||||
|
||||
> div {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
grid-gap: 4px;
|
||||
|
||||
> * {
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&[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>
|
||||
79
src/client/components/media-video.vue
Normal file
79
src/client/components/media-video.vue
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<template>
|
||||
<div class="icozogqfvdetwohsdglrbswgrejoxbdj" v-if="video.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false">
|
||||
<div>
|
||||
<b><fa icon="exclamation-triangle"/> {{ $t('sensitive') }}</b>
|
||||
<span>{{ $t('clickToShow') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="kkjnbbplepmiyuadieoenjgutgcmtsvu" v-else
|
||||
:href="video.url"
|
||||
rel="nofollow noopener"
|
||||
target="_blank"
|
||||
:style="imageStyle"
|
||||
:title="video.name"
|
||||
>
|
||||
<fa :icon="faPlayCircle"/>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faPlayCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
props: {
|
||||
video: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hide: true,
|
||||
faPlayCircle
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
imageStyle(): any {
|
||||
return {
|
||||
'background-image': `url(${this.video.thumbnailUrl})`
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.kkjnbbplepmiyuadieoenjgutgcmtsvu {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
font-size: 3.5em;
|
||||
overflow: hidden;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icozogqfvdetwohsdglrbswgrejoxbdj {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
|
||||
> div {
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
|
||||
> b {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
82
src/client/components/mention.vue
Normal file
82
src/client/components/mention.vue
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<template>
|
||||
<router-link class="ldlomzub" :to="url" v-user-preview="canonical" v-if="url.startsWith('/')">
|
||||
<span class="me" v-if="isMe">{{ $t('you') }}</span>
|
||||
<span class="main">
|
||||
<span class="username">@{{ username }}</span>
|
||||
<span class="host" v-if="(host != localHost) || $store.state.settings.showFullAcct">@{{ toUnicode(host) }}</span>
|
||||
</span>
|
||||
</router-link>
|
||||
<a class="ldlomzub" :href="url" target="_blank" rel="noopener" v-else>
|
||||
<span class="main">
|
||||
<span class="username">@{{ username }}</span>
|
||||
<span class="host">@{{ toUnicode(host) }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import { toUnicode } from 'punycode';
|
||||
import { host as localHost } from '../config';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
props: {
|
||||
username: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
host: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
localHost
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
url(): string {
|
||||
switch (this.host) {
|
||||
case 'twitter.com':
|
||||
case 'github.com':
|
||||
return `https://${this.host}/${this.username}`;
|
||||
default:
|
||||
return `/${this.canonical}`;
|
||||
}
|
||||
},
|
||||
canonical(): string {
|
||||
return this.host === localHost ? `@${this.username}` : `@${this.username}@${toUnicode(this.host)}`;
|
||||
},
|
||||
isMe(): boolean {
|
||||
return this.$store.getters.isSignedIn && (
|
||||
`@${this.username}@${toUnicode(this.host)}` === `@${this.$store.state.i.username}@${toUnicode(localHost)}`.toLowerCase()
|
||||
);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toUnicode
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ldlomzub {
|
||||
color: var(--mention);
|
||||
|
||||
> .me {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
font-size: 70%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
> .main {
|
||||
> .host {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
165
src/client/components/menu.vue
Normal file
165
src/client/components/menu.vue
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<template>
|
||||
<x-popup :source="source" :no-center="noCenter" :fixed="fixed" :width="width" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }">
|
||||
<sequential-entrance class="rrevdjwt" :class="{ left: align === 'left' }" :delay="15" :direction="direction">
|
||||
<template v-for="(item, i) in items.filter(item => item !== undefined)">
|
||||
<div v-if="item === null" class="divider" :key="i" :data-index="i"></div>
|
||||
<span v-else-if="item.type === 'label'" class="label item" :key="i" :data-index="i">
|
||||
<span>{{ item.text }}</span>
|
||||
</span>
|
||||
<router-link v-else-if="item.type === 'link'" :to="item.to" @click.native="close()" :tabindex="i" class="_button item" :key="i" :data-index="i">
|
||||
<fa v-if="item.icon" :icon="item.icon" fixed-width/>
|
||||
<mk-avatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
|
||||
<span>{{ item.text }}</span>
|
||||
<i v-if="item.indicate"><fa :icon="faCircle"/></i>
|
||||
</router-link>
|
||||
<a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" @click="close()" :tabindex="i" class="_button item" :key="i" :data-index="i">
|
||||
<fa v-if="item.icon" :icon="item.icon" fixed-width/>
|
||||
<span>{{ item.text }}</span>
|
||||
<i v-if="item.indicate"><fa :icon="faCircle"/></i>
|
||||
</a>
|
||||
<button v-else-if="item.type === 'user'" @click="clicked(item.action)" :tabindex="i" class="_button item" :key="i" :data-index="i">
|
||||
<mk-avatar :user="item.user" class="avatar"/><mk-user-name :user="item.user"/>
|
||||
<i v-if="item.indicate"><fa :icon="faCircle"/></i>
|
||||
</button>
|
||||
<button v-else @click="clicked(item.action)" :tabindex="i" class="_button item" :key="i" :data-index="i">
|
||||
<fa v-if="item.icon" :icon="item.icon" fixed-width/>
|
||||
<mk-avatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
|
||||
<span>{{ item.text }}</span>
|
||||
<i v-if="item.indicate"><fa :icon="faCircle"/></i>
|
||||
</button>
|
||||
</template>
|
||||
</sequential-entrance>
|
||||
</x-popup>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import XPopup from './popup.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XPopup
|
||||
},
|
||||
props: {
|
||||
source: {
|
||||
required: true
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
noCenter: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
fixed: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
required: false
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
faCircle
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
clicked(fn) {
|
||||
fn();
|
||||
this.close();
|
||||
},
|
||||
close() {
|
||||
this.$refs.popup.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@keyframes blink {
|
||||
0% { opacity: 1; }
|
||||
30% { opacity: 1; }
|
||||
90% { opacity: 0; }
|
||||
}
|
||||
|
||||
.rrevdjwt {
|
||||
padding: 8px 0;
|
||||
|
||||
&.left {
|
||||
> .item {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
> .item {
|
||||
display: block;
|
||||
padding: 8px 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: #fff;
|
||||
background: var(--accentDarken);
|
||||
}
|
||||
|
||||
&.label {
|
||||
pointer-events: none;
|
||||
font-size: 0.7em;
|
||||
padding-bottom: 4px;
|
||||
|
||||
> span {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 4px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
margin-right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
> i {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 13px;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
> .divider {
|
||||
margin: 8px 0;
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
299
src/client/components/mfm.ts
Normal file
299
src/client/components/mfm.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import Vue, { VNode } from 'vue';
|
||||
import { MfmForest } from '../../mfm/types';
|
||||
import { parse, parsePlain } from '../../mfm/parse';
|
||||
import MkUrl from './url.vue';
|
||||
import MkMention from './mention.vue';
|
||||
import { concat } from '../../prelude/array';
|
||||
import MkFormula from './formula.vue';
|
||||
import MkCode from './code.vue';
|
||||
import MkGoogle from './google.vue';
|
||||
import { host } from '../config';
|
||||
|
||||
export default Vue.component('misskey-flavored-markdown', {
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
plain: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
nowrap: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
author: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
i: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
customEmojis: {
|
||||
required: false,
|
||||
},
|
||||
isNote: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
},
|
||||
|
||||
render(createElement) {
|
||||
if (this.text == null || this.text == '') return;
|
||||
|
||||
const ast = (this.plain ? parsePlain : parse)(this.text);
|
||||
|
||||
const genEl = (ast: MfmForest) => concat(ast.map((token): VNode[] => {
|
||||
switch (token.node.type) {
|
||||
case 'text': {
|
||||
const text = token.node.props.text.replace(/(\r\n|\n|\r)/g, '\n');
|
||||
|
||||
if (!this.plain) {
|
||||
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('b', genEl(token.children))];
|
||||
}
|
||||
|
||||
case 'strike': {
|
||||
return [createElement('del', genEl(token.children))];
|
||||
}
|
||||
|
||||
case 'italic': {
|
||||
return (createElement as any)('i', {
|
||||
attrs: {
|
||||
style: 'font-style: oblique;'
|
||||
},
|
||||
}, genEl(token.children));
|
||||
}
|
||||
|
||||
case 'big': {
|
||||
return (createElement as any)('strong', {
|
||||
attrs: {
|
||||
style: `display: inline-block; font-size: 150% };`
|
||||
},
|
||||
directives: [this.$store.state.settings.disableAnimatedMfm ? {} : {
|
||||
name: 'animate-css',
|
||||
value: { classes: 'tada', iteration: 'infinite' }
|
||||
}]
|
||||
}, genEl(token.children));
|
||||
}
|
||||
|
||||
case 'small': {
|
||||
return [createElement('small', {
|
||||
attrs: {
|
||||
style: 'opacity: 0.7;'
|
||||
},
|
||||
}, genEl(token.children))];
|
||||
}
|
||||
|
||||
case 'center': {
|
||||
return [createElement('div', {
|
||||
attrs: {
|
||||
style: 'text-align:center;'
|
||||
}
|
||||
}, genEl(token.children))];
|
||||
}
|
||||
|
||||
case 'motion': {
|
||||
return (createElement as any)('span', {
|
||||
attrs: {
|
||||
style: 'display: inline-block;'
|
||||
},
|
||||
directives: [this.$store.state.settings.disableAnimatedMfm ? {} : {
|
||||
name: 'animate-css',
|
||||
value: { classes: 'rubberBand', iteration: 'infinite' }
|
||||
}]
|
||||
}, genEl(token.children));
|
||||
}
|
||||
|
||||
case 'spin': {
|
||||
const direction =
|
||||
token.node.props.attr == 'left' ? 'reverse' :
|
||||
token.node.props.attr == 'alternate' ? 'alternate' :
|
||||
'normal';
|
||||
const style = (this.$store.state.settings.disableAnimatedMfm)
|
||||
? ''
|
||||
: `animation: spin 1.5s linear infinite; animation-direction: ${direction};`;
|
||||
return (createElement as any)('span', {
|
||||
attrs: {
|
||||
style: 'display: inline-block;' + style
|
||||
},
|
||||
}, genEl(token.children));
|
||||
}
|
||||
|
||||
case 'jump': {
|
||||
return (createElement as any)('span', {
|
||||
attrs: {
|
||||
style: (this.$store.state.settings.disableAnimatedMfm) ? 'display: inline-block;' : 'display: inline-block; animation: jump 0.75s linear infinite;'
|
||||
},
|
||||
}, genEl(token.children));
|
||||
}
|
||||
|
||||
case 'flip': {
|
||||
return (createElement as any)('span', {
|
||||
attrs: {
|
||||
style: 'display: inline-block; transform: scaleX(-1);'
|
||||
},
|
||||
}, genEl(token.children));
|
||||
}
|
||||
|
||||
case 'url': {
|
||||
return [createElement(MkUrl, {
|
||||
key: Math.random(),
|
||||
props: {
|
||||
url: token.node.props.url,
|
||||
rel: 'nofollow noopener',
|
||||
},
|
||||
attrs: {
|
||||
style: 'color:var(--link);'
|
||||
}
|
||||
})];
|
||||
}
|
||||
|
||||
case 'link': {
|
||||
return [createElement('a', {
|
||||
attrs: {
|
||||
class: 'link',
|
||||
href: token.node.props.url,
|
||||
rel: 'nofollow noopener',
|
||||
target: '_blank',
|
||||
title: token.node.props.url,
|
||||
style: 'color:var(--link);'
|
||||
}
|
||||
}, genEl(token.children))];
|
||||
}
|
||||
|
||||
case 'mention': {
|
||||
return [createElement(MkMention, {
|
||||
key: Math.random(),
|
||||
props: {
|
||||
host: (token.node.props.host == null && this.author && this.author.host != null ? this.author.host : token.node.props.host) || host,
|
||||
username: token.node.props.username
|
||||
}
|
||||
})];
|
||||
}
|
||||
|
||||
case 'hashtag': {
|
||||
return [createElement('router-link', {
|
||||
key: Math.random(),
|
||||
attrs: {
|
||||
to: this.isNote ? `/tags/${encodeURIComponent(token.node.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.node.props.hashtag)}`,
|
||||
style: 'color:var(--hashtag);'
|
||||
}
|
||||
}, `#${token.node.props.hashtag}`)];
|
||||
}
|
||||
|
||||
case 'blockCode': {
|
||||
return [createElement(MkCode, {
|
||||
key: Math.random(),
|
||||
props: {
|
||||
code: token.node.props.code,
|
||||
lang: token.node.props.lang,
|
||||
}
|
||||
})];
|
||||
}
|
||||
|
||||
case 'inlineCode': {
|
||||
return [createElement(MkCode, {
|
||||
key: Math.random(),
|
||||
props: {
|
||||
code: token.node.props.code,
|
||||
lang: token.node.props.lang,
|
||||
inline: true
|
||||
}
|
||||
})];
|
||||
}
|
||||
|
||||
case 'quote': {
|
||||
if (this.shouldBreak) {
|
||||
return [createElement('div', {
|
||||
attrs: {
|
||||
class: 'quote'
|
||||
}
|
||||
}, genEl(token.children))];
|
||||
} else {
|
||||
return [createElement('span', {
|
||||
attrs: {
|
||||
class: 'quote'
|
||||
}
|
||||
}, genEl(token.children))];
|
||||
}
|
||||
}
|
||||
|
||||
case 'title': {
|
||||
return [createElement('div', {
|
||||
attrs: {
|
||||
class: 'title'
|
||||
}
|
||||
}, genEl(token.children))];
|
||||
}
|
||||
|
||||
case 'emoji': {
|
||||
const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || [];
|
||||
return [createElement('mk-emoji', {
|
||||
key: Math.random(),
|
||||
attrs: {
|
||||
emoji: token.node.props.emoji,
|
||||
name: token.node.props.name
|
||||
},
|
||||
props: {
|
||||
customEmojis: this.customEmojis || customEmojis,
|
||||
normal: this.plain
|
||||
}
|
||||
})];
|
||||
}
|
||||
|
||||
case 'mathInline': {
|
||||
//const MkFormula = () => import('./formula.vue').then(m => m.default);
|
||||
return [createElement(MkFormula, {
|
||||
key: Math.random(),
|
||||
props: {
|
||||
formula: token.node.props.formula,
|
||||
block: false
|
||||
}
|
||||
})];
|
||||
}
|
||||
|
||||
case 'mathBlock': {
|
||||
//const MkFormula = () => import('./formula.vue').then(m => m.default);
|
||||
return [createElement(MkFormula, {
|
||||
key: Math.random(),
|
||||
props: {
|
||||
formula: token.node.props.formula,
|
||||
block: true
|
||||
}
|
||||
})];
|
||||
}
|
||||
|
||||
case 'search': {
|
||||
//const MkGoogle = () => import('./google.vue').then(m => m.default);
|
||||
return [createElement(MkGoogle, {
|
||||
key: Math.random(),
|
||||
props: {
|
||||
q: token.node.props.query
|
||||
}
|
||||
})];
|
||||
}
|
||||
|
||||
default: {
|
||||
console.log('unknown ast type:', token.node.type);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Parse ast to DOM
|
||||
return createElement('span', genEl(ast));
|
||||
}
|
||||
});
|
||||
35
src/client/components/misskey-flavored-markdown.vue
Normal file
35
src/client/components/misskey-flavored-markdown.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }" v-once/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import MfmCore from './mfm';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
MfmCore
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.havbbuyv {
|
||||
white-space: pre-wrap;
|
||||
|
||||
&.nowrap {
|
||||
white-space: pre;
|
||||
word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
::v-deep .quote {
|
||||
display: block;
|
||||
margin: 8px;
|
||||
padding: 6px 0 6px 12px;
|
||||
color: var(--mfmQuote);
|
||||
border-left: solid 3px var(--mfmQuoteLine);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
84
src/client/components/modal.vue
Normal file
84
src/client/components/modal.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<template>
|
||||
<div class="mk-modal">
|
||||
<transition name="bg-fade" appear>
|
||||
<div class="bg" ref="bg" v-if="show" @click="close()"></div>
|
||||
</transition>
|
||||
<transition name="modal" appear @after-leave="() => { $emit('closed'); destroyDom(); }">
|
||||
<div class="content" ref="content" v-if="show" @click.self="close()"><slot></slot></div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show: true,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
this.show = false;
|
||||
(this.$refs.bg as any).style.pointerEvents = 'none';
|
||||
(this.$refs.content as any).style.pointerEvents = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-enter-active, .modal-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s !important;
|
||||
}
|
||||
.modal-enter, .modal-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.bg-fade-enter-active, .bg-fade-leave-active {
|
||||
transition: opacity 0.3s !important;
|
||||
}
|
||||
.bg-fade-enter, .bg-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mk-modal {
|
||||
> .bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--modalBg)
|
||||
}
|
||||
|
||||
> .content {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: calc(100% - 16px);
|
||||
max-height: calc(100% - 16px);
|
||||
overflow: auto;
|
||||
margin: auto;
|
||||
|
||||
::v-deep > * {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
99
src/client/components/note-header.vue
Normal file
99
src/client/components/note-header.vue
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<header class="kkwtjztg">
|
||||
<router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id">
|
||||
<mk-user-name :user="note.user"/>
|
||||
</router-link>
|
||||
<span class="is-bot" v-if="note.user.isBot">bot</span>
|
||||
<span class="username"><mk-acct :user="note.user"/></span>
|
||||
<div class="info">
|
||||
<span class="mobile" v-if="note.viaMobile"><fa :icon="faMobileAlt"/></span>
|
||||
<router-link class="created-at" :to="note | notePage">
|
||||
<mk-time :time="note.createdAt"/>
|
||||
</router-link>
|
||||
<span class="visibility" v-if="note.visibility != 'public'">
|
||||
<fa v-if="note.visibility == 'home'" :icon="faHome"/>
|
||||
<fa v-if="note.visibility == 'followers'" :icon="faUnlock"/>
|
||||
<fa v-if="note.visibility == 'specified'" :icon="faEnvelope"/>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faHome, faUnlock, faEnvelope, faMobileAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faHome, faUnlock, faEnvelope, faMobileAlt
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.kkwtjztg {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
margin: 0 .5em 0 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: var(--noteHeaderName);
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
> .is-bot {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
margin: 0 .5em 0 0;
|
||||
padding: 1px 6px;
|
||||
font-size: 80%;
|
||||
color: var(--noteHeaderBadgeFg);
|
||||
background: var(--noteHeaderBadgeBg);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
> .username {
|
||||
margin: 0 .5em 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--noteHeaderAcct);
|
||||
}
|
||||
|
||||
> .info {
|
||||
margin-left: auto;
|
||||
font-size: 0.9em;
|
||||
|
||||
> * {
|
||||
color: var(--noteHeaderInfo);
|
||||
}
|
||||
|
||||
> .mobile {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
> .visibility {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
199
src/client/components/note-menu.vue
Normal file
199
src/client/components/note-menu.vue
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<template>
|
||||
<x-menu :source="source" :items="items" @closed="closed"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faStar, faLink, faThumbtack, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCopy, faTrashAlt, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import { url } from '../config';
|
||||
import copyToClipboard from '../scripts/copy-to-clipboard';
|
||||
import XMenu from './menu.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
components: {
|
||||
XMenu
|
||||
},
|
||||
props: ['note', 'source'],
|
||||
data() {
|
||||
return {
|
||||
isFavorited: false,
|
||||
isWatching: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
items(): any[] {
|
||||
if (this.$store.getters.isSignedIn) {
|
||||
return [{
|
||||
icon: faCopy,
|
||||
text: this.$t('copyContent'),
|
||||
action: this.copyContent
|
||||
}, {
|
||||
icon: faLink,
|
||||
text: this.$t('copyLink'),
|
||||
action: this.copyLink
|
||||
}, this.note.uri ? {
|
||||
icon: faExternalLinkSquareAlt,
|
||||
text: this.$t('showOnRemote'),
|
||||
action: () => {
|
||||
window.open(this.note.uri, '_blank');
|
||||
}
|
||||
} : undefined,
|
||||
null,
|
||||
this.isFavorited ? {
|
||||
icon: faStar,
|
||||
text: this.$t('unfavorite'),
|
||||
action: () => this.toggleFavorite(false)
|
||||
} : {
|
||||
icon: faStar,
|
||||
text: this.$t('favorite'),
|
||||
action: () => this.toggleFavorite(true)
|
||||
},
|
||||
this.note.userId != this.$store.state.i.id ? this.isWatching ? {
|
||||
icon: faEyeSlash,
|
||||
text: this.$t('unwatch'),
|
||||
action: () => this.toggleWatch(false)
|
||||
} : {
|
||||
icon: faEye,
|
||||
text: this.$t('watch'),
|
||||
action: () => this.toggleWatch(true)
|
||||
} : undefined,
|
||||
this.note.userId == this.$store.state.i.id ? (this.$store.state.i.pinnedNoteIds || []).includes(this.note.id) ? {
|
||||
icon: faThumbtack,
|
||||
text: this.$t('unpin'),
|
||||
action: () => this.togglePin(false)
|
||||
} : {
|
||||
icon: faThumbtack,
|
||||
text: this.$t('pin'),
|
||||
action: () => this.togglePin(true)
|
||||
} : undefined,
|
||||
...(this.note.userId == this.$store.state.i.id ? [
|
||||
null,
|
||||
{
|
||||
icon: faTrashAlt,
|
||||
text: this.$t('delete'),
|
||||
action: this.del
|
||||
}]
|
||||
: []
|
||||
)]
|
||||
.filter(x => x !== undefined);
|
||||
} else {
|
||||
return [{
|
||||
icon: faCopy,
|
||||
text: this.$t('copyContent'),
|
||||
action: this.copyContent
|
||||
}, {
|
||||
icon: faLink,
|
||||
text: this.$t('copyLink'),
|
||||
action: this.copyLink
|
||||
}, this.note.uri ? {
|
||||
icon: faExternalLinkSquareAlt,
|
||||
text: this.$t('showOnRemote'),
|
||||
action: () => {
|
||||
window.open(this.note.uri, '_blank');
|
||||
}
|
||||
} : undefined]
|
||||
.filter(x => x !== undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$root.api('notes/state', {
|
||||
noteId: this.note.id
|
||||
}).then(state => {
|
||||
this.isFavorited = state.isFavorited;
|
||||
this.isWatching = state.isWatching;
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
copyContent() {
|
||||
copyToClipboard(this.note.text);
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
},
|
||||
|
||||
copyLink() {
|
||||
copyToClipboard(`${url}/notes/${this.note.id}`);
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
},
|
||||
|
||||
togglePin(pin: boolean) {
|
||||
this.$root.api(pin ? 'i/pin' : 'i/unpin', {
|
||||
noteId: this.note.id
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
this.$emit('closed');
|
||||
this.destroyDom();
|
||||
}).catch(e => {
|
||||
if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('pinLimitExceeded')
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
del() {
|
||||
this.$root.dialog({
|
||||
type: 'warning',
|
||||
text: this.$t('noteDeleteConfirm'),
|
||||
showCancelButton: true
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
|
||||
this.$root.api('notes/delete', {
|
||||
noteId: this.note.id
|
||||
}).then(() => {
|
||||
this.$emit('closed');
|
||||
this.destroyDom();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
toggleFavorite(favorite: boolean) {
|
||||
this.$root.api(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
|
||||
noteId: this.note.id
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
this.$emit('closed');
|
||||
this.destroyDom();
|
||||
});
|
||||
},
|
||||
|
||||
toggleWatch(watch: boolean) {
|
||||
this.$root.api(watch ? 'notes/watching/create' : 'notes/watching/delete', {
|
||||
noteId: this.note.id
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
this.destroyDom();
|
||||
});
|
||||
},
|
||||
|
||||
closed() {
|
||||
this.$emit('closed');
|
||||
this.$nextTick(() => {
|
||||
this.destroyDom();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
121
src/client/components/note-preview.vue
Normal file
121
src/client/components/note-preview.vue
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<template>
|
||||
<div class="yohlumlkhizgfkvvscwfcrcggkotpvry">
|
||||
<mk-avatar class="avatar" :user="note.user"/>
|
||||
<div class="main">
|
||||
<x-note-header class="header" :note="note" :mini="true"/>
|
||||
<div class="body">
|
||||
<p v-if="note.cw != null" class="cw">
|
||||
<span class="text" v-if="note.cw != ''">{{ note.cw }}</span>
|
||||
<x-cw-button v-model="showContent" :note="note"/>
|
||||
</p>
|
||||
<div class="content" v-show="note.cw == null || showContent">
|
||||
<x-sub-note-content class="text" :note="note"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XNoteHeader from './note-header.vue';
|
||||
import XSubNoteContent from './sub-note-content.vue';
|
||||
import XCwButton from './cw-button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XNoteHeader,
|
||||
XSubNoteContent,
|
||||
XCwButton,
|
||||
},
|
||||
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
showContent: false
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.yohlumlkhizgfkvvscwfcrcggkotpvry {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
font-size: 10px;
|
||||
|
||||
@media (min-width: 350px) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 500px) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
|
||||
@media (min-width: 350px) {
|
||||
margin: 0 10px 0 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
@media (min-width: 500px) {
|
||||
margin: 0 12px 0 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
margin: 0 10px 0 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
> .main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
> .header {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
|
||||
> .cw {
|
||||
cursor: default;
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-wrap: break-word;
|
||||
color: var(--noteText);
|
||||
|
||||
> .text {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
> .text {
|
||||
cursor: default;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--subNoteText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
src/client/components/note.sub.vue
Normal file
108
src/client/components/note.sub.vue
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<template>
|
||||
<div class="zlrxdaqttccpwhpaagdmkawtzklsccam">
|
||||
<mk-avatar class="avatar" :user="note.user"/>
|
||||
<div class="main">
|
||||
<x-note-header class="header" :note="note" :mini="true"/>
|
||||
<div class="body">
|
||||
<p v-if="note.cw != null" class="cw">
|
||||
<mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis" />
|
||||
<x-cw-button v-model="showContent" :note="note"/>
|
||||
</p>
|
||||
<div class="content" v-show="note.cw == null || showContent">
|
||||
<x-sub-note-content class="text" :note="note"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XNoteHeader from './note-header.vue';
|
||||
import XSubNoteContent from './sub-note-content.vue';
|
||||
import XCwButton from './cw-button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XNoteHeader,
|
||||
XSubNoteContent,
|
||||
XCwButton,
|
||||
},
|
||||
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
// TODO
|
||||
truncate: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
inject: {
|
||||
narrow: {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
showContent: false
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.zlrxdaqttccpwhpaagdmkawtzklsccam {
|
||||
display: flex;
|
||||
padding: 16px 32px;
|
||||
font-size: 0.9em;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
|
||||
@media (max-width: 450px) {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
margin: 0 8px 0 0;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
> .main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
> .header {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
> .cw {
|
||||
cursor: default;
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
> .text {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
> .text {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
729
src/client/components/note.vue
Normal file
729
src/client/components/note.vue
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
<template>
|
||||
<div
|
||||
class="note _panel"
|
||||
v-show="appearNote.deletedAt == null && !hideThisNote"
|
||||
:tabindex="appearNote.deletedAt == null ? '-1' : null"
|
||||
:class="{ renote: isRenote }"
|
||||
v-hotkey="keymap"
|
||||
v-size="[{ max: 500 }, { max: 450 }, { max: 350 }, { max: 300 }]"
|
||||
>
|
||||
<x-sub v-for="note in conversation" :key="note.id" :note="note"/>
|
||||
<x-sub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/>
|
||||
<div class="pinned" v-if="pinned"><fa :icon="faThumbtack"/> {{ $t('pinnedNote') }}</div>
|
||||
<div class="renote" v-if="isRenote">
|
||||
<mk-avatar class="avatar" :user="note.user"/>
|
||||
<fa :icon="faRetweet"/>
|
||||
<i18n path="renotedBy" tag="span">
|
||||
<router-link class="name" :to="note.user | userPage" v-user-preview="note.userId" place="user">
|
||||
<mk-user-name :user="note.user"/>
|
||||
</router-link>
|
||||
</i18n>
|
||||
<div class="info">
|
||||
<mk-time :time="note.createdAt"/>
|
||||
<span class="visibility" v-if="note.visibility != 'public'">
|
||||
<fa v-if="note.visibility == 'home'" :icon="faHome"/>
|
||||
<fa v-if="note.visibility == 'followers'" :icon="faUnlock"/>
|
||||
<fa v-if="note.visibility == 'specified'" :icon="faEnvelope"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<article class="article">
|
||||
<mk-avatar class="avatar" :user="appearNote.user"/>
|
||||
<div class="main">
|
||||
<x-note-header class="header" :note="appearNote" :mini="true"/>
|
||||
<div class="body" v-if="appearNote.deletedAt == null">
|
||||
<p v-if="appearNote.cw != null" class="cw">
|
||||
<mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" />
|
||||
<x-cw-button v-model="showContent" :note="appearNote"/>
|
||||
</p>
|
||||
<div class="content" v-show="appearNote.cw == null || showContent">
|
||||
<div class="text">
|
||||
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span>
|
||||
<router-link class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><fa :icon="faReply"/></router-link>
|
||||
<mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/>
|
||||
<a class="rp" v-if="appearNote.renote != null">RN:</a>
|
||||
</div>
|
||||
<div class="files" v-if="appearNote.files.length > 0">
|
||||
<x-media-list :media-list="appearNote.files"/>
|
||||
</div>
|
||||
<x-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/>
|
||||
<x-url-preview v-for="url in urls" :url="url" :key="url" :compact="true" class="url-preview"/>
|
||||
<div class="renote" v-if="appearNote.renote"><x-note-preview :note="appearNote.renote"/></div>
|
||||
</div>
|
||||
</div>
|
||||
<footer v-if="appearNote.deletedAt == null" class="footer">
|
||||
<x-reactions-viewer :note="appearNote" ref="reactionsViewer"/>
|
||||
<button @click="reply()" class="button _button">
|
||||
<template v-if="appearNote.reply"><fa :icon="faReplyAll"/></template>
|
||||
<template v-else><fa :icon="faReply"/></template>
|
||||
<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
|
||||
</button>
|
||||
<button v-if="['public', 'home'].includes(appearNote.visibility)" @click="renote()" class="button _button" ref="renoteButton">
|
||||
<fa :icon="faRetweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
|
||||
</button>
|
||||
<button v-else class="button _button">
|
||||
<fa :icon="faBan"/>
|
||||
</button>
|
||||
<button v-if="!isMyNote && appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton">
|
||||
<fa :icon="faPlus"/>
|
||||
</button>
|
||||
<button v-if="!isMyNote && appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton">
|
||||
<fa :icon="faMinus"/>
|
||||
</button>
|
||||
<button class="button _button" @click="menu()" ref="menuButton">
|
||||
<fa :icon="faEllipsisH"/>
|
||||
</button>
|
||||
</footer>
|
||||
<div class="deleted" v-if="appearNote.deletedAt != null">{{ $t('deleted') }}</div>
|
||||
</div>
|
||||
</article>
|
||||
<x-sub v-for="note in replies" :key="note.id" :note="note"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan } from '@fortawesome/free-solid-svg-icons';
|
||||
import { parse } from '../../mfm/parse';
|
||||
import { sum, unique } from '../../prelude/array';
|
||||
import i18n from '../i18n';
|
||||
import XSub from './note.sub.vue';
|
||||
import XNoteHeader from './note-header.vue';
|
||||
import XNotePreview from './note-preview.vue';
|
||||
import XReactionsViewer from './reactions-viewer.vue';
|
||||
import XMediaList from './media-list.vue';
|
||||
import XCwButton from './cw-button.vue';
|
||||
import XPoll from './poll.vue';
|
||||
import XUrlPreview from './url-preview.vue';
|
||||
import MkNoteMenu from './note-menu.vue';
|
||||
import MkReactionPicker from './reaction-picker.vue';
|
||||
import MkRenotePicker from './renote-picker.vue';
|
||||
import pleaseLogin from '../scripts/please-login';
|
||||
|
||||
function focus(el, fn) {
|
||||
const target = fn(el);
|
||||
if (target) {
|
||||
if (target.hasAttribute('tabindex')) {
|
||||
target.focus();
|
||||
} else {
|
||||
focus(target, fn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XSub,
|
||||
XNoteHeader,
|
||||
XNotePreview,
|
||||
XReactionsViewer,
|
||||
XMediaList,
|
||||
XCwButton,
|
||||
XPoll,
|
||||
XUrlPreview,
|
||||
},
|
||||
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
detail: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
pinned: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
connection: null,
|
||||
conversation: [],
|
||||
replies: [],
|
||||
showContent: false,
|
||||
hideThisNote: false,
|
||||
openingMenu: false,
|
||||
faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
keymap(): any {
|
||||
return {
|
||||
'r': () => this.reply(true),
|
||||
'e|a|plus': () => this.react(true),
|
||||
'q': () => this.renote(true),
|
||||
'f|b': this.favorite,
|
||||
'delete|ctrl+d': this.del,
|
||||
'ctrl+q': this.renoteDirectly,
|
||||
'up|k|shift+tab': this.focusBefore,
|
||||
'down|j|tab': this.focusAfter,
|
||||
'esc': this.blur,
|
||||
'm|o': () => this.menu(true),
|
||||
's': this.toggleShowContent,
|
||||
'1': () => this.reactDirectly(this.$store.state.settings.reactions[0]),
|
||||
'2': () => this.reactDirectly(this.$store.state.settings.reactions[1]),
|
||||
'3': () => this.reactDirectly(this.$store.state.settings.reactions[2]),
|
||||
'4': () => this.reactDirectly(this.$store.state.settings.reactions[3]),
|
||||
'5': () => this.reactDirectly(this.$store.state.settings.reactions[4]),
|
||||
'6': () => this.reactDirectly(this.$store.state.settings.reactions[5]),
|
||||
'7': () => this.reactDirectly(this.$store.state.settings.reactions[6]),
|
||||
'8': () => this.reactDirectly(this.$store.state.settings.reactions[7]),
|
||||
'9': () => this.reactDirectly(this.$store.state.settings.reactions[8]),
|
||||
'0': () => this.reactDirectly(this.$store.state.settings.reactions[9]),
|
||||
};
|
||||
},
|
||||
|
||||
isRenote(): boolean {
|
||||
return (this.note.renote &&
|
||||
this.note.text == null &&
|
||||
this.note.fileIds.length == 0 &&
|
||||
this.note.poll == null);
|
||||
},
|
||||
|
||||
appearNote(): any {
|
||||
return this.isRenote ? this.note.renote : this.note;
|
||||
},
|
||||
|
||||
isMyNote(): boolean {
|
||||
return this.$store.getters.isSignedIn && (this.$store.state.i.id === this.appearNote.userId);
|
||||
},
|
||||
|
||||
reactionsCount(): number {
|
||||
return this.appearNote.reactions
|
||||
? sum(Object.values(this.appearNote.reactions))
|
||||
: 0;
|
||||
},
|
||||
|
||||
title(): string {
|
||||
return '';
|
||||
},
|
||||
|
||||
urls(): string[] {
|
||||
if (this.appearNote.text) {
|
||||
const ast = parse(this.appearNote.text);
|
||||
// TODO: 再帰的にURL要素がないか調べる
|
||||
const urls = unique(ast
|
||||
.filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent))
|
||||
.map(t => t.node.props.url));
|
||||
|
||||
// unique without hash
|
||||
// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
|
||||
const removeHash = x => x.replace(/#[^#]*$/, '');
|
||||
|
||||
return urls.reduce((array, url) => {
|
||||
const removed = removeHash(url);
|
||||
if (!array.map(x => removeHash(x)).includes(removed)) array.push(url);
|
||||
return array;
|
||||
}, []);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.$store.getters.isSignedIn) {
|
||||
this.connection = this.$root.stream;
|
||||
}
|
||||
|
||||
if (this.detail) {
|
||||
this.$root.api('notes/children', {
|
||||
noteId: this.appearNote.id,
|
||||
limit: 30
|
||||
}).then(replies => {
|
||||
this.replies = replies;
|
||||
});
|
||||
|
||||
if (this.appearNote.replyId) {
|
||||
this.$root.api('notes/conversation', {
|
||||
noteId: this.appearNote.replyId
|
||||
}).then(conversation => {
|
||||
this.conversation = conversation.reverse();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.capture(true);
|
||||
|
||||
if (this.$store.getters.isSignedIn) {
|
||||
this.connection.on('_connected_', this.onStreamConnected);
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.decapture(true);
|
||||
|
||||
if (this.$store.getters.isSignedIn) {
|
||||
this.connection.off('_connected_', this.onStreamConnected);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
capture(withHandler = false) {
|
||||
if (this.$store.getters.isSignedIn) {
|
||||
this.connection.send('sn', { id: this.appearNote.id });
|
||||
if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
|
||||
}
|
||||
},
|
||||
|
||||
decapture(withHandler = false) {
|
||||
if (this.$store.getters.isSignedIn) {
|
||||
this.connection.send('un', {
|
||||
id: this.appearNote.id
|
||||
});
|
||||
if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
|
||||
}
|
||||
},
|
||||
|
||||
onStreamConnected() {
|
||||
this.capture();
|
||||
},
|
||||
|
||||
onStreamNoteUpdated(data) {
|
||||
const { type, id, body } = data;
|
||||
|
||||
if (id !== this.appearNote.id) return;
|
||||
|
||||
switch (type) {
|
||||
case 'reacted': {
|
||||
const reaction = body.reaction;
|
||||
|
||||
if (this.appearNote.reactions == null) {
|
||||
Vue.set(this.appearNote, 'reactions', {});
|
||||
}
|
||||
|
||||
if (this.appearNote.reactions[reaction] == null) {
|
||||
Vue.set(this.appearNote.reactions, reaction, 0);
|
||||
}
|
||||
|
||||
// Increment the count
|
||||
this.appearNote.reactions[reaction]++;
|
||||
|
||||
if (body.userId == this.$store.state.i.id) {
|
||||
Vue.set(this.appearNote, 'myReaction', reaction);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unreacted': {
|
||||
const reaction = body.reaction;
|
||||
|
||||
if (this.appearNote.reactions == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.appearNote.reactions[reaction] == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrement the count
|
||||
if (this.appearNote.reactions[reaction] > 0) this.appearNote.reactions[reaction]--;
|
||||
|
||||
if (body.userId == this.$store.state.i.id) {
|
||||
Vue.set(this.appearNote, 'myReaction', null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pollVoted': {
|
||||
const choice = body.choice;
|
||||
this.appearNote.poll.choices[choice].votes++;
|
||||
if (body.userId == this.$store.state.i.id) {
|
||||
Vue.set(this.appearNote.poll.choices[choice], 'isVoted', true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'deleted': {
|
||||
Vue.set(this.appearNote, 'deletedAt', body.deletedAt);
|
||||
Vue.set(this.appearNote, 'renote', null);
|
||||
this.appearNote.text = null;
|
||||
this.appearNote.fileIds = [];
|
||||
this.appearNote.poll = null;
|
||||
this.appearNote.cw = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
reply(viaKeyboard = false) {
|
||||
pleaseLogin(this.$root);
|
||||
this.$root.post({
|
||||
reply: this.appearNote,
|
||||
animation: !viaKeyboard,
|
||||
}, () => {
|
||||
this.focus();
|
||||
});
|
||||
},
|
||||
|
||||
renote() {
|
||||
pleaseLogin(this.$root);
|
||||
this.blur();
|
||||
this.$root.new(MkRenotePicker, {
|
||||
source: this.$refs.renoteButton,
|
||||
note: this.appearNote,
|
||||
}).$once('closed', this.focus);
|
||||
},
|
||||
|
||||
renoteDirectly() {
|
||||
(this as any).$root.api('notes/create', {
|
||||
renoteId: this.appearNote.id
|
||||
});
|
||||
},
|
||||
|
||||
react(viaKeyboard = false) {
|
||||
pleaseLogin(this.$root);
|
||||
this.blur();
|
||||
const picker = this.$root.new(MkReactionPicker, {
|
||||
source: this.$refs.reactButton,
|
||||
showFocus: viaKeyboard,
|
||||
});
|
||||
picker.$once('chosen', reaction => {
|
||||
this.$root.api('notes/reactions/create', {
|
||||
noteId: this.appearNote.id,
|
||||
reaction: reaction
|
||||
}).then(() => {
|
||||
picker.close();
|
||||
});
|
||||
});
|
||||
picker.$once('closed', this.focus);
|
||||
},
|
||||
|
||||
reactDirectly(reaction) {
|
||||
this.$root.api('notes/reactions/create', {
|
||||
noteId: this.appearNote.id,
|
||||
reaction: reaction
|
||||
});
|
||||
},
|
||||
|
||||
undoReact(note) {
|
||||
const oldReaction = note.myReaction;
|
||||
if (!oldReaction) return;
|
||||
this.$root.api('notes/reactions/delete', {
|
||||
noteId: note.id
|
||||
});
|
||||
},
|
||||
|
||||
favorite() {
|
||||
pleaseLogin(this.$root);
|
||||
this.$root.api('notes/favorites/create', {
|
||||
noteId: this.appearNote.id
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
del() {
|
||||
this.$root.dialog({
|
||||
type: 'warning',
|
||||
text: this.$t('noteDeleteConfirm'),
|
||||
showCancelButton: true
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
|
||||
this.$root.api('notes/delete', {
|
||||
noteId: this.appearNote.id
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
menu(viaKeyboard = false) {
|
||||
if (this.openingMenu) return;
|
||||
this.openingMenu = true;
|
||||
const w = this.$root.new(MkNoteMenu, {
|
||||
source: this.$refs.menuButton,
|
||||
note: this.appearNote,
|
||||
animation: !viaKeyboard
|
||||
}).$once('closed', () => {
|
||||
this.openingMenu = false;
|
||||
this.focus();
|
||||
});
|
||||
},
|
||||
|
||||
toggleShowContent() {
|
||||
this.showContent = !this.showContent;
|
||||
},
|
||||
|
||||
focus() {
|
||||
this.$el.focus();
|
||||
},
|
||||
|
||||
blur() {
|
||||
this.$el.blur();
|
||||
},
|
||||
|
||||
focusBefore() {
|
||||
focus(this.$el, e => e.previousElementSibling);
|
||||
},
|
||||
|
||||
focusAfter() {
|
||||
focus(this.$el, e => e.nextElementSibling);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.note {
|
||||
position: relative;
|
||||
transition: box-shadow 0.1s ease;
|
||||
|
||||
&.max-width_500px {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
&.max-width_450px {
|
||||
> .renote {
|
||||
padding: 8px 16px 0 16px;
|
||||
}
|
||||
|
||||
> .article {
|
||||
padding: 14px 16px 9px;
|
||||
|
||||
> .avatar {
|
||||
margin: 0 10px 8px 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_350px {
|
||||
> .article {
|
||||
> .main {
|
||||
> .footer {
|
||||
> .button {
|
||||
&:not(:last-child) {
|
||||
margin-right: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_300px {
|
||||
font-size: 0.825em;
|
||||
|
||||
> .article {
|
||||
> .avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
> .main {
|
||||
> .footer {
|
||||
> .button {
|
||||
&:not(:last-child) {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--focus);
|
||||
}
|
||||
|
||||
&:hover > .article > .main > .footer > .button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
> *:first-child {
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
}
|
||||
|
||||
> .pinned {
|
||||
padding: 16px 32px 8px 32px;
|
||||
line-height: 24px;
|
||||
font-size: 90%;
|
||||
white-space: pre;
|
||||
color: #d28a3f;
|
||||
|
||||
@media (max-width: 450px) {
|
||||
padding: 8px 16px 0 16px;
|
||||
}
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .pinned + .article {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
> .renote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 32px 8px 32px;
|
||||
line-height: 28px;
|
||||
white-space: pre;
|
||||
color: var(--renote);
|
||||
|
||||
> .avatar {
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 8px 0 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
flex-shrink: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
> .name {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
> .info {
|
||||
margin-left: auto;
|
||||
font-size: 0.9em;
|
||||
|
||||
> .mk-time {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> .visibility {
|
||||
margin-left: 8px;
|
||||
|
||||
[data-icon] {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .renote + .article {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
> .article {
|
||||
display: flex;
|
||||
padding: 28px 32px 18px;
|
||||
|
||||
> .avatar {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
//position: sticky;
|
||||
//top: 72px;
|
||||
margin: 0 14px 8px 0;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
> .main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
> .body {
|
||||
> .cw {
|
||||
cursor: default;
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
> .text {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
> .text {
|
||||
overflow-wrap: break-word;
|
||||
|
||||
> .reply {
|
||||
color: var(--accent);
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
> .rp {
|
||||
margin-left: 4px;
|
||||
font-style: oblique;
|
||||
color: var(--renote);
|
||||
}
|
||||
}
|
||||
|
||||
> .url-preview {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
> .mk-poll {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
> .renote {
|
||||
padding: 8px 0;
|
||||
|
||||
> * {
|
||||
padding: 16px;
|
||||
border: dashed 1px var(--renote);
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .footer {
|
||||
> .button {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
opacity: 0.7;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 28px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--mkykhqkw);
|
||||
}
|
||||
|
||||
> .count {
|
||||
display: inline;
|
||||
margin: 0 0 0 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.reacted {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .deleted {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
144
src/client/components/notes.vue
Normal file
144
src/client/components/notes.vue
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<template>
|
||||
<div class="mk-notes" v-size="[{ max: 500 }]">
|
||||
<div class="empty" v-if="empty">{{ $t('noNotes') }}</div>
|
||||
|
||||
<mk-error v-if="error" @retry="init()"/>
|
||||
|
||||
<x-list ref="notes" class="notes" :items="notes" v-slot="{ item: note, i }">
|
||||
<x-note :note="note" :detail="detail" :key="note.id" :data-index="i"/>
|
||||
</x-list>
|
||||
|
||||
<footer v-if="more">
|
||||
<button @click="fetchMore()" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" class="_buttonPrimary">
|
||||
<template v-if="!moreFetching">{{ $t('loadMore') }}</template>
|
||||
<template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import paging from '../scripts/paging';
|
||||
import XNote from './note.vue';
|
||||
import XList from './date-separated-list.vue';
|
||||
import getUserName from '../../misc/get-user-name';
|
||||
import getNoteSummary from '../../misc/get-note-summary';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XNote, XList
|
||||
},
|
||||
|
||||
mixins: [
|
||||
paging({
|
||||
onPrepend: (self, note) => {
|
||||
// タブが非表示なら通知
|
||||
if (document.hidden) {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification(getUserName(note.user), {
|
||||
body: getNoteSummary(note),
|
||||
icon: note.user.avatarUrl,
|
||||
tag: 'newNote'
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
before: (self) => {
|
||||
self.$emit('before');
|
||||
},
|
||||
|
||||
after: (self, e) => {
|
||||
self.$emit('after', e);
|
||||
}
|
||||
}),
|
||||
],
|
||||
|
||||
props: {
|
||||
pagination: {
|
||||
required: true
|
||||
},
|
||||
|
||||
detail: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
|
||||
extract: {
|
||||
required: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faSpinner
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
notes(): any[] {
|
||||
return this.extract ? this.extract(this.items) : this.items;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
focus() {
|
||||
this.$refs.notes.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-notes {
|
||||
> .empty {
|
||||
margin: 0 auto;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: #fff;
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
> .notes {
|
||||
> ::v-deep * {
|
||||
margin-bottom: var(--marginFull);
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_500px {
|
||||
> .notes {
|
||||
> ::v-deep * {
|
||||
margin-bottom: var(--marginHalf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
text-align: center;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> button {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
border-radius: var(--radius);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
219
src/client/components/notification.vue
Normal file
219
src/client/components/notification.vue
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<template>
|
||||
<div class="mk-notification" :class="notification.type">
|
||||
<div class="head">
|
||||
<mk-avatar class="avatar" :user="notification.user"/>
|
||||
<div class="icon" :class="notification.type">
|
||||
<fa :icon="faPlus" v-if="notification.type === 'follow'"/>
|
||||
<fa :icon="faClock" v-if="notification.type === 'receiveFollowRequest'"/>
|
||||
<fa :icon="faCheck" v-if="notification.type === 'followRequestAccepted'"/>
|
||||
<fa :icon="faRetweet" v-if="notification.type === 'renote'"/>
|
||||
<fa :icon="faReply" v-if="notification.type === 'reply'"/>
|
||||
<fa :icon="faAt" v-if="notification.type === 'mention'"/>
|
||||
<fa :icon="faQuoteLeft" v-if="notification.type === 'quote'"/>
|
||||
<x-reaction-icon v-if="notification.type === 'reaction'" :reaction="notification.reaction" :no-style="true"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tail">
|
||||
<header>
|
||||
<router-link class="name" :to="notification.user | userPage" v-user-preview="notification.user.id"><mk-user-name :user="notification.user"/></router-link>
|
||||
<mk-time :time="notification.createdAt" v-if="withTime"/>
|
||||
</header>
|
||||
<router-link v-if="notification.type === 'reaction'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)">
|
||||
<fa :icon="faQuoteLeft"/>
|
||||
<mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/>
|
||||
<fa :icon="faQuoteRight"/>
|
||||
</router-link>
|
||||
<router-link v-if="notification.type === 'renote'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)">
|
||||
<fa :icon="faQuoteLeft"/>
|
||||
<mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.renote.emojis"/>
|
||||
<fa :icon="faQuoteRight"/>
|
||||
</router-link>
|
||||
<router-link v-if="notification.type === 'reply'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)">
|
||||
<mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/>
|
||||
</router-link>
|
||||
<router-link v-if="notification.type === 'mention'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)">
|
||||
<mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/>
|
||||
</router-link>
|
||||
<router-link v-if="notification.type === 'quote'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)">
|
||||
<mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/>
|
||||
</router-link>
|
||||
<span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $t('youGotNewFollower') }}</span>
|
||||
<span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $t('followRequestAccepted') }}</span>
|
||||
<span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $t('receiveFollowRequest') }}<div v-if="!nowrap && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $t('accept') }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $t('reject') }}</button></div></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faPlus, faQuoteLeft, faQuoteRight, faRetweet, faReply, faAt, faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faClock } from '@fortawesome/free-regular-svg-icons';
|
||||
import getNoteSummary from '../../misc/get-note-summary';
|
||||
import XReactionIcon from './reaction-icon.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XReactionIcon
|
||||
},
|
||||
props: {
|
||||
notification: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
withTime: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
nowrap: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
getNoteSummary,
|
||||
followRequestDone: false,
|
||||
faPlus, faQuoteLeft, faQuoteRight, faRetweet, faReply, faAt, faClock, faCheck
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
acceptFollowRequest() {
|
||||
this.followRequestDone = true;
|
||||
this.$root.api('following/requests/accept', { userId: this.notification.user.id });
|
||||
},
|
||||
rejectFollowRequest() {
|
||||
this.followRequestDone = true;
|
||||
this.$root.api('following/requests/reject', { userId: this.notification.user.id });
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-notification {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
font-size: 0.9em;
|
||||
overflow-wrap: break-word;
|
||||
display: flex;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 12px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
> .head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
flex-shrink: 0;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-right: 8px;
|
||||
|
||||
> .avatar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 100%;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 0 0 3px var(--panel);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
|
||||
> * {
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.follow, &.followRequestAccepted, &.receiveFollowRequest {
|
||||
padding: 3px;
|
||||
background: #36aed2;
|
||||
}
|
||||
|
||||
&.retweet {
|
||||
padding: 3px;
|
||||
background: #36d298;
|
||||
}
|
||||
|
||||
&.quote {
|
||||
padding: 3px;
|
||||
background: #36d298;
|
||||
}
|
||||
|
||||
&.reply {
|
||||
padding: 3px;
|
||||
background: #007aff;
|
||||
}
|
||||
|
||||
&.mention {
|
||||
padding: 3px;
|
||||
background: #88a6b7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .tail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
> header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
|
||||
> .name {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
> .mk-time {
|
||||
margin-left: auto;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
> .text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
> [data-icon] {
|
||||
vertical-align: super;
|
||||
font-size: 50%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
> [data-icon]:first-child {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
> [data-icon]:last-child {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
136
src/client/components/notifications.vue
Normal file
136
src/client/components/notifications.vue
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<template>
|
||||
<div class="mk-notifications">
|
||||
<div class="contents">
|
||||
<x-list class="notifications" :items="items" v-slot="{ item: notification, i }">
|
||||
<x-notification :notification="notification" :with-time="true" :nowrap="false" class="notification" :key="notification.id" :data-index="i"/>
|
||||
</x-list>
|
||||
|
||||
<button class="more _button" v-if="more" @click="fetchMore" :disabled="moreFetching">
|
||||
<template v-if="!moreFetching">{{ $t('loadMore') }}</template>
|
||||
<template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template>
|
||||
</button>
|
||||
|
||||
<p class="empty" v-if="empty">{{ $t('noNotifications') }}</p>
|
||||
|
||||
<mk-error v-if="error" @retry="init()"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import paging from '../scripts/paging';
|
||||
import XNotification from './notification.vue';
|
||||
import XList from './date-separated-list.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XNotification,
|
||||
XList,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
paging({}),
|
||||
],
|
||||
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
wide: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
connection: null,
|
||||
pagination: {
|
||||
endpoint: 'i/notifications',
|
||||
limit: 10,
|
||||
params: () => ({
|
||||
includeTypes: this.type ? [this.type] : undefined
|
||||
})
|
||||
},
|
||||
faSpinner
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
type() {
|
||||
this.reload();
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.connection = this.$root.stream.useSharedConnection('main');
|
||||
this.connection.on('notification', this.onNotification);
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.connection.dispose();
|
||||
},
|
||||
|
||||
methods: {
|
||||
onNotification(notification) {
|
||||
// TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
|
||||
this.$root.stream.send('readNotification', {
|
||||
id: notification.id
|
||||
});
|
||||
|
||||
this.prepend(notification);
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-notifications {
|
||||
> .contents {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
padding: 8px 8px 0 8px;
|
||||
|
||||
> .notifications {
|
||||
> ::v-deep * {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
> .notification {
|
||||
background: var(--panel);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
> .more {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .empty {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
> .placeholder {
|
||||
padding: 32px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
163
src/client/components/page-preview.vue
Normal file
163
src/client/components/page-preview.vue
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<template>
|
||||
<router-link :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1">
|
||||
<div class="thumbnail" v-if="page.eyeCatchingImage" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div>
|
||||
<article>
|
||||
<header>
|
||||
<h1 :title="page.title">{{ page.title }}</h1>
|
||||
</header>
|
||||
<p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p>
|
||||
<footer>
|
||||
<img class="icon" :src="page.user.avatarUrl"/>
|
||||
<p>{{ page.user | userName }}</p>
|
||||
</footer>
|
||||
</article>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
page: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vhpxefrj {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border: solid var(--lineWidth) var(--urlPreviewBorder);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
border-color: var(--urlPreviewBorderHover);
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
height: 100%;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
> button {
|
||||
font-size: 3.5em;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
font-size: 4em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
& + article {
|
||||
left: 100px;
|
||||
width: calc(100% - 100px);
|
||||
}
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 16px;
|
||||
|
||||
> header {
|
||||
margin-bottom: 8px;
|
||||
|
||||
> h1 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
color: var(--urlPreviewTitle);
|
||||
}
|
||||
}
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
color: var(--urlPreviewText);
|
||||
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: var(--urlPreviewInfo);
|
||||
font-size: 0.8em;
|
||||
line-height: 16px;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
> .thumbnail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
|
||||
& + article {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
font-size: 12px;
|
||||
|
||||
> .thumbnail {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
font-size: 10px;
|
||||
|
||||
> .thumbnail {
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 8px;
|
||||
|
||||
> header {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
> footer {
|
||||
margin-top: 4px;
|
||||
|
||||
> img {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
40
src/client/components/page/page.block.vue
Normal file
40
src/client/components/page/page.block.vue
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<component :is="'x-' + value.type" :value="value" :page="page" :script="script" :key="value.id" :h="h"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XText from './page.text.vue';
|
||||
import XSection from './page.section.vue';
|
||||
import XImage from './page.image.vue';
|
||||
import XButton from './page.button.vue';
|
||||
import XNumberInput from './page.number-input.vue';
|
||||
import XTextInput from './page.text-input.vue';
|
||||
import XTextareaInput from './page.textarea-input.vue';
|
||||
import XSwitch from './page.switch.vue';
|
||||
import XIf from './page.if.vue';
|
||||
import XTextarea from './page.textarea.vue';
|
||||
import XPost from './page.post.vue';
|
||||
import XCounter from './page.counter.vue';
|
||||
import XRadioButton from './page.radio-button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
script: {
|
||||
required: true
|
||||
},
|
||||
page: {
|
||||
required: true
|
||||
},
|
||||
h: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
59
src/client/components/page/page.button.vue
Normal file
59
src/client/components/page/page.button.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<div>
|
||||
<mk-button class="kudkigyw" @click="click()" :primary="value.primary">{{ script.interpolate(value.text) }}</mk-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import MkButton from '../ui/button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
MkButton
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
script: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
click() {
|
||||
if (this.value.action === 'dialog') {
|
||||
this.script.eval();
|
||||
this.$root.dialog({
|
||||
text: this.script.interpolate(this.value.content)
|
||||
});
|
||||
} else if (this.value.action === 'resetRandom') {
|
||||
this.script.aiScript.updateRandomSeed(Math.random());
|
||||
this.script.eval();
|
||||
} else if (this.value.action === 'pushEvent') {
|
||||
this.$root.api('page-push', {
|
||||
pageId: this.script.page.id,
|
||||
event: this.value.event,
|
||||
...(this.value.var ? {
|
||||
var: this.script.vars[this.value.var]
|
||||
} : {})
|
||||
});
|
||||
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
text: this.script.interpolate(this.value.message)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.kudkigyw {
|
||||
display: inline-block;
|
||||
min-width: 200px;
|
||||
max-width: 450px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
49
src/client/components/page/page.counter.vue
Normal file
49
src/client/components/page/page.counter.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<div>
|
||||
<mk-button class="llumlmnx" @click="click()">{{ script.interpolate(value.text) }}</mk-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import MkButton from '../ui/button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
MkButton
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
script: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
v: 0,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
v() {
|
||||
this.script.aiScript.updatePageVar(this.value.name, this.v);
|
||||
this.script.eval();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
click() {
|
||||
this.v = this.v + (this.value.inc || 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.llumlmnx {
|
||||
display: inline-block;
|
||||
min-width: 300px;
|
||||
max-width: 450px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
29
src/client/components/page/page.if.vue
Normal file
29
src/client/components/page/page.if.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<div v-show="script.vars[value.var]">
|
||||
<x-block v-for="child in value.children" :value="child" :page="page" :script="script" :key="child.id" :h="h"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
script: {
|
||||
required: true
|
||||
},
|
||||
page: {
|
||||
required: true
|
||||
},
|
||||
h: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
beforeCreate() {
|
||||
this.$options.components.XBlock = require('./page.block.vue').default;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
36
src/client/components/page/page.image.vue
Normal file
36
src/client/components/page/page.image.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<div class="lzyxtsnt">
|
||||
<img v-if="image" :src="image.url" alt=""/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
page: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
image: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.image = this.page.attachedFiles.find(x => x.id === this.value.fileId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.lzyxtsnt {
|
||||
> img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
44
src/client/components/page/page.number-input.vue
Normal file
44
src/client/components/page/page.number-input.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div>
|
||||
<mk-input class="kudkigyw" v-model="v" type="number">{{ script.interpolate(value.text) }}</mk-input>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import MkInput from '../ui/input.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
MkInput
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
script: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
v: this.value.default,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
v() {
|
||||
this.script.aiScript.updatePageVar(this.value.name, this.v);
|
||||
this.script.eval();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.kudkigyw {
|
||||
display: inline-block;
|
||||
min-width: 300px;
|
||||
max-width: 450px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
75
src/client/components/page/page.post.vue
Normal file
75
src/client/components/page/page.post.vue
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<div class="ngbfujlo">
|
||||
<mk-textarea class="textarea" :value="text" readonly></mk-textarea>
|
||||
<mk-button primary @click="post()" :disabled="posting || posted">{{ posted ? $t('posted-from-post-form') : $t('post-from-post-form') }}</mk-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../../i18n';
|
||||
import MkTextarea from '../ui/textarea.vue';
|
||||
import MkButton from '../ui/button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
components: {
|
||||
MkTextarea,
|
||||
MkButton,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
script: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
text: this.script.interpolate(this.value.text),
|
||||
posted: false,
|
||||
posting: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
'script.vars': {
|
||||
handler() {
|
||||
this.text = this.script.interpolate(this.value.text);
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
post() {
|
||||
this.posting = true;
|
||||
this.$root.api('notes/create', {
|
||||
text: this.text,
|
||||
}).then(() => {
|
||||
this.posted = true;
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ngbfujlo {
|
||||
padding: 0 32px 32px 32px;
|
||||
border: solid 2px var(--divider);
|
||||
border-radius: 6px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 0 16px 16px 16px;
|
||||
|
||||
> .textarea {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
36
src/client/components/page/page.radio-button.vue
Normal file
36
src/client/components/page/page.radio-button.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<div>
|
||||
<div>{{ script.interpolate(value.title) }}</div>
|
||||
<mk-radio v-for="x in value.values" v-model="v" :value="x" :key="x">{{ x }}</mk-radio>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import MkRadio from '../ui/radio.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
MkRadio
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
script: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
v: this.value.default,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
v() {
|
||||
this.script.aiScript.updatePageVar(this.value.name, this.v);
|
||||
this.script.eval();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
58
src/client/components/page/page.section.vue
Normal file
58
src/client/components/page/page.section.vue
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<template>
|
||||
<section class="sdgxphyu">
|
||||
<component :is="'h' + h">{{ value.title }}</component>
|
||||
|
||||
<div class="children">
|
||||
<x-block v-for="child in value.children" :value="child" :page="page" :script="script" :key="child.id" :h="h + 1"/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
script: {
|
||||
required: true
|
||||
},
|
||||
page: {
|
||||
required: true
|
||||
},
|
||||
h: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
beforeCreate() {
|
||||
this.$options.components.XBlock = require('./page.block.vue').default;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sdgxphyu {
|
||||
margin: 1.5em 0;
|
||||
|
||||
> h2 {
|
||||
font-size: 1.35em;
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
> h3 {
|
||||
font-size: 1em;
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
> h4 {
|
||||
font-size: 1em;
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
> .children {
|
||||
//padding 16px
|
||||
}
|
||||
}
|
||||
</style>
|
||||
46
src/client/components/page/page.switch.vue
Normal file
46
src/client/components/page/page.switch.vue
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<div class="hkcxmtwj">
|
||||
<mk-switch v-model="v">{{ script.interpolate(value.text) }}</mk-switch>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import MkSwitch from '../ui/switch.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
MkSwitch
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
script: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
v: this.value.default,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
v() {
|
||||
this.script.aiScript.updatePageVar(this.value.name, this.v);
|
||||
this.script.eval();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hkcxmtwj {
|
||||
display: inline-block;
|
||||
margin: 16px auto;
|
||||
|
||||
& + .hkcxmtwj {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
44
src/client/components/page/page.text-input.vue
Normal file
44
src/client/components/page/page.text-input.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div>
|
||||
<mk-input class="kudkigyw" v-model="v" type="text">{{ script.interpolate(value.text) }}</mk-input>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import MkInput from '../ui/input.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
MkInput
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
script: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
v: this.value.default,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
v() {
|
||||
this.script.aiScript.updatePageVar(this.value.name, this.v);
|
||||
this.script.eval();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.kudkigyw {
|
||||
display: inline-block;
|
||||
min-width: 300px;
|
||||
max-width: 450px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
65
src/client/components/page/page.text.vue
Normal file
65
src/client/components/page/page.text.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<div class="mrdgzndn">
|
||||
<mfm :text="text" :is-note="false" :i="$store.state.i" :key="text"/>
|
||||
<mk-url-preview v-for="url in urls" :url="url" :key="url" class="url"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { parse } from '../../../mfm/parse';
|
||||
import { unique } from '../../../prelude/array';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
script: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
text: this.script.interpolate(this.value.text),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
urls(): string[] {
|
||||
if (this.text) {
|
||||
const ast = parse(this.text);
|
||||
// TODO: 再帰的にURL要素がないか調べる
|
||||
return unique(ast
|
||||
.filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent))
|
||||
.map(t => t.node.props.url));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'script.vars': {
|
||||
handler() {
|
||||
this.text = this.script.interpolate(this.value.text);
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mrdgzndn {
|
||||
&:not(:first-child) {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
> .url {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
35
src/client/components/page/page.textarea-input.vue
Normal file
35
src/client/components/page/page.textarea-input.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div>
|
||||
<mk-textarea v-model="v">{{ script.interpolate(value.text) }}</mk-textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import MkTextarea from '../ui/textarea.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
MkTextarea
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
script: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
v: this.value.default,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
v() {
|
||||
this.script.aiScript.updatePageVar(this.value.name, this.v);
|
||||
this.script.eval();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
35
src/client/components/page/page.textarea.vue
Normal file
35
src/client/components/page/page.textarea.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<mk-textarea :value="text" readonly></mk-textarea>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import MkTextarea from '../ui/textarea.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
MkTextarea
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
script: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
text: this.script.interpolate(this.value.text),
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
'script.vars': {
|
||||
handler() {
|
||||
this.text = this.script.interpolate(this.value.text);
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
230
src/client/components/page/page.vue
Normal file
230
src/client/components/page/page.vue
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<template>
|
||||
<div class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }">
|
||||
<header v-if="showTitle">
|
||||
<div class="title">{{ page.title }}</div>
|
||||
</header>
|
||||
|
||||
<div v-if="script">
|
||||
<x-block v-for="child in page.content" :value="child" @input="v => updateBlock(v)" :page="page" :script="script" :key="child.id" :h="2"/>
|
||||
</div>
|
||||
|
||||
<footer v-if="showFooter">
|
||||
<small>@{{ page.user.username }}</small>
|
||||
<template v-if="$store.getters.isSignedIn && $store.state.i.id === page.userId">
|
||||
<router-link :to="`/my/pages/edit/${page.id}`">{{ $t('edit-this-page') }}</router-link>
|
||||
<a v-if="$store.state.i.pinnedPageId === page.id" @click="pin(false)">{{ $t('unpin-this-page') }}</a>
|
||||
<a v-else @click="pin(true)">{{ $t('pin-this-page') }}</a>
|
||||
</template>
|
||||
<router-link :to="`./${page.name}/view-source`">{{ $t('view-source') }}</router-link>
|
||||
<div class="like">
|
||||
<button @click="unlike()" v-if="page.isLiked" :title="$t('unlike')"><fa :icon="faHeartS"/></button>
|
||||
<button @click="like()" v-else :title="$t('like')"><fa :icon="faHeart"/></button>
|
||||
<span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../../i18n';
|
||||
import { faHeart as faHeartS } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faHeart } from '@fortawesome/free-regular-svg-icons';
|
||||
import XBlock from './page.block.vue';
|
||||
import { ASEvaluator } from '../../scripts/aiscript/evaluator';
|
||||
import { collectPageVars } from '../../scripts/collect-page-vars';
|
||||
import { url } from '../../config';
|
||||
|
||||
class Script {
|
||||
public aiScript: ASEvaluator;
|
||||
private onError: any;
|
||||
public vars: Record<string, any>;
|
||||
public page: Record<string, any>;
|
||||
|
||||
constructor(page, aiScript, onError) {
|
||||
this.page = page;
|
||||
this.aiScript = aiScript;
|
||||
this.onError = onError;
|
||||
this.eval();
|
||||
}
|
||||
|
||||
public eval() {
|
||||
try {
|
||||
this.vars = this.aiScript.evaluateVars();
|
||||
} catch (e) {
|
||||
this.onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public interpolate(str: string) {
|
||||
if (str == null) return null;
|
||||
return str.replace(/{(.+?)}/g, match => {
|
||||
const v = this.vars[match.slice(1, -1).trim()];
|
||||
return v == null ? 'NULL' : v.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XBlock
|
||||
},
|
||||
|
||||
props: {
|
||||
page: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
showFooter: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
script: null,
|
||||
faHeartS, faHeart
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
const pageVars = this.getPageVars();
|
||||
this.script = new Script(this.page, new ASEvaluator(this.page.variables, pageVars, {
|
||||
randomSeed: Math.random(),
|
||||
user: this.page.user,
|
||||
visitor: this.$store.state.i,
|
||||
page: this.page,
|
||||
url: url
|
||||
}), e => {
|
||||
console.dir(e);
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
getPageVars() {
|
||||
return collectPageVars(this.page.content);
|
||||
},
|
||||
|
||||
like() {
|
||||
this.$root.api('pages/like', {
|
||||
pageId: this.page.id,
|
||||
}).then(() => {
|
||||
this.page.isLiked = true;
|
||||
this.page.likedCount++;
|
||||
});
|
||||
},
|
||||
|
||||
unlike() {
|
||||
this.$root.api('pages/unlike', {
|
||||
pageId: this.page.id,
|
||||
}).then(() => {
|
||||
this.page.isLiked = false;
|
||||
this.page.likedCount--;
|
||||
});
|
||||
},
|
||||
|
||||
pin(pin) {
|
||||
this.$root.api('i/update', {
|
||||
pinnedPageId: pin ? this.page.id : null,
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
splash: true
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.iroscrza {
|
||||
&.serif {
|
||||
> div {
|
||||
font-family: serif;
|
||||
}
|
||||
}
|
||||
|
||||
&.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
> header {
|
||||
> .title {
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 16px 32px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: var(--text);
|
||||
box-shadow: 0 var(--lineWidth) rgba(#000, 0.07);
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 16px 32px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
color: var(--text);
|
||||
padding: 24px 32px;
|
||||
font-size: 16px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 24px 32px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
padding: 20px 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
color: var(--text);
|
||||
padding: 0 32px 28px 32px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 0 32px 28px 32px;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
padding: 0 20px 20px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
> small {
|
||||
display: block;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
> a {
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
> a + a {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
> .like {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
218
src/client/components/poll-editor.vue
Normal file
218
src/client/components/poll-editor.vue
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<template>
|
||||
<div class="zmdxowus">
|
||||
<p class="caution" v-if="choices.length < 2">
|
||||
<fa :icon="faExclamationTriangle"/>{{ $t('_poll.noOnlyOneChoice') }}
|
||||
</p>
|
||||
<ul ref="choices">
|
||||
<li v-for="(choice, i) in choices" :key="i">
|
||||
<mk-input class="input" :value="choice" @input="onInput(i, $event)">
|
||||
<span>{{ $t('_poll.choiceN', { n: i + 1 }) }}</span>
|
||||
</mk-input>
|
||||
<button @click="remove(i)" class="_button">
|
||||
<fa :icon="faTimes"/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<mk-button class="add" v-if="choices.length < 10" @click="add">{{ $t('add') }}</mk-button>
|
||||
<mk-button class="add" v-else disabled>{{ $t('_poll.noMore') }}</mk-button>
|
||||
<section>
|
||||
<mk-switch v-model="multiple">{{ $t('_poll.canMultipleVote') }}</mk-switch>
|
||||
<div>
|
||||
<mk-select v-model="expiration">
|
||||
<template #label>{{ $t('_poll.expiration') }}</template>
|
||||
<option value="infinite">{{ $t('_poll.infinite') }}</option>
|
||||
<option value="at">{{ $t('_poll.at') }}</option>
|
||||
<option value="after">{{ $t('_poll.after') }}</option>
|
||||
</mk-select>
|
||||
<section v-if="expiration === 'at'">
|
||||
<mk-input v-model="atDate" type="date" class="input">
|
||||
<span>{{ $t('_poll.deadlineDate') }}</span>
|
||||
</mk-input>
|
||||
<mk-input v-model="atTime" type="time" class="input">
|
||||
<span>{{ $t('_poll.deadlineTime') }}</span>
|
||||
</mk-input>
|
||||
</section>
|
||||
<section v-if="expiration === 'after'">
|
||||
<mk-input v-model="after" type="number" class="input">
|
||||
<span>{{ $t('_poll.duration') }}</span>
|
||||
</mk-input>
|
||||
<mk-select v-model="unit">
|
||||
<option value="second">{{ $t('_time.second') }}</option>
|
||||
<option value="minute">{{ $t('_time.minute') }}</option>
|
||||
<option value="hour">{{ $t('_time.hour') }}</option>
|
||||
<option value="day">{{ $t('_time.day') }}</option>
|
||||
</mk-select>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faExclamationTriangle, faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import { erase } from '../../prelude/array';
|
||||
import { addTimespan } from '../../prelude/time';
|
||||
import { formatDateTimeString } from '../../misc/format-time-string';
|
||||
import MkInput from './ui/input.vue';
|
||||
import MkSelect from './ui/select.vue';
|
||||
import MkSwitch from './ui/switch.vue';
|
||||
import MkButton from './ui/button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
components: {
|
||||
MkInput,
|
||||
MkSelect,
|
||||
MkSwitch,
|
||||
MkButton,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
choices: ['', ''],
|
||||
multiple: false,
|
||||
expiration: 'infinite',
|
||||
atDate: formatDateTimeString(addTimespan(new Date(), 1, 'days'), 'yyyy-MM-dd'),
|
||||
atTime: '00:00',
|
||||
after: 0,
|
||||
unit: 'second',
|
||||
faExclamationTriangle, faTimes
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
choices() {
|
||||
this.$emit('updated');
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onInput(i, e) {
|
||||
Vue.set(this.choices, i, e);
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
get() {
|
||||
const at = () => {
|
||||
return new Date(`${this.atDate} ${this.atTime}`).getTime();
|
||||
};
|
||||
|
||||
const after = () => {
|
||||
let base = parseInt(this.after);
|
||||
switch (this.unit) {
|
||||
case 'day': base *= 24;
|
||||
case 'hour': base *= 60;
|
||||
case 'minute': base *= 60;
|
||||
case 'second': return base *= 1000;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
choices: erase('', this.choices),
|
||||
multiple: this.multiple,
|
||||
...(
|
||||
this.expiration === 'at' ? { expiresAt: at() } :
|
||||
this.expiration === 'after' ? { expiredAfter: after() } : {})
|
||||
};
|
||||
},
|
||||
|
||||
set(data) {
|
||||
if (data.choices.length == 0) return;
|
||||
this.choices = data.choices;
|
||||
if (data.choices.length == 1) this.choices = this.choices.concat('');
|
||||
this.multiple = data.multiple;
|
||||
if (data.expiresAt) {
|
||||
this.expiration = 'at';
|
||||
this.atDate = this.atTime = data.expiresAt;
|
||||
} else if (typeof data.expiredAfter === 'number') {
|
||||
this.expiration = 'after';
|
||||
this.after = data.expiredAfter;
|
||||
} else {
|
||||
this.expiration = 'infinite';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.zmdxowus {
|
||||
padding: 8px;
|
||||
|
||||
> .caution {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.8em;
|
||||
color: #f00;
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> ul {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
> li {
|
||||
display: flex;
|
||||
margin: 8px 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
|
||||
> .input {
|
||||
flex: 1;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
> button {
|
||||
width: 32px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .add {
|
||||
margin: 8px 0 0 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
> section {
|
||||
margin: 16px 0 -16px 0;
|
||||
|
||||
> div {
|
||||
margin: 0 8px;
|
||||
|
||||
&:last-child {
|
||||
flex: 1 0 auto;
|
||||
|
||||
> section {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin: -32px 0 0;
|
||||
|
||||
> &:first-child {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
> .input {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
174
src/client/components/poll.vue
Normal file
174
src/client/components/poll.vue
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<template>
|
||||
<div class="mk-poll" :data-done="closed || isVoted">
|
||||
<ul>
|
||||
<li v-for="(choice, i) in poll.choices" :key="i" @click="vote(i)" :class="{ voted: choice.voted }">
|
||||
<div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
|
||||
<span>
|
||||
<template v-if="choice.isVoted"><fa :icon="faCheck"/></template>
|
||||
<mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/>
|
||||
<span class="votes" v-if="showResult">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<span>{{ $t('_poll.totalVotes', { n: total }) }}</span>
|
||||
<span> · </span>
|
||||
<a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $t('_poll.vote') : $t('_poll.showResult') }}</a>
|
||||
<span v-if="isVoted">{{ $t('_poll.voted') }}</span>
|
||||
<span v-else-if="closed">{{ $t('_poll.closed') }}</span>
|
||||
<span v-if="remaining > 0"> · {{ timer }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import { sum } from '../../prelude/array';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
remaining: -1,
|
||||
showResult: false,
|
||||
faCheck
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
poll(): any {
|
||||
return this.note.poll;
|
||||
},
|
||||
total(): number {
|
||||
return sum(this.poll.choices.map(x => x.votes));
|
||||
},
|
||||
closed(): boolean {
|
||||
return !this.remaining;
|
||||
},
|
||||
timer(): string {
|
||||
return this.$t(
|
||||
this.remaining > 86400 ? '_poll.remainingDays' :
|
||||
this.remaining > 3600 ? '_poll.remainingHours' :
|
||||
this.remaining > 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', {
|
||||
s: Math.floor(this.remaining % 60),
|
||||
m: Math.floor(this.remaining / 60) % 60,
|
||||
h: Math.floor(this.remaining / 3600) % 24,
|
||||
d: Math.floor(this.remaining / 86400)
|
||||
});
|
||||
},
|
||||
isVoted(): boolean {
|
||||
return !this.poll.multiple && this.poll.choices.some(c => c.isVoted);
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.showResult = this.isVoted;
|
||||
|
||||
if (this.note.poll.expiresAt) {
|
||||
const update = () => {
|
||||
if (this.remaining = Math.floor(Math.max(new Date(this.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000))
|
||||
requestAnimationFrame(update);
|
||||
else
|
||||
this.showResult = true;
|
||||
};
|
||||
|
||||
update();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleShowResult() {
|
||||
this.showResult = !this.showResult;
|
||||
},
|
||||
vote(id) {
|
||||
if (this.closed || !this.poll.multiple && this.poll.choices.some(c => c.isVoted)) return;
|
||||
this.$root.api('notes/polls/vote', {
|
||||
noteId: this.note.id,
|
||||
choice: id
|
||||
}).then(() => {
|
||||
if (!this.showResult) this.showResult = !this.poll.multiple;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-poll {
|
||||
> ul {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
> li {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin: 4px 0;
|
||||
padding: 4px 8px;
|
||||
width: 100%;
|
||||
color: var(--pollChoiceText);
|
||||
border: solid 1px var(--pollChoiceBorder);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(#000, 0.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(#000, 0.1);
|
||||
}
|
||||
|
||||
> .backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 1s ease;
|
||||
}
|
||||
|
||||
> span {
|
||||
position: relative;
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
> .votes {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> p {
|
||||
color: var(--fg);
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-done] {
|
||||
> ul > li {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
147
src/client/components/popup.vue
Normal file
147
src/client/components/popup.vue
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<template>
|
||||
<div class="mk-popup">
|
||||
<transition name="bg-fade" appear>
|
||||
<div class="bg" ref="bg" @click="close()" v-if="show"></div>
|
||||
</transition>
|
||||
<transition name="popup" appear @after-leave="() => { $emit('closed'); destroyDom(); }">
|
||||
<div class="content" :class="{ fixed }" ref="content" v-if="show" :style="{ width: width ? width + 'px' : 'auto' }"><slot></slot></div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
source: {
|
||||
required: true
|
||||
},
|
||||
noCenter: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
fixed: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show: true,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
const popover = this.$refs.content as any;
|
||||
|
||||
const rect = this.source.getBoundingClientRect();
|
||||
const width = popover.offsetWidth;
|
||||
const height = popover.offsetHeight;
|
||||
|
||||
let left;
|
||||
let top;
|
||||
|
||||
if (this.$root.isMobile && !this.noCenter) {
|
||||
const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.source.offsetWidth / 2);
|
||||
const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.source.offsetHeight / 2);
|
||||
left = (x - (width / 2));
|
||||
top = (y - (height / 2));
|
||||
popover.style.transformOrigin = 'center';
|
||||
} else {
|
||||
const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.source.offsetWidth / 2);
|
||||
const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.source.offsetHeight;
|
||||
left = (x - (width / 2));
|
||||
top = y;
|
||||
}
|
||||
|
||||
if (this.fixed) {
|
||||
if (left + width > window.innerWidth) {
|
||||
left = window.innerWidth - width;
|
||||
popover.style.transformOrigin = 'center';
|
||||
}
|
||||
|
||||
if (top + height > window.innerHeight) {
|
||||
top = window.innerHeight - height;
|
||||
popover.style.transformOrigin = 'center';
|
||||
}
|
||||
} else {
|
||||
if (left + width - window.pageXOffset > window.innerWidth) {
|
||||
left = window.innerWidth - width + window.pageXOffset;
|
||||
popover.style.transformOrigin = 'center';
|
||||
}
|
||||
|
||||
if (top + height - window.pageYOffset > window.innerHeight) {
|
||||
top = window.innerHeight - height + window.pageYOffset;
|
||||
popover.style.transformOrigin = 'center';
|
||||
}
|
||||
}
|
||||
|
||||
if (top < 0) {
|
||||
top = 0;
|
||||
}
|
||||
|
||||
if (left < 0) {
|
||||
left = 0;
|
||||
}
|
||||
|
||||
popover.style.left = left + 'px';
|
||||
popover.style.top = top + 'px';
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
this.show = false;
|
||||
(this.$refs.bg as any).style.pointerEvents = 'none';
|
||||
(this.$refs.content as any).style.pointerEvents = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup-enter-active, .popup-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s !important;
|
||||
}
|
||||
.popup-enter, .popup-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.bg-fade-enter-active, .bg-fade-leave-active {
|
||||
transition: opacity 0.3s !important;
|
||||
}
|
||||
.bg-fade-enter, .bg-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mk-popup {
|
||||
> .bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--modalBg)
|
||||
}
|
||||
|
||||
> .content {
|
||||
position: absolute;
|
||||
z-index: 10001;
|
||||
background: var(--panel);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 3px 12px rgba(27, 31, 35, 0.15);
|
||||
overflow: hidden;
|
||||
transform-origin: center top;
|
||||
|
||||
&.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
158
src/client/components/post-form-attaches.vue
Normal file
158
src/client/components/post-form-attaches.vue
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<template>
|
||||
<div class="skeikyzd" v-show="files.length != 0">
|
||||
<x-draggable class="files" :list="files" animation="150">
|
||||
<div v-for="file in files" :key="file.id" @click="showFileMenu(file, $event)" @contextmenu.prevent="showFileMenu(file, $event)">
|
||||
<x-file-thumbnail :data-id="file.id" class="thumbnail" :file="file" fit="cover"/>
|
||||
<div class="sensitive" v-if="file.isSensitive">
|
||||
<fa class="icon" :icon="faExclamationTriangle"/>
|
||||
</div>
|
||||
</div>
|
||||
</x-draggable>
|
||||
<p class="remain">{{ 4 - files.length }}/4</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import * as XDraggable from 'vuedraggable';
|
||||
import { faTimesCircle, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faExclamationTriangle, faICursor } from '@fortawesome/free-solid-svg-icons';
|
||||
import XFileThumbnail from './drive-file-thumbnail.vue'
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XDraggable,
|
||||
XFileThumbnail
|
||||
},
|
||||
|
||||
props: {
|
||||
files: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
detachMediaFn: {
|
||||
type: Function,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faExclamationTriangle
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
detachMedia(id) {
|
||||
if (this.detachMediaFn) {
|
||||
this.detachMediaFn(id);
|
||||
} else if (this.$parent.detachMedia) {
|
||||
this.$parent.detachMedia(id);
|
||||
}
|
||||
},
|
||||
toggleSensitive(file) {
|
||||
this.$root.api('drive/files/update', {
|
||||
fileId: file.id,
|
||||
isSensitive: !file.isSensitive
|
||||
}).then(() => {
|
||||
file.isSensitive = !file.isSensitive;
|
||||
this.$parent.updateMedia(file);
|
||||
});
|
||||
},
|
||||
async rename(file) {
|
||||
const { canceled, result } = await this.$root.dialog({
|
||||
title: this.$t('enterFileName'),
|
||||
input: {
|
||||
default: file.name
|
||||
},
|
||||
allowEmpty: false
|
||||
});
|
||||
if (canceled) return;
|
||||
this.$root.api('drive/files/update', {
|
||||
fileId: file.id,
|
||||
name: result
|
||||
}).then(() => {
|
||||
file.name = result;
|
||||
this.$parent.updateMedia(file);
|
||||
});
|
||||
},
|
||||
showFileMenu(file, ev: MouseEvent) {
|
||||
this.$root.menu({
|
||||
items: [{
|
||||
text: this.$t('renameFile'),
|
||||
icon: faICursor,
|
||||
action: () => { this.rename(file) }
|
||||
}, {
|
||||
text: file.isSensitive ? this.$t('unmarkAsSensitive') : this.$t('markAsSensitive'),
|
||||
icon: file.isSensitive ? faEyeSlash : faEye,
|
||||
action: () => { this.toggleSensitive(file) }
|
||||
}, {
|
||||
text: this.$t('attachCancel'),
|
||||
icon: faTimesCircle,
|
||||
action: () => { this.detachMedia(file.id) }
|
||||
}],
|
||||
source: ev.currentTarget || ev.target
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skeikyzd {
|
||||
padding: 4px;
|
||||
position: relative;
|
||||
|
||||
> .files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> div {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 4px;
|
||||
cursor: move;
|
||||
|
||||
&:hover > .remove {
|
||||
display: block;
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
> .sensitive {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: rgba(17, 17, 17, .7);
|
||||
color: #fff;
|
||||
|
||||
> .icon {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .remain {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
157
src/client/components/post-form-dialog.vue
Normal file
157
src/client/components/post-form-dialog.vue
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<template>
|
||||
<div class="ulveipglmagnxfgvitaxyszerjwiqmwl">
|
||||
<transition name="form-fade" appear>
|
||||
<div class="bg" ref="bg" v-if="show" @click="close()"></div>
|
||||
</transition>
|
||||
<div class="main" ref="main" @click.self="close()" @keydown="onKeydown">
|
||||
<transition name="form" appear
|
||||
@after-leave="destroyDom"
|
||||
>
|
||||
<x-post-form ref="form"
|
||||
v-if="show"
|
||||
:reply="reply"
|
||||
:renote="renote"
|
||||
:mention="mention"
|
||||
:specified="specified"
|
||||
:initial-text="initialText"
|
||||
:initial-note="initialNote"
|
||||
:instant="instant"
|
||||
@posted="onPosted"
|
||||
@cancel="onCanceled"/>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XPostForm from './post-form.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XPostForm
|
||||
},
|
||||
|
||||
props: {
|
||||
reply: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
renote: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
mention: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
specified: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
initialText: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
initialNote: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
instant: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
show: true
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
focus() {
|
||||
this.$refs.form.focus();
|
||||
},
|
||||
|
||||
close() {
|
||||
this.show = false;
|
||||
(this.$refs.bg as any).style.pointerEvents = 'none';
|
||||
(this.$refs.main as any).style.pointerEvents = 'none';
|
||||
},
|
||||
|
||||
onPosted() {
|
||||
this.$emit('posted');
|
||||
this.close();
|
||||
},
|
||||
|
||||
onCanceled() {
|
||||
this.$emit('cancel');
|
||||
this.close();
|
||||
},
|
||||
|
||||
onKeydown(e) {
|
||||
if (e.which === 27) { // Esc
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-enter-active, .form-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s !important;
|
||||
}
|
||||
.form-enter, .form-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.form-fade-enter-active, .form-fade-leave-active {
|
||||
transition: opacity 0.3s !important;
|
||||
}
|
||||
.form-fade-enter, .form-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ulveipglmagnxfgvitaxyszerjwiqmwl {
|
||||
> .bg {
|
||||
display: block;
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(#000, 0.7);
|
||||
}
|
||||
|
||||
> .main {
|
||||
display: block;
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
top: 32px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(100% - 64px);
|
||||
width: 500px;
|
||||
max-width: calc(100% - 16px);
|
||||
overflow: auto;
|
||||
margin: 0 auto 0 auto;
|
||||
|
||||
@media (max-width: 550px) {
|
||||
top: 16px;
|
||||
height: calc(100% - 32px);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
top: 8px;
|
||||
height: calc(100% - 16px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
747
src/client/components/post-form.vue
Normal file
747
src/client/components/post-form.vue
Normal file
|
|
@ -0,0 +1,747 @@
|
|||
<template>
|
||||
<div class="gafaadew"
|
||||
@dragover.stop="onDragover"
|
||||
@dragenter="onDragenter"
|
||||
@dragleave="onDragleave"
|
||||
@drop.stop="onDrop"
|
||||
>
|
||||
<header>
|
||||
<button class="cancel _button" @click="cancel"><fa :icon="faTimes"/></button>
|
||||
<div>
|
||||
<span class="text-count" :class="{ over: trimmedLength(text) > 500 }">{{ 500 - trimmedLength(text) }}</span>
|
||||
<button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="form">
|
||||
<x-note-preview class="preview" v-if="reply" :note="reply"/>
|
||||
<x-note-preview class="preview" v-if="renote" :note="renote"/>
|
||||
<div class="with-quote" v-if="quoteId"><fa icon="quote-left"/> {{ $t('@.post-form.quote-attached') }}<button @click="quoteId = null"><fa icon="times"/></button></div>
|
||||
<div v-if="visibility === 'specified'" class="to-specified">
|
||||
<span style="margin-right: 8px;">{{ $t('recipient') }}</span>
|
||||
<div class="visibleUsers">
|
||||
<span v-for="u in visibleUsers">
|
||||
<mk-acct :user="u"/>
|
||||
<button class="_button" @click="removeVisibleUser(u)"><fa :icon="faTimes"/></button>
|
||||
</span>
|
||||
<button @click="addVisibleUser" class="_buttonPrimary"><fa :icon="faPlus" fixed-width/></button>
|
||||
</div>
|
||||
</div>
|
||||
<input v-show="useCw" ref="cw" v-model="cw" :placeholder="$t('annotation')" v-autocomplete="{ model: 'cw' }">
|
||||
<textarea v-model="text" ref="text" :disabled="posting" :placeholder="placeholder" v-autocomplete="{ model: 'text' }" @keydown="onKeydown" @paste="onPaste"></textarea>
|
||||
<x-post-form-attaches class="attaches" :files="files"/>
|
||||
<x-poll-editor v-if="poll" ref="poll" @destroyed="poll = false" @updated="onPollUpdate()"/>
|
||||
<x-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/>
|
||||
<footer>
|
||||
<button class="_button" @click="chooseFileFrom"><fa :icon="faPhotoVideo"/></button>
|
||||
<button class="_button" @click="poll = !poll"><fa :icon="faChartPie"/></button>
|
||||
<button class="_button" @click="useCw = !useCw"><fa :icon="faEyeSlash"/></button>
|
||||
<button class="_button" @click="insertMention"><fa :icon="faAt"/></button>
|
||||
<button class="_button" @click="insertEmoji"><fa :icon="faLaughSquint"/></button>
|
||||
<button class="_button" @click="setVisibility" ref="visibilityButton">
|
||||
<span v-if="visibility === 'public'"><fa :icon="faGlobe"/></span>
|
||||
<span v-if="visibility === 'home'"><fa :icon="faHome"/></span>
|
||||
<span v-if="visibility === 'followers'"><fa :icon="faUnlock"/></span>
|
||||
<span v-if="visibility === 'specified'"><fa :icon="faEnvelope"/></span>
|
||||
</button>
|
||||
</footer>
|
||||
<input ref="file" class="file _button" type="file" multiple="multiple" @change="onChangeFile"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faTimes, faUpload, faChartPie, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faCloud, faLink, faAt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons';
|
||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
import { length } from 'stringz';
|
||||
import { toASCII } from 'punycode';
|
||||
import i18n from '../i18n';
|
||||
import MkVisibilityChooser from './visibility-chooser.vue';
|
||||
import MkUserSelect from './user-select.vue';
|
||||
import XNotePreview from './note-preview.vue';
|
||||
import XEmojiPicker from './emoji-picker.vue';
|
||||
import { parse } from '../../mfm/parse';
|
||||
import { host, url } from '../config';
|
||||
import { erase, unique } from '../../prelude/array';
|
||||
import extractMentions from '../../misc/extract-mentions';
|
||||
import getAcct from '../../misc/acct/render';
|
||||
import { formatTimeString } from '../../misc/format-time-string';
|
||||
import { selectDriveFile } from '../scripts/select-drive-file';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XNotePreview,
|
||||
XUploader: () => import('./uploader.vue').then(m => m.default),
|
||||
XPostFormAttaches: () => import('./post-form-attaches.vue').then(m => m.default),
|
||||
XPollEditor: () => import('./poll-editor.vue').then(m => m.default)
|
||||
},
|
||||
|
||||
props: {
|
||||
reply: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
renote: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
mention: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
specified: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
initialText: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
initialNote: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
instant: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
posting: false,
|
||||
text: '',
|
||||
files: [],
|
||||
uploadings: [],
|
||||
poll: false,
|
||||
pollChoices: [],
|
||||
pollMultiple: false,
|
||||
pollExpiration: [],
|
||||
useCw: false,
|
||||
cw: null,
|
||||
visibility: 'public',
|
||||
visibleUsers: [],
|
||||
autocomplete: null,
|
||||
draghover: false,
|
||||
quoteId: null,
|
||||
recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
|
||||
faTimes, faUpload, faChartPie, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faCloud, faLink, faAt
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
draftId(): string {
|
||||
return this.renote
|
||||
? `renote:${this.renote.id}`
|
||||
: this.reply
|
||||
? `reply:${this.reply.id}`
|
||||
: 'note';
|
||||
},
|
||||
|
||||
placeholder(): string {
|
||||
const xs = [
|
||||
this.$t('_postForm._placeholders.a'),
|
||||
this.$t('_postForm._placeholders.b'),
|
||||
this.$t('_postForm._placeholders.c'),
|
||||
this.$t('_postForm._placeholders.d'),
|
||||
this.$t('_postForm._placeholders.e'),
|
||||
this.$t('_postForm._placeholders.f')
|
||||
];
|
||||
const x = xs[Math.floor(Math.random() * xs.length)];
|
||||
|
||||
return this.renote
|
||||
? this.$t('_postForm.quotePlaceholder')
|
||||
: this.reply
|
||||
? this.$t('_postForm.replyPlaceholder')
|
||||
: x;
|
||||
},
|
||||
|
||||
submitText(): string {
|
||||
return this.renote
|
||||
? this.$t('renote')
|
||||
: this.reply
|
||||
? this.$t('reply')
|
||||
: this.$t('_postForm.post');
|
||||
},
|
||||
|
||||
canPost(): boolean {
|
||||
return !this.posting &&
|
||||
(1 <= this.text.length || 1 <= this.files.length || this.poll || this.renote) &&
|
||||
(length(this.text.trim()) <= 500) &&
|
||||
(!this.poll || this.pollChoices.length >= 2);
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.initialText) {
|
||||
this.text = this.initialText;
|
||||
}
|
||||
|
||||
if (this.mention) {
|
||||
this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`;
|
||||
this.text += ' ';
|
||||
}
|
||||
|
||||
if (this.reply && this.reply.user.host != null) {
|
||||
this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `;
|
||||
}
|
||||
|
||||
if (this.reply && this.reply.text != null) {
|
||||
const ast = parse(this.reply.text);
|
||||
|
||||
for (const x of extractMentions(ast)) {
|
||||
const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
|
||||
|
||||
// 自分は除外
|
||||
if (this.$store.state.i.username == x.username && x.host == null) continue;
|
||||
if (this.$store.state.i.username == x.username && x.host == host) continue;
|
||||
|
||||
// 重複は除外
|
||||
if (this.text.indexOf(`${mention} `) != -1) continue;
|
||||
|
||||
this.text += `${mention} `;
|
||||
}
|
||||
}
|
||||
|
||||
// デフォルト公開範囲
|
||||
this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility);
|
||||
|
||||
// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
|
||||
if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
|
||||
this.visibility = this.reply.visibility;
|
||||
if (this.reply.visibility === 'specified') {
|
||||
this.$root.api('users/show', {
|
||||
userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$store.state.i.id && uid !== this.reply.userId)
|
||||
}).then(users => {
|
||||
this.visibleUsers.push(...users);
|
||||
});
|
||||
|
||||
if (this.reply.userId !== this.$store.state.i.id) {
|
||||
this.$root.api('users/show', { userId: this.reply.userId }).then(user => {
|
||||
this.visibleUsers.push(user);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.specified) {
|
||||
this.visibility = 'specified';
|
||||
this.visibleUsers.push(this.specified);
|
||||
}
|
||||
|
||||
// keep cw when reply
|
||||
if (this.$store.state.settings.keepCw && this.reply && this.reply.cw) {
|
||||
this.useCw = true;
|
||||
this.cw = this.reply.cw;
|
||||
}
|
||||
|
||||
this.focus();
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.focus();
|
||||
});
|
||||
|
||||
this.$nextTick(() => {
|
||||
// 書きかけの投稿を復元
|
||||
if (!this.instant && !this.mention) {
|
||||
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId];
|
||||
if (draft) {
|
||||
this.text = draft.data.text;
|
||||
this.files = (draft.data.files || []).filter(e => e);
|
||||
if (draft.data.poll) {
|
||||
this.poll = true;
|
||||
this.$nextTick(() => {
|
||||
(this.$refs.poll as any).set(draft.data.poll);
|
||||
});
|
||||
}
|
||||
this.$emit('change-attached-files', this.files);
|
||||
}
|
||||
}
|
||||
|
||||
// 削除して編集
|
||||
if (this.initialNote) {
|
||||
const init = this.initialNote;
|
||||
this.text = init.text ? init.text : '';
|
||||
this.files = init.files;
|
||||
this.cw = init.cw;
|
||||
this.useCw = init.cw != null;
|
||||
if (init.poll) {
|
||||
this.poll = true;
|
||||
this.$nextTick(() => {
|
||||
(this.$refs.poll as any).set({
|
||||
choices: init.poll.choices.map(c => c.text),
|
||||
multiple: init.poll.multiple
|
||||
});
|
||||
});
|
||||
}
|
||||
this.visibility = init.visibility;
|
||||
this.quoteId = init.renote ? init.renote.id : null;
|
||||
}
|
||||
|
||||
this.$nextTick(() => this.watch());
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
watch() {
|
||||
this.$watch('text', () => this.saveDraft());
|
||||
this.$watch('poll', () => this.saveDraft());
|
||||
this.$watch('files', () => this.saveDraft());
|
||||
},
|
||||
|
||||
trimmedLength(text: string) {
|
||||
return length(text.trim());
|
||||
},
|
||||
|
||||
addTag(tag: string) {
|
||||
insertTextAtCursor(this.$refs.text, ` #${tag} `);
|
||||
},
|
||||
|
||||
focus() {
|
||||
(this.$refs.text as any).focus();
|
||||
},
|
||||
|
||||
chooseFileFrom(ev) {
|
||||
this.$root.menu({
|
||||
items: [{
|
||||
type: 'label',
|
||||
text: this.$t('attachFile'),
|
||||
}, {
|
||||
text: this.$t('upload'),
|
||||
icon: faUpload,
|
||||
action: () => { this.chooseFileFromPc() }
|
||||
}, {
|
||||
text: this.$t('fromDrive'),
|
||||
icon: faCloud,
|
||||
action: () => { this.chooseFileFromDrive() }
|
||||
}, {
|
||||
text: this.$t('fromUrl'),
|
||||
icon: faLink,
|
||||
action: () => { this.chooseFileFromUrl() }
|
||||
}],
|
||||
source: ev.currentTarget || ev.target
|
||||
});
|
||||
},
|
||||
|
||||
chooseFileFromPc() {
|
||||
(this.$refs.file as any).click();
|
||||
},
|
||||
|
||||
chooseFileFromDrive() {
|
||||
selectDriveFile(this.$root, true).then(files => {
|
||||
for (const file of files) {
|
||||
this.attachMedia(file);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
attachMedia(driveFile) {
|
||||
this.files.push(driveFile);
|
||||
},
|
||||
|
||||
detachMedia(id) {
|
||||
this.files = this.files.filter(x => x.id != id);
|
||||
},
|
||||
|
||||
updateMedia(file) {
|
||||
Vue.set(this.files, this.files.findIndex(x => x.id === file.id), file);
|
||||
},
|
||||
|
||||
onChangeFile() {
|
||||
for (const x of Array.from((this.$refs.file as any).files)) this.upload(x);
|
||||
},
|
||||
|
||||
upload(file: File, name?: string) {
|
||||
(this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name);
|
||||
},
|
||||
|
||||
onChangeUploadings(uploads) {
|
||||
this.$emit('change-uploadings', uploads);
|
||||
},
|
||||
|
||||
onPollUpdate() {
|
||||
const got = this.$refs.poll.get();
|
||||
this.pollChoices = got.choices;
|
||||
this.pollMultiple = got.multiple;
|
||||
this.pollExpiration = [got.expiration, got.expiresAt || got.expiredAfter];
|
||||
this.saveDraft();
|
||||
},
|
||||
|
||||
setVisibility() {
|
||||
const w = this.$root.new(MkVisibilityChooser, {
|
||||
source: this.$refs.visibilityButton,
|
||||
currentVisibility: this.visibility
|
||||
});
|
||||
w.$once('chosen', v => {
|
||||
this.applyVisibility(v);
|
||||
});
|
||||
},
|
||||
|
||||
applyVisibility(v: string) {
|
||||
this.visibility = v;
|
||||
},
|
||||
|
||||
addVisibleUser() {
|
||||
const vm = this.$root.new(MkUserSelect, {});
|
||||
vm.$once('selected', user => {
|
||||
this.visibleUsers.push(user);
|
||||
});
|
||||
},
|
||||
|
||||
removeVisibleUser(user) {
|
||||
this.visibleUsers = erase(user, this.visibleUsers);
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.text = '';
|
||||
this.files = [];
|
||||
this.poll = false;
|
||||
this.quoteId = null;
|
||||
this.$emit('change-attached-files', this.files);
|
||||
},
|
||||
|
||||
onKeydown(e) {
|
||||
if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
|
||||
},
|
||||
|
||||
async onPaste(e: ClipboardEvent) {
|
||||
for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
|
||||
if (item.kind == 'file') {
|
||||
const file = item.getAsFile();
|
||||
const lio = file.name.lastIndexOf('.');
|
||||
const ext = lio >= 0 ? file.name.slice(lio) : '';
|
||||
const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.settings.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
|
||||
this.upload(file, formatted);
|
||||
}
|
||||
}
|
||||
|
||||
const paste = e.clipboardData.getData('text');
|
||||
|
||||
if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) {
|
||||
e.preventDefault();
|
||||
|
||||
this.$root.dialog({
|
||||
type: 'info',
|
||||
text: this.$t('@.post-form.quote-question'),
|
||||
showCancelButton: true
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) {
|
||||
insertTextAtCursor(this.$refs.text, paste);
|
||||
return;
|
||||
}
|
||||
|
||||
this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onDragover(e) {
|
||||
if (!e.dataTransfer.items[0]) return;
|
||||
const isFile = e.dataTransfer.items[0].kind == 'file';
|
||||
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
|
||||
if (isFile || isDriveFile) {
|
||||
e.preventDefault();
|
||||
this.draghover = true;
|
||||
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
|
||||
}
|
||||
},
|
||||
|
||||
onDragenter(e) {
|
||||
this.draghover = true;
|
||||
},
|
||||
|
||||
onDragleave(e) {
|
||||
this.draghover = false;
|
||||
},
|
||||
|
||||
onDrop(e): void {
|
||||
this.draghover = false;
|
||||
|
||||
// ファイルだったら
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
e.preventDefault();
|
||||
for (const x of Array.from(e.dataTransfer.files)) this.upload(x);
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = e.dataTransfer.getData('mk_drive_file');
|
||||
if (driveFile != null && driveFile != '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
this.files.push(file);
|
||||
this.$emit('change-attached-files', this.files);
|
||||
e.preventDefault();
|
||||
}
|
||||
//#endregion
|
||||
},
|
||||
|
||||
saveDraft() {
|
||||
if (this.instant) return;
|
||||
|
||||
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
|
||||
|
||||
data[this.draftId] = {
|
||||
updatedAt: new Date(),
|
||||
data: {
|
||||
text: this.text,
|
||||
files: this.files,
|
||||
poll: this.poll && this.$refs.poll ? (this.$refs.poll as any).get() : undefined
|
||||
}
|
||||
};
|
||||
|
||||
localStorage.setItem('drafts', JSON.stringify(data));
|
||||
},
|
||||
|
||||
deleteDraft() {
|
||||
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
|
||||
|
||||
delete data[this.draftId];
|
||||
|
||||
localStorage.setItem('drafts', JSON.stringify(data));
|
||||
},
|
||||
|
||||
post() {
|
||||
this.posting = true;
|
||||
this.$root.api('notes/create', {
|
||||
text: this.text == '' ? undefined : this.text,
|
||||
fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
|
||||
replyId: this.reply ? this.reply.id : undefined,
|
||||
renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
|
||||
poll: this.poll ? (this.$refs.poll as any).get() : undefined,
|
||||
cw: this.useCw ? this.cw || '' : undefined,
|
||||
visibility: this.visibility,
|
||||
visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
|
||||
viaMobile: this.$root.isMobile
|
||||
}).then(data => {
|
||||
this.clear();
|
||||
this.deleteDraft();
|
||||
this.$emit('posted');
|
||||
}).catch(err => {
|
||||
}).then(() => {
|
||||
this.posting = false;
|
||||
});
|
||||
|
||||
if (this.text && this.text != '') {
|
||||
const hashtags = parse(this.text).filter(x => x.node.type === 'hashtag').map(x => x.node.props.hashtag);
|
||||
const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
|
||||
localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
|
||||
}
|
||||
},
|
||||
|
||||
cancel() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
|
||||
insertMention() {
|
||||
const vm = this.$root.new(MkUserSelect, {});
|
||||
vm.$once('selected', user => {
|
||||
insertTextAtCursor(this.$refs.text, getAcct(user) + ' ');
|
||||
});
|
||||
},
|
||||
|
||||
insertEmoji(ev) {
|
||||
const vm = this.$root.new(XEmojiPicker, {
|
||||
source: ev.currentTarget || ev.target
|
||||
}).$once('chosen', emoji => {
|
||||
insertTextAtCursor(this.$refs.text, emoji);
|
||||
vm.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.gafaadew {
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 0 2px rgba(#000, 0.1);
|
||||
|
||||
> header {
|
||||
z-index: 1000;
|
||||
height: 66px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
> .cancel {
|
||||
padding: 0;
|
||||
font-size: 20px;
|
||||
width: 64px;
|
||||
line-height: 66px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
width: 50px;
|
||||
line-height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
> .text-count {
|
||||
line-height: 66px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
line-height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
> .submit {
|
||||
margin: 16px;
|
||||
padding: 0 16px;
|
||||
line-height: 34px;
|
||||
vertical-align: bottom;
|
||||
border-radius: 4px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .form {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
|
||||
> .preview {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
> .with-quote {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--accent);
|
||||
|
||||
> button {
|
||||
padding: 4px 8px;
|
||||
color: var(--accentAlpha04);
|
||||
|
||||
&:hover {
|
||||
color: var(--accentAlpha06);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--accentDarken30);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .to-specified {
|
||||
padding: 6px 24px;
|
||||
margin-bottom: 8px;
|
||||
overflow: auto;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 6px 16px;
|
||||
}
|
||||
|
||||
> .visibleUsers {
|
||||
display: inline;
|
||||
top: -1px;
|
||||
font-size: 14px;
|
||||
|
||||
> button {
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
> span {
|
||||
margin-right: 14px;
|
||||
padding: 8px 0 8px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--nwjktjjq);
|
||||
|
||||
> button {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> input {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
> input,
|
||||
> textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
padding: 0 24px;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font-family: initial;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
> textarea {
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 90px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
min-height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
> .mk-uploader {
|
||||
margin: 8px 0 0 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
> .file {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> footer {
|
||||
padding: 0 16px 16px 16px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 0 8px 8px 8px;
|
||||
}
|
||||
|
||||
> * {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background: var(--geavgsxy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
32
src/client/components/reaction-icon.vue
Normal file
32
src/client/components/reaction-icon.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<mk-emoji :emoji="reaction.startsWith(':') ? null : reaction" :name="reaction.startsWith(':') ? reaction.substr(1, reaction.length - 2) : null" :is-reaction="true" :custom-emojis="customEmojis" :normal="true" :no-style="noStyle"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
props: {
|
||||
reaction: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
noStyle: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
customEmojis: []
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.$root.getMeta().then(meta => {
|
||||
if (meta && meta.emojis) this.customEmojis = meta.emojis;
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
229
src/client/components/reaction-picker.vue
Normal file
229
src/client/components/reaction-picker.vue
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<template>
|
||||
<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }" v-hotkey.global="keymap">
|
||||
<div class="rdfaahpb">
|
||||
<transition-group
|
||||
name="reaction-fade"
|
||||
tag="div"
|
||||
class="buttons"
|
||||
ref="buttons"
|
||||
:class="{ showFocus }"
|
||||
:css="false"
|
||||
@before-enter="beforeEnter"
|
||||
@enter="enter"
|
||||
mode="out-in"
|
||||
appear
|
||||
>
|
||||
<button class="_button" v-for="(reaction, i) in rs" :key="reaction" @click="react(reaction)" :data-index="i" :tabindex="i + 1" :title="/^[a-z]+$/.test(reaction) ? $t('@.reactions.' + reaction) : reaction"><x-reaction-icon :reaction="reaction"/></button>
|
||||
</transition-group>
|
||||
<input class="text" v-model="text" :placeholder="$t('enterEmoji')" @keyup.enter="reactText" @input="tryReactText" v-autocomplete="{ model: 'text' }">
|
||||
</div>
|
||||
</x-popup>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import { emojiRegex } from '../../misc/emoji-regex';
|
||||
import XReactionIcon from './reaction-icon.vue';
|
||||
import XPopup from './popup.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XPopup,
|
||||
XReactionIcon,
|
||||
},
|
||||
|
||||
props: {
|
||||
source: {
|
||||
required: true
|
||||
},
|
||||
|
||||
reactions: {
|
||||
required: false
|
||||
},
|
||||
|
||||
showFocus: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
rs: this.reactions || this.$store.state.settings.reactions,
|
||||
text: null,
|
||||
focus: null
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
keymap(): any {
|
||||
return {
|
||||
'esc': this.close,
|
||||
'enter|space|plus': this.choose,
|
||||
'up|k': this.focusUp,
|
||||
'left|h|shift+tab': this.focusLeft,
|
||||
'right|l|tab': this.focusRight,
|
||||
'down|j': this.focusDown,
|
||||
'1': () => this.react(this.rs[0]),
|
||||
'2': () => this.react(this.rs[1]),
|
||||
'3': () => this.react(this.rs[2]),
|
||||
'4': () => this.react(this.rs[3]),
|
||||
'5': () => this.react(this.rs[4]),
|
||||
'6': () => this.react(this.rs[5]),
|
||||
'7': () => this.react(this.rs[6]),
|
||||
'8': () => this.react(this.rs[7]),
|
||||
'9': () => this.react(this.rs[8]),
|
||||
'0': () => this.react(this.rs[9]),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
focus(i) {
|
||||
this.$refs.buttons.children[i].elm.focus();
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.focus = 0;
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.$refs.popup.close();
|
||||
},
|
||||
|
||||
react(reaction) {
|
||||
this.$emit('chosen', reaction);
|
||||
},
|
||||
|
||||
reactText() {
|
||||
if (!this.text) return;
|
||||
this.react(this.text);
|
||||
},
|
||||
|
||||
tryReactText() {
|
||||
if (!this.text) return;
|
||||
if (!this.text.match(emojiRegex)) return;
|
||||
this.reactText();
|
||||
},
|
||||
|
||||
focusUp() {
|
||||
this.focus = this.focus == 0 ? 9 : this.focus < 5 ? (this.focus + 4) : (this.focus - 5);
|
||||
},
|
||||
|
||||
focusDown() {
|
||||
this.focus = this.focus == 9 ? 0 : this.focus >= 5 ? (this.focus - 4) : (this.focus + 5);
|
||||
},
|
||||
|
||||
focusRight() {
|
||||
this.focus = this.focus == 9 ? 0 : (this.focus + 1);
|
||||
},
|
||||
|
||||
focusLeft() {
|
||||
this.focus = this.focus == 0 ? 9 : (this.focus - 1);
|
||||
},
|
||||
|
||||
choose() {
|
||||
this.$refs.buttons.children[this.focus].elm.click();
|
||||
},
|
||||
|
||||
beforeEnter(el) {
|
||||
el.style.opacity = 0;
|
||||
el.style.transform = 'scale(0.7)';
|
||||
},
|
||||
|
||||
enter(el, done) {
|
||||
el.style.transition = [getComputedStyle(el).transition, 'transform 1s cubic-bezier(0.23, 1, 0.32, 1)', 'opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1)'].filter(x => x != '').join(',');
|
||||
setTimeout(() => {
|
||||
el.style.opacity = 1;
|
||||
el.style.transform = 'scale(1)';
|
||||
setTimeout(done, 1000);
|
||||
}, 0 * el.dataset.index)
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rdfaahpb {
|
||||
> .buttons {
|
||||
padding: 6px 6px 0 6px;
|
||||
width: 212px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
|
||||
@media (max-width: 1025px) {
|
||||
padding: 8px 8px 0 8px;
|
||||
width: 256px;
|
||||
}
|
||||
|
||||
&.showFocus {
|
||||
> button:focus {
|
||||
z-index: 1;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border: 2px solid var(--focus);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> button {
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 24px;
|
||||
border-radius: 2px;
|
||||
|
||||
@media (max-width: 1025px) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
> * {
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accent);
|
||||
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .text {
|
||||
width: 208px;
|
||||
padding: 8px;
|
||||
margin: 0 0 6px 0;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
|
||||
@media (max-width: 1025px) {
|
||||
width: 256px;
|
||||
margin: 4px 0 8px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
117
src/client/components/reactions-viewer.details.vue
Normal file
117
src/client/components/reactions-viewer.details.vue
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<transition name="zoom-in-top">
|
||||
<div class="buebdbiu" ref="popover" v-if="show">
|
||||
<template v-if="users.length <= 10">
|
||||
<b v-for="u in users" :key="u.id" style="margin-right: 12px;">
|
||||
<mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/>
|
||||
<mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/>
|
||||
</b>
|
||||
</template>
|
||||
<template v-if="10 < users.length">
|
||||
<b v-for="u in users" :key="u.id" style="margin-right: 12px;">
|
||||
<mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/>
|
||||
<mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/>
|
||||
</b>
|
||||
<span slot="omitted">+{{ count - 10 }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
props: {
|
||||
reaction: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
count: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
source: {
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show: false
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.show = true;
|
||||
|
||||
this.$nextTick(() => {
|
||||
const popover = this.$refs.popover as any;
|
||||
|
||||
if (this.source == null) {
|
||||
this.destroyDom();
|
||||
return;
|
||||
}
|
||||
const rect = this.source.getBoundingClientRect();
|
||||
|
||||
const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
|
||||
const y = rect.top + window.pageYOffset + this.source.offsetHeight;
|
||||
popover.style.left = (x - 28) + 'px';
|
||||
popover.style.top = (y + 16) + 'px';
|
||||
});
|
||||
}
|
||||
methods: {
|
||||
close() {
|
||||
this.show = false;
|
||||
setTimeout(this.destroyDom, 300);
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.buebdbiu {
|
||||
z-index: 10000;
|
||||
display: block;
|
||||
position: absolute;
|
||||
max-width: 240px;
|
||||
font-size: 0.8em;
|
||||
padding: 6px 8px;
|
||||
background: var(--panel);
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||
pointer-events: none;
|
||||
transform-origin: center -16px;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -28px;
|
||||
left: 12px;
|
||||
border-top: solid 14px transparent;
|
||||
border-right: solid 14px transparent;
|
||||
border-bottom: solid 14px rgba(0,0,0,0.1);
|
||||
border-left: solid 14px transparent;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -27px;
|
||||
left: 12px;
|
||||
border-top: solid 14px transparent;
|
||||
border-right: solid 14px transparent;
|
||||
border-bottom: solid 14px var(--panel);
|
||||
border-left: solid 14px transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
167
src/client/components/reactions-viewer.reaction.vue
Normal file
167
src/client/components/reactions-viewer.reaction.vue
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<template>
|
||||
<span
|
||||
class="reaction _button"
|
||||
:class="{ reacted: note.myReaction == reaction }"
|
||||
@click="toggleReaction(reaction)"
|
||||
v-if="count > 0"
|
||||
@mouseover="onMouseover"
|
||||
@mouseleave="onMouseleave"
|
||||
ref="reaction"
|
||||
>
|
||||
<x-reaction-icon :reaction="reaction" ref="icon"/>
|
||||
<span>{{ count }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XDetails from './reactions-viewer.details.vue';
|
||||
import XReactionIcon from './reaction-icon.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XReactionIcon
|
||||
},
|
||||
props: {
|
||||
reaction: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
count: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isInitial: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
note: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
canToggle: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
details: null,
|
||||
detailsTimeoutId: null,
|
||||
isHovering: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isMe(): boolean {
|
||||
return this.$store.getters.isSignedIn && this.$store.state.i.id === this.note.userId;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (!this.isInitial) this.anime();
|
||||
},
|
||||
watch: {
|
||||
count(newCount, oldCount) {
|
||||
if (oldCount < newCount) this.anime();
|
||||
if (this.details != null) this.openDetails();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleReaction() {
|
||||
if (this.isMe) return;
|
||||
if (!this.canToggle) return;
|
||||
|
||||
const oldReaction = this.note.myReaction;
|
||||
if (oldReaction) {
|
||||
this.$root.api('notes/reactions/delete', {
|
||||
noteId: this.note.id
|
||||
}).then(() => {
|
||||
if (oldReaction !== this.reaction) {
|
||||
this.$root.api('notes/reactions/create', {
|
||||
noteId: this.note.id,
|
||||
reaction: this.reaction
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.$root.api('notes/reactions/create', {
|
||||
noteId: this.note.id,
|
||||
reaction: this.reaction
|
||||
});
|
||||
}
|
||||
},
|
||||
onMouseover() {
|
||||
this.isHovering = true;
|
||||
this.detailsTimeoutId = setTimeout(this.openDetails, 300);
|
||||
},
|
||||
onMouseleave() {
|
||||
this.isHovering = false;
|
||||
clearTimeout(this.detailsTimeoutId);
|
||||
this.closeDetails();
|
||||
},
|
||||
openDetails() {
|
||||
if (this.$root.isMobile) return;
|
||||
this.$root.api('notes/reactions', {
|
||||
noteId: this.note.id,
|
||||
type: this.reaction,
|
||||
limit: 11
|
||||
}).then((reactions: any[]) => {
|
||||
const users = reactions
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.map(x => x.user);
|
||||
|
||||
this.closeDetails();
|
||||
if (!this.isHovering) return;
|
||||
this.details = this.$root.new(XDetails, {
|
||||
reaction: this.reaction,
|
||||
users,
|
||||
count: this.count,
|
||||
source: this.$refs.reaction
|
||||
});
|
||||
});
|
||||
},
|
||||
closeDetails() {
|
||||
if (this.details != null) {
|
||||
this.details.close();
|
||||
this.details = null;
|
||||
}
|
||||
},
|
||||
anime() {
|
||||
if (document.hidden) return;
|
||||
|
||||
// TODO
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.reaction {
|
||||
display: inline-block;
|
||||
height: 32px;
|
||||
margin: 2px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&.reacted {
|
||||
background: var(--accent);
|
||||
|
||||
> span {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.reacted) {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
> span {
|
||||
font-size: 0.9em;
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
48
src/client/components/reactions-viewer.vue
Normal file
48
src/client/components/reactions-viewer.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div class="mk-reactions-viewer" :class="{ isMe }">
|
||||
<x-reaction v-for="(count, reaction) in note.reactions" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note" :key="reaction"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XReaction from './reactions-viewer.reaction.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XReaction
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
initialReactions: new Set(Object.keys(this.note.reactions))
|
||||
};
|
||||
},
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isMe(): boolean {
|
||||
return this.$store.getters.isSignedIn && this.$store.state.i.id === this.note.userId;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-reactions-viewer {
|
||||
margin: 4px -2px 0 -2px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.isMe {
|
||||
> span {
|
||||
cursor: default !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
94
src/client/components/renote-picker.vue
Normal file
94
src/client/components/renote-picker.vue
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<template>
|
||||
<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }" v-hotkey.global="keymap">
|
||||
<div class="rdfaahpc">
|
||||
<button class="_button" @click="renote()"><fa :icon="faRetweet"/></button>
|
||||
<button class="_button" @click="quote()"><fa :icon="faQuoteRight"/></button>
|
||||
</div>
|
||||
</x-popup>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faQuoteRight, faRetweet } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import XPopup from './popup.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XPopup,
|
||||
},
|
||||
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
|
||||
source: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faQuoteRight, faRetweet
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
keymap(): any {
|
||||
return {
|
||||
'esc': this.close,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
renote() {
|
||||
(this as any).$root.api('notes/create', {
|
||||
renoteId: this.note.id
|
||||
}).then(() => {
|
||||
this.$emit('closed');
|
||||
this.destroyDom();
|
||||
});
|
||||
},
|
||||
|
||||
quote() {
|
||||
this.$emit('closed');
|
||||
this.destroyDom();
|
||||
this.$root.post({
|
||||
renote: this.note,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rdfaahpc {
|
||||
padding: 4px;
|
||||
|
||||
> button {
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
border-radius: 2px;
|
||||
|
||||
> * {
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accent);
|
||||
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
src/client/components/sequential-entrance.vue
Normal file
63
src/client/components/sequential-entrance.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<template>
|
||||
<transition-group
|
||||
name="staggered-fade"
|
||||
tag="div"
|
||||
:css="false"
|
||||
@before-enter="beforeEnter"
|
||||
@enter="enter"
|
||||
@leave="leave"
|
||||
mode="out-in"
|
||||
appear
|
||||
>
|
||||
<slot></slot>
|
||||
</transition-group>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
delay: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 40
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'down'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
beforeEnter(el) {
|
||||
el.style.opacity = 0;
|
||||
el.style.transform = this.direction === 'down' ? 'translateY(-64px)' : 'translateY(64px)';
|
||||
},
|
||||
enter(el, done) {
|
||||
el.style.transition = [getComputedStyle(el).transition, 'transform 0.7s cubic-bezier(0.23, 1, 0.32, 1)', 'opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1)'].filter(x => x != '').join(',');
|
||||
setTimeout(() => {
|
||||
el.style.opacity = 1;
|
||||
el.style.transform = 'translateY(0px)';
|
||||
setTimeout(done, 700);
|
||||
}, this.delay * el.dataset.index)
|
||||
},
|
||||
leave(el, done) {
|
||||
setTimeout(() => {
|
||||
el.style.opacity = 0;
|
||||
el.style.transform = this.direction === 'down' ? 'translateY(64px)' : 'translateY(-64px)';
|
||||
setTimeout(done, 700);
|
||||
}, this.delay * el.dataset.index)
|
||||
},
|
||||
focus() {
|
||||
this.$slots.default[0].elm.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.staggered-fade-move {
|
||||
transition: transform 0.7s !important;
|
||||
}
|
||||
</style>
|
||||
37
src/client/components/signin-dialog.vue
Normal file
37
src/client/components/signin-dialog.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }">
|
||||
<template #header>{{ $t('login') }}</template>
|
||||
<x-signin :auto-set="autoSet" @login="onLogin"/>
|
||||
</x-window>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import XWindow from './window.vue';
|
||||
import XSignin from './signin.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XSignin,
|
||||
XWindow,
|
||||
},
|
||||
|
||||
props: {
|
||||
autoSet: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onLogin(res) {
|
||||
this.$emit('login', res);
|
||||
this.$refs.window.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
219
src/client/components/signin.vue
Normal file
219
src/client/components/signin.vue
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<template>
|
||||
<form class="eppvobhk" :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
|
||||
<div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div>
|
||||
<div class="normal-signin" v-if="!totpLogin">
|
||||
<mk-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange">
|
||||
<span>{{ $t('username') }}</span>
|
||||
<template #prefix>@</template>
|
||||
<template #suffix>@{{ host }}</template>
|
||||
</mk-input>
|
||||
<mk-input v-model="password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required>
|
||||
<span>{{ $t('password') }}</span>
|
||||
<template #prefix><fa :icon="faLock"/></template>
|
||||
</mk-input>
|
||||
<mk-button type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $t('loggingIn') : $t('login') }}</mk-button>
|
||||
<p v-if="meta && meta.enableTwitterIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/twitter`"><fa :icon="['fab', 'twitter']"/> {{ $t('signin-with-twitter') }}</a></p>
|
||||
<p v-if="meta && meta.enableGithubIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/github`"><fa :icon="['fab', 'github']"/> {{ $t('signin-with-github') }}</a></p>
|
||||
<p v-if="meta && meta.enableDiscordIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/discord`"><fa :icon="['fab', 'discord']"/> {{ $t('signin-with-discord') /* TODO: Make these layouts better */ }}</a></p>
|
||||
</div>
|
||||
<div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }">
|
||||
<div v-if="user && user.securityKeys" class="twofa-group tap-group">
|
||||
<p>{{ $t('tap-key') }}</p>
|
||||
<mk-button @click="queryKey" v-if="!queryingKey">
|
||||
{{ $t('@.error.retry') }}
|
||||
</mk-button>
|
||||
</div>
|
||||
<div class="or-hr" v-if="user && user.securityKeys">
|
||||
<p class="or-msg">{{ $t('or') }}</p>
|
||||
</div>
|
||||
<div class="twofa-group totp-group">
|
||||
<p style="margin-bottom:0;">{{ $t('twoStepAuthentication') }}</p>
|
||||
<mk-input v-model="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required>
|
||||
<span>{{ $t('password') }}</span>
|
||||
<template #prefix><fa :icon="faLock"/></template>
|
||||
</mk-input>
|
||||
<mk-input v-model="token" type="number" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required>
|
||||
<span>{{ $t('token') }}</span>
|
||||
<template #prefix><fa :icon="faGavel"/></template>
|
||||
</mk-input>
|
||||
<mk-button type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? $t('loggingIn') : $t('login') }}</mk-button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { toUnicode } from 'punycode';
|
||||
import { faLock, faGavel } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkButton from './ui/button.vue';
|
||||
import MkInput from './ui/input.vue';
|
||||
import i18n from '../i18n';
|
||||
import { apiUrl, host } from '../config';
|
||||
import { hexifyAB } from '../scripts/2fa';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
MkInput,
|
||||
},
|
||||
|
||||
props: {
|
||||
withAvatar: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
autoSet: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
signing: false,
|
||||
user: null,
|
||||
username: '',
|
||||
password: '',
|
||||
token: '',
|
||||
apiUrl,
|
||||
host: toUnicode(host),
|
||||
meta: null,
|
||||
totpLogin: false,
|
||||
credential: null,
|
||||
challengeData: null,
|
||||
queryingKey: false,
|
||||
faLock, faGavel
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$root.getMeta().then(meta => {
|
||||
this.meta = meta;
|
||||
});
|
||||
|
||||
if (this.autoSet) {
|
||||
this.$once('login', res => {
|
||||
localStorage.setItem('i', res.i);
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onUsernameChange() {
|
||||
this.$root.api('users/show', {
|
||||
username: this.username
|
||||
}).then(user => {
|
||||
this.user = user;
|
||||
}, () => {
|
||||
this.user = null;
|
||||
});
|
||||
},
|
||||
|
||||
queryKey() {
|
||||
this.queryingKey = true;
|
||||
return navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: Buffer.from(
|
||||
this.challengeData.challenge
|
||||
.replace(/\-/g, '+')
|
||||
.replace(/_/g, '/'),
|
||||
'base64'
|
||||
),
|
||||
allowCredentials: this.challengeData.securityKeys.map(key => ({
|
||||
id: Buffer.from(key.id, 'hex'),
|
||||
type: 'public-key',
|
||||
transports: ['usb', 'nfc', 'ble', 'internal']
|
||||
})),
|
||||
timeout: 60 * 1000
|
||||
}
|
||||
}).catch(() => {
|
||||
this.queryingKey = false;
|
||||
return Promise.reject(null);
|
||||
}).then(credential => {
|
||||
this.queryingKey = false;
|
||||
this.signing = true;
|
||||
return this.$root.api('signin', {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
signature: hexifyAB(credential.response.signature),
|
||||
authenticatorData: hexifyAB(credential.response.authenticatorData),
|
||||
clientDataJSON: hexifyAB(credential.response.clientDataJSON),
|
||||
credentialId: credential.id,
|
||||
challengeId: this.challengeData.challengeId
|
||||
});
|
||||
}).then(res => {
|
||||
this.$emit('login', res);
|
||||
}).catch(err => {
|
||||
if (err === null) return;
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('login-failed')
|
||||
});
|
||||
this.signing = false;
|
||||
});
|
||||
},
|
||||
|
||||
onSubmit() {
|
||||
this.signing = true;
|
||||
if (!this.totpLogin && this.user && this.user.twoFactorEnabled) {
|
||||
if (window.PublicKeyCredential && this.user.securityKeys) {
|
||||
this.$root.api('signin', {
|
||||
username: this.username,
|
||||
password: this.password
|
||||
}).then(res => {
|
||||
this.totpLogin = true;
|
||||
this.signing = false;
|
||||
this.challengeData = res;
|
||||
return this.queryKey();
|
||||
}).catch(() => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('login-failed')
|
||||
});
|
||||
this.challengeData = null;
|
||||
this.totpLogin = false;
|
||||
this.signing = false;
|
||||
});
|
||||
} else {
|
||||
this.totpLogin = true;
|
||||
this.signing = false;
|
||||
}
|
||||
} else {
|
||||
this.$root.api('signin', {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
token: this.user && this.user.twoFactorEnabled ? this.token : undefined
|
||||
}).then(res => {
|
||||
this.$emit('login', res);
|
||||
}).catch(() => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('loginFailed')
|
||||
});
|
||||
this.signing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.eppvobhk {
|
||||
> .avatar {
|
||||
margin: 0 auto 0 auto;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #ddd;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
22
src/client/components/signup-dialog.vue
Normal file
22
src/client/components/signup-dialog.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<x-window @closed="() => { $emit('closed'); destroyDom(); }">
|
||||
<template #header>{{ $t('signup') }}</template>
|
||||
<x-signup/>
|
||||
</x-window>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import XWindow from './window.vue';
|
||||
import XSignup from './signup.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XSignup,
|
||||
XWindow,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
191
src/client/components/signup.vue
Normal file
191
src/client/components/signup.vue
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<template>
|
||||
<form class="mk-signup" @submit.prevent="onSubmit" :autocomplete="Math.random()">
|
||||
<template v-if="meta">
|
||||
<mk-input v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required>
|
||||
<span>{{ $t('invitation-code') }}</span>
|
||||
<template #prefix><fa icon="id-card-alt"/></template>
|
||||
<template #desc v-html="this.$t('invitation-info').replace('{}', 'mailto:' + meta.maintainerEmail)"></template>
|
||||
</mk-input>
|
||||
<mk-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @input="onChangeUsername">
|
||||
<span>{{ $t('username') }}</span>
|
||||
<template #prefix>@</template>
|
||||
<template #suffix>@{{ host }}</template>
|
||||
<template #desc>
|
||||
<span v-if="usernameState == 'wait'" style="color:#999"><fa :icon="faSpinner" pulse fixed-width/> {{ $t('checking') }}</span>
|
||||
<span v-if="usernameState == 'ok'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('available') }}</span>
|
||||
<span v-if="usernameState == 'unavailable'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('unavailable') }}</span>
|
||||
<span v-if="usernameState == 'error'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('error') }}</span>
|
||||
<span v-if="usernameState == 'invalid-format'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('invalid-format') }}</span>
|
||||
<span v-if="usernameState == 'min-range'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('too-short') }}</span>
|
||||
<span v-if="usernameState == 'max-range'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('too-long') }}</span>
|
||||
</template>
|
||||
</mk-input>
|
||||
<mk-input v-model="password" type="password" :autocomplete="Math.random()" required @input="onChangePassword">
|
||||
<span>{{ $t('password') }}</span>
|
||||
<template #prefix><fa :icon="faLock"/></template>
|
||||
<template #desc>
|
||||
<p v-if="passwordStrength == 'low'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('weak-password') }}</p>
|
||||
<p v-if="passwordStrength == 'medium'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('normal-password') }}</p>
|
||||
<p v-if="passwordStrength == 'high'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('strong-password') }}</p>
|
||||
</template>
|
||||
</mk-input>
|
||||
<mk-input v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @input="onChangePasswordRetype">
|
||||
<span>{{ $t('password') }} ({{ $t('retype') }})</span>
|
||||
<template #prefix><fa :icon="faLock"/></template>
|
||||
<template #desc>
|
||||
<p v-if="passwordRetypeState == 'match'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('password-matched') }}</p>
|
||||
<p v-if="passwordRetypeState == 'not-match'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('password-not-matched') }}</p>
|
||||
</template>
|
||||
</mk-input>
|
||||
<mk-switch v-model="ToSAgreement" v-if="meta.tosUrl">
|
||||
<i18n path="agreeTo">
|
||||
<a :href="meta.tosUrl" target="_blank">{{ $t('tos') }}</a>
|
||||
</i18n>
|
||||
</mk-switch>
|
||||
<div v-if="meta.enableRecaptcha" class="g-recaptcha" :data-sitekey="meta.recaptchaSiteKey" style="margin: 16px 0;"></div>
|
||||
<mk-button type="submit" :disabled=" submitting || !(meta.tosUrl ? ToSAgreement : true) || passwordRetypeState == 'not-match'" primary>{{ $t('start') }}</mk-button>
|
||||
</template>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faLock, faExclamationTriangle, faSpinner, faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||
const getPasswordStrength = require('syuilo-password-strength');
|
||||
import { toUnicode } from 'punycode';
|
||||
import i18n from '../i18n';
|
||||
import { host, url } from '../config';
|
||||
import MkButton from './ui/button.vue';
|
||||
import MkInput from './ui/input.vue';
|
||||
import MkSwitch from './ui/switch.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
MkInput,
|
||||
MkSwitch,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
host: toUnicode(host),
|
||||
username: '',
|
||||
password: '',
|
||||
retypedPassword: '',
|
||||
invitationCode: '',
|
||||
url,
|
||||
usernameState: null,
|
||||
passwordStrength: '',
|
||||
passwordRetypeState: null,
|
||||
meta: {},
|
||||
submitting: false,
|
||||
ToSAgreement: false,
|
||||
faLock, faExclamationTriangle, faSpinner, faCheck
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
shouldShowProfileUrl(): boolean {
|
||||
return (this.username != '' &&
|
||||
this.usernameState != 'invalid-format' &&
|
||||
this.usernameState != 'min-range' &&
|
||||
this.usernameState != 'max-range');
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$root.getMeta().then(meta => {
|
||||
this.meta = meta;
|
||||
});
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
methods: {
|
||||
onChangeUsername() {
|
||||
if (this.username == '') {
|
||||
this.usernameState = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const err =
|
||||
!this.username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' :
|
||||
this.username.length < 1 ? 'min-range' :
|
||||
this.username.length > 20 ? 'max-range' :
|
||||
null;
|
||||
|
||||
if (err) {
|
||||
this.usernameState = err;
|
||||
return;
|
||||
}
|
||||
|
||||
this.usernameState = 'wait';
|
||||
|
||||
this.$root.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';
|
||||
},
|
||||
|
||||
onChangePasswordRetype() {
|
||||
if (this.retypedPassword == '') {
|
||||
this.passwordRetypeState = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.passwordRetypeState = this.password == this.retypedPassword ? 'match' : 'not-match';
|
||||
},
|
||||
|
||||
onSubmit() {
|
||||
if (this.submitting) return;
|
||||
this.submitting = true;
|
||||
|
||||
this.$root.api('signup', {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
invitationCode: this.invitationCode,
|
||||
'g-recaptcha-response': this.meta.enableRecaptcha ? (window as any).grecaptcha.getResponse() : null
|
||||
}).then(() => {
|
||||
this.$root.api('signin', {
|
||||
username: this.username,
|
||||
password: this.password
|
||||
}).then(res => {
|
||||
localStorage.setItem('i', res.i);
|
||||
location.href = '/';
|
||||
});
|
||||
}).catch(() => {
|
||||
this.submitting = false;
|
||||
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('some-error')
|
||||
});
|
||||
|
||||
if (this.meta.enableRecaptcha) {
|
||||
(window as any).grecaptcha.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
65
src/client/components/sub-note-content.vue
Normal file
65
src/client/components/sub-note-content.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<div class="wrmlmaau">
|
||||
<div class="body">
|
||||
<span v-if="note.isHidden" style="opacity: 0.5">({{ $t('private') }})</span>
|
||||
<span v-if="note.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span>
|
||||
<router-link class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><fa :icon="faReply"/></router-link>
|
||||
<mfm v-if="note.text" :text="note.text" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis"/>
|
||||
<router-link class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</router-link>
|
||||
</div>
|
||||
<details v-if="note.files.length > 0">
|
||||
<summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary>
|
||||
<x-media-list :media-list="note.files"/>
|
||||
</details>
|
||||
<details v-if="note.poll">
|
||||
<summary>{{ $t('poll') }}</summary>
|
||||
<x-poll :note="note"/>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faReply } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import XPoll from './poll.vue';
|
||||
import XMediaList from './media-list.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
components: {
|
||||
XPoll,
|
||||
XMediaList,
|
||||
},
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
faReply
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wrmlmaau {
|
||||
overflow-wrap: break-word;
|
||||
|
||||
> .body {
|
||||
> .reply {
|
||||
margin-right: 6px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
> .rp {
|
||||
margin-left: 4px;
|
||||
font-style: oblique;
|
||||
color: var(--renote);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
74
src/client/components/time.vue
Normal file
74
src/client/components/time.vue
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<time class="mk-time" :title="absolute">
|
||||
<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';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
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 {
|
||||
return this._time.toLocaleString();
|
||||
},
|
||||
relative(): string {
|
||||
const time = this._time;
|
||||
const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/;
|
||||
return (
|
||||
ago >= 31536000 ? this.$t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) :
|
||||
ago >= 2592000 ? this.$t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) :
|
||||
ago >= 604800 ? this.$t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) :
|
||||
ago >= 86400 ? this.$t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) :
|
||||
ago >= 3600 ? this.$t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) :
|
||||
ago >= 60 ? this.$t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
|
||||
ago >= 10 ? this.$t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
|
||||
ago >= -1 ? this.$t('_ago.justNow') :
|
||||
ago < -1 ? this.$t('_ago.future') :
|
||||
this.$t('@.time.unknown'));
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.mode == 'relative' || this.mode == 'detail') {
|
||||
this.tickId = window.requestAnimationFrame(this.tick);
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
if (this.mode === 'relative' || this.mode === 'detail') {
|
||||
window.clearTimeout(this.tickId);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
tick() {
|
||||
this.now = new Date();
|
||||
|
||||
this.tickId = setTimeout(() => {
|
||||
window.requestAnimationFrame(this.tick);
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
118
src/client/components/timeline.vue
Normal file
118
src/client/components/timeline.vue
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<x-notes ref="tl" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XNotes from './notes.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XNotes
|
||||
},
|
||||
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
list: {
|
||||
required: false
|
||||
},
|
||||
antenna: {
|
||||
required: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
connection: null,
|
||||
pagination: null,
|
||||
baseQuery: {
|
||||
includeMyRenotes: this.$store.state.settings.showMyRenotes,
|
||||
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
|
||||
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
|
||||
},
|
||||
query: {},
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$once('hook:beforeDestroy', () => {
|
||||
this.connection.dispose();
|
||||
});
|
||||
|
||||
const prepend = note => {
|
||||
(this.$refs.tl as any).prepend(note);
|
||||
};
|
||||
|
||||
const onUserAdded = () => {
|
||||
(this.$refs.tl as any).reload();
|
||||
};
|
||||
|
||||
const onUserRemoved = () => {
|
||||
(this.$refs.tl as any).reload();
|
||||
};
|
||||
|
||||
let endpoint;
|
||||
|
||||
if (this.src == 'antenna') {
|
||||
endpoint = 'antennas/notes';
|
||||
this.query = {
|
||||
antennaId: this.antenna.id
|
||||
};
|
||||
this.connection = this.$root.stream.connectToChannel('antenna', {
|
||||
antennaId: this.antenna.id
|
||||
});
|
||||
this.connection.on('note', prepend);
|
||||
} else if (this.src == 'home') {
|
||||
endpoint = 'notes/timeline';
|
||||
const onChangeFollowing = () => {
|
||||
this.fetch();
|
||||
};
|
||||
this.connection = this.$root.stream.useSharedConnection('homeTimeline');
|
||||
this.connection.on('note', prepend);
|
||||
this.connection.on('follow', onChangeFollowing);
|
||||
this.connection.on('unfollow', onChangeFollowing);
|
||||
} else if (this.src == 'local') {
|
||||
endpoint = 'notes/local-timeline';
|
||||
this.connection = this.$root.stream.useSharedConnection('localTimeline');
|
||||
this.connection.on('note', prepend);
|
||||
} else if (this.src == 'social') {
|
||||
endpoint = 'notes/hybrid-timeline';
|
||||
this.connection = this.$root.stream.useSharedConnection('hybridTimeline');
|
||||
this.connection.on('note', prepend);
|
||||
} else if (this.src == 'global') {
|
||||
endpoint = 'notes/global-timeline';
|
||||
this.connection = this.$root.stream.useSharedConnection('globalTimeline');
|
||||
this.connection.on('note', prepend);
|
||||
} else if (this.src == 'list') {
|
||||
endpoint = 'notes/user-list-timeline';
|
||||
this.query = {
|
||||
listId: this.list.id
|
||||
};
|
||||
this.connection = this.$root.stream.connectToChannel('userList', {
|
||||
listId: this.list.id
|
||||
});
|
||||
this.connection.on('note', prepend);
|
||||
this.connection.on('userAdded', onUserAdded);
|
||||
this.connection.on('userRemoved', onUserRemoved);
|
||||
}
|
||||
|
||||
this.pagination = {
|
||||
endpoint: endpoint,
|
||||
limit: 10,
|
||||
params: init => ({
|
||||
untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
|
||||
...this.baseQuery, ...this.query
|
||||
})
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
focus() {
|
||||
this.$refs.tl.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
76
src/client/components/toast.vue
Normal file
76
src/client/components/toast.vue
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<template>
|
||||
<div class="mk-toast">
|
||||
<transition name="notification-slide" appear @after-leave="() => { destroyDom(); }">
|
||||
<x-notification :notification="notification" class="notification" v-if="show"/>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XNotification from './notification.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XNotification
|
||||
},
|
||||
props: {
|
||||
notification: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show: true
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
setTimeout(() => {
|
||||
this.show = false;
|
||||
}, 6000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notification-slide-enter-active, .notification-slide-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s !important;
|
||||
}
|
||||
.notification-slide-enter, .notification-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-250px);
|
||||
}
|
||||
|
||||
.mk-toast {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
left: 0;
|
||||
width: 250px;
|
||||
top: 32px;
|
||||
padding: 0 32px;
|
||||
pointer-events: none;
|
||||
|
||||
@media (max-width: 700px) {
|
||||
top: initial;
|
||||
bottom: 112px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
bottom: 92px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
> .notification {
|
||||
height: 100%;
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
background-color: var(--toastBg);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
color: var(--toastFg);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
204
src/client/components/ui/button.vue
Normal file
204
src/client/components/ui/button.vue
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<template>
|
||||
<component class="bghgjjyj _button"
|
||||
:is="link ? 'a' : 'button'"
|
||||
:class="{ inline, primary }"
|
||||
:type="type"
|
||||
@click="$emit('click', $event)"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
<div ref="ripples" class="ripples"></div>
|
||||
<div class="content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
primary: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
link: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
wait: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.autofocus) {
|
||||
this.$nextTick(() => {
|
||||
this.$el.focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onMousedown(e: MouseEvent) {
|
||||
function distance(p, q) {
|
||||
return Math.hypot(p.x - q.x, p.y - q.y);
|
||||
}
|
||||
|
||||
function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY) {
|
||||
const origin = {x: circleCenterX, y: circleCenterY};
|
||||
const dist1 = distance({x: 0, y: 0}, origin);
|
||||
const dist2 = distance({x: boxW, y: 0}, origin);
|
||||
const dist3 = distance({x: 0, y: boxH}, origin);
|
||||
const dist4 = distance({x: boxW, y: boxH }, origin);
|
||||
return Math.max(dist1, dist2, dist3, dist4) * 2;
|
||||
}
|
||||
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
|
||||
const ripple = document.createElement('div');
|
||||
ripple.style.top = (e.clientY - rect.top - 1).toString() + 'px';
|
||||
ripple.style.left = (e.clientX - rect.left - 1).toString() + 'px';
|
||||
|
||||
this.$refs.ripples.appendChild(ripple);
|
||||
|
||||
const circleCenterX = e.clientX - rect.left;
|
||||
const circleCenterY = e.clientY - rect.top;
|
||||
|
||||
const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY);
|
||||
|
||||
setTimeout(() => {
|
||||
ripple.style.transform = 'scale(' + (scale / 2) + ')';
|
||||
}, 1);
|
||||
setTimeout(() => {
|
||||
ripple.style.transition = 'all 1s ease';
|
||||
ripple.style.opacity = '0';
|
||||
}, 1000);
|
||||
setTimeout(() => {
|
||||
if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bghgjjyj {
|
||||
position: relative;
|
||||
display: block;
|
||||
min-width: 100px;
|
||||
padding: 8px 14px;
|
||||
text-align: center;
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
box-shadow: none;
|
||||
text-decoration: none;
|
||||
background: var(--buttonBg);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--jkhztclx);
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: var(--jkhztclx);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
border: 2px solid var(--accentAlpha03);
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&.inline + .bghgjjyj {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
&:not(.inline) + .bghgjjyj {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&.inline {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> .ripples {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
::v-deep div {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 100%;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transition: all 0.5s cubic-bezier(0,.5,0,1);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary > .ripples ::v-deep div {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
> .content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
104
src/client/components/ui/container.vue
Normal file
104
src/client/components/ui/container.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader }">
|
||||
<header v-if="showHeader">
|
||||
<div class="title"><slot name="header"></slot></div>
|
||||
<slot name="func"></slot>
|
||||
<button class="_button" v-if="bodyTogglable" @click="() => showBody = !showBody">
|
||||
<template v-if="showBody"><fa :icon="faAngleUp"/></template>
|
||||
<template v-else><fa :icon="faAngleDown"/></template>
|
||||
</button>
|
||||
</header>
|
||||
<div v-show="showBody">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
naked: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
bodyTogglable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showBody: this.expanded,
|
||||
faAngleUp, faAngleDown
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
toggleContent(show: boolean) {
|
||||
if (!this.bodyTogglable) return;
|
||||
this.showBody = show;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ukygtjoj {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
& + .ukygtjoj {
|
||||
margin-top: var(--margin);
|
||||
}
|
||||
|
||||
&.naked {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
> header {
|
||||
position: relative;
|
||||
|
||||
> .title {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> button {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 0;
|
||||
width: 42px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
src/client/components/ui/hr.vue
Normal file
15
src/client/components/ui/hr.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<div class="evrzpitu"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
export default Vue.extend({});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.evrzpitu
|
||||
margin 16px 0
|
||||
border-bottom solid var(--lineWidth) var(--faceDivider)
|
||||
|
||||
</style>
|
||||
55
src/client/components/ui/info.vue
Normal file
55
src/client/components/ui/info.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<template>
|
||||
<div class="fpezltsf" :class="{ warn }">
|
||||
<i v-if="warn"><fa :icon="faExclamationTriangle"/></i>
|
||||
<i v-else><fa :icon="faInfoCircle"/></i>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faInfoCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
warn: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
faInfoCircle, faExclamationTriangle
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fpezltsf {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
font-size: 90%;
|
||||
background: var(--infoBg);
|
||||
color: var(--infoFg);
|
||||
border-radius: 5px;
|
||||
|
||||
&.warn {
|
||||
background: var(--infoWarnBg);
|
||||
color: var(--infoWarnFg);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
443
src/client/components/ui/input.vue
Normal file
443
src/client/components/ui/input.vue
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
<template>
|
||||
<div class="juejbjww" :class="{ focused, filled, inline, disabled }">
|
||||
<div class="icon" ref="icon"><slot name="icon"></slot></div>
|
||||
<div class="input">
|
||||
<span class="label" ref="label"><slot></slot></span>
|
||||
<span class="title" ref="title">
|
||||
<slot name="title"></slot>
|
||||
<span class="warning" v-if="invalid"><fa :icon="faExclamationCircle"/>{{ $refs.input.validationMessage }}</span>
|
||||
</span>
|
||||
<div class="prefix" ref="prefix"><slot name="prefix"></slot></div>
|
||||
<template v-if="type != 'file'">
|
||||
<input v-if="debounce" ref="input"
|
||||
v-debounce="500"
|
||||
:type="type"
|
||||
v-model.lazy="v"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:readonly="readonly"
|
||||
:placeholder="placeholder"
|
||||
:pattern="pattern"
|
||||
:autocomplete="autocomplete"
|
||||
:spellcheck="spellcheck"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
@keydown="$emit('keydown', $event)"
|
||||
@input="onInput"
|
||||
:list="id"
|
||||
>
|
||||
<input v-else ref="input"
|
||||
:type="type"
|
||||
v-model="v"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:readonly="readonly"
|
||||
:placeholder="placeholder"
|
||||
:pattern="pattern"
|
||||
:autocomplete="autocomplete"
|
||||
:spellcheck="spellcheck"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
@keydown="$emit('keydown', $event)"
|
||||
@input="onInput"
|
||||
:list="id"
|
||||
>
|
||||
<datalist :id="id" v-if="datalist">
|
||||
<option v-for="data in datalist" :value="data"/>
|
||||
</datalist>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input ref="input"
|
||||
type="text"
|
||||
:value="filePlaceholder"
|
||||
readonly
|
||||
@click="chooseFile"
|
||||
>
|
||||
<input ref="file"
|
||||
type="file"
|
||||
:value="value"
|
||||
@change="onChangeFile"
|
||||
>
|
||||
</template>
|
||||
<div class="suffix" ref="suffix"><slot name="suffix"></slot></div>
|
||||
</div>
|
||||
<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button>
|
||||
<div class="desc"><slot name="desc"></slot></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import debounce from 'v-debounce';
|
||||
import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default Vue.extend({
|
||||
directives: {
|
||||
debounce
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: false
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
pattern: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
autocomplete: {
|
||||
required: false
|
||||
},
|
||||
spellcheck: {
|
||||
required: false
|
||||
},
|
||||
debounce: {
|
||||
required: false
|
||||
},
|
||||
datalist: {
|
||||
type: Array,
|
||||
required: false,
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
save: {
|
||||
type: Function,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
v: this.value,
|
||||
focused: false,
|
||||
invalid: false,
|
||||
changed: false,
|
||||
id: Math.random().toString(),
|
||||
faExclamationCircle
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filled(): boolean {
|
||||
return this.v !== '' && this.v != null;
|
||||
},
|
||||
filePlaceholder(): string | null {
|
||||
if (this.type != 'file') return null;
|
||||
if (this.v == null) return null;
|
||||
|
||||
if (typeof this.v == 'string') return this.v;
|
||||
|
||||
if (Array.isArray(this.v)) {
|
||||
return this.v.map(file => file.name).join(', ');
|
||||
} else {
|
||||
return this.v.name;
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value(v) {
|
||||
this.v = v;
|
||||
},
|
||||
v(v) {
|
||||
if (this.type === 'number') {
|
||||
this.$emit('input', parseInt(v, 10));
|
||||
} else {
|
||||
this.$emit('input', v);
|
||||
}
|
||||
|
||||
this.invalid = this.$refs.input.validity.badInput;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.autofocus) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
// このコンポーネントが作成された時、非表示状態である場合がある
|
||||
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
|
||||
const clock = setInterval(() => {
|
||||
if (this.$refs.prefix) {
|
||||
this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px';
|
||||
if (this.$refs.prefix.offsetWidth) {
|
||||
this.$refs.input.style.paddingLeft = this.$refs.prefix.offsetWidth + 'px';
|
||||
}
|
||||
}
|
||||
if (this.$refs.suffix) {
|
||||
if (this.$refs.suffix.offsetWidth) {
|
||||
this.$refs.input.style.paddingRight = this.$refs.suffix.offsetWidth + 'px';
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
this.$once('hook:beforeDestroy', () => {
|
||||
clearInterval(clock);
|
||||
});
|
||||
});
|
||||
|
||||
this.$on('keydown', (e: KeyboardEvent) => {
|
||||
if (e.code == 'Enter') {
|
||||
this.$emit('enter');
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
focus() {
|
||||
this.$refs.input.focus();
|
||||
},
|
||||
togglePassword() {
|
||||
if (this.type == 'password') {
|
||||
this.type = 'text'
|
||||
} else {
|
||||
this.type = 'password'
|
||||
}
|
||||
},
|
||||
chooseFile() {
|
||||
this.$refs.file.click();
|
||||
},
|
||||
onChangeFile() {
|
||||
this.v = Array.from((this.$refs.file as any).files);
|
||||
this.$emit('input', this.v);
|
||||
this.$emit('change', this.v);
|
||||
},
|
||||
onInput(ev) {
|
||||
this.changed = true;
|
||||
this.$emit('change', ev);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.juejbjww {
|
||||
position: relative;
|
||||
margin: 32px 0;
|
||||
|
||||
> .icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
line-height: 32px;
|
||||
|
||||
&:not(:empty) + .input {
|
||||
margin-left: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
> .input {
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--inputBorder);
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
transform: scaleX(0.12);
|
||||
transition: border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: border opacity transform;
|
||||
}
|
||||
|
||||
> .label {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
transition-duration: 0.3s;
|
||||
font-size: 16px;
|
||||
line-height: 32px;
|
||||
color: var(--inputLabel);
|
||||
pointer-events: none;
|
||||
//will-change transform
|
||||
transform-origin: top left;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
> .title {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -17px;
|
||||
left: 0 !important;
|
||||
pointer-events: none;
|
||||
font-size: 16px;
|
||||
line-height: 32px;
|
||||
color: var(--inputLabel);
|
||||
pointer-events: none;
|
||||
//will-change transform
|
||||
transform-origin: top left;
|
||||
transform: scale(.75);
|
||||
white-space: nowrap;
|
||||
width: 133%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
> .warning {
|
||||
margin-left: 0.5em;
|
||||
color: var(--infoWarnFg);
|
||||
|
||||
> svg {
|
||||
margin-right: 0.1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-weight: normal;
|
||||
font-size: 16px;
|
||||
line-height: 32px;
|
||||
color: var(--inputText);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
&[type='file'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .prefix,
|
||||
> .suffix {
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
font-size: 16px;
|
||||
line-height: 32px;
|
||||
color: var(--inputLabel);
|
||||
pointer-events: none;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> * {
|
||||
display: inline-block;
|
||||
min-width: 16px;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
> .prefix {
|
||||
left: 0;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
> .suffix {
|
||||
right: 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .save {
|
||||
margin: 6px 0 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
> .desc {
|
||||
margin: 6px 0 0 0;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.focused {
|
||||
> .input {
|
||||
&:after {
|
||||
opacity: 1;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
> .label {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.focused,
|
||||
&.filled {
|
||||
> .input {
|
||||
> .label {
|
||||
top: -17px;
|
||||
left: 0 !important;
|
||||
transform: scale(0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.inline {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.7;
|
||||
|
||||
&, * {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
59
src/client/components/ui/pagination.vue
Normal file
59
src/client/components/ui/pagination.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<sequential-entrance class="cxiknjgy" :class="{ autoMargin }">
|
||||
<slot :items="items"></slot>
|
||||
<div class="empty" v-if="empty" key="_empty_">
|
||||
<slot name="empty"></slot>
|
||||
</div>
|
||||
<div class="more" v-if="more" key="_more_">
|
||||
<mk-button :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore()">
|
||||
<template v-if="!moreFetching">{{ $t('loadMore') }}</template>
|
||||
<template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template>
|
||||
</mk-button>
|
||||
</div>
|
||||
</sequential-entrance>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkButton from './button.vue';
|
||||
import paging from '../../scripts/paging';
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [
|
||||
paging({}),
|
||||
],
|
||||
|
||||
components: {
|
||||
MkButton
|
||||
},
|
||||
|
||||
props: {
|
||||
pagination: {
|
||||
required: true
|
||||
},
|
||||
autoMargin: {
|
||||
required: false,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faSpinner
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cxiknjgy {
|
||||
&.autoMargin > *:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
119
src/client/components/ui/radio.vue
Normal file
119
src/client/components/ui/radio.vue
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<template>
|
||||
<div
|
||||
class="novjtctn"
|
||||
:class="{ disabled, checked }"
|
||||
:aria-checked="checked"
|
||||
:aria-disabled="disabled"
|
||||
@click="toggle"
|
||||
>
|
||||
<input type="radio"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span class="button">
|
||||
<span></span>
|
||||
</span>
|
||||
<span class="label"><slot></slot></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
export default Vue.extend({
|
||||
model: {
|
||||
prop: 'model',
|
||||
event: 'change'
|
||||
},
|
||||
props: {
|
||||
model: {
|
||||
required: false
|
||||
},
|
||||
value: {
|
||||
required: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
checked(): boolean {
|
||||
return this.model === this.value;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggle() {
|
||||
this.$emit('change', this.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.novjtctn {
|
||||
display: inline-block;
|
||||
margin: 0 32px 0 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
> * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.checked {
|
||||
> .button {
|
||||
border-color: var(--radioActive);
|
||||
|
||||
&:after {
|
||||
background-color: var(--radioActive);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> .button {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: none;
|
||||
border: solid 2px var(--inputLabel);
|
||||
border-radius: 100%;
|
||||
transition: inherit;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
left: 3px;
|
||||
border-radius: 100%;
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
}
|
||||
|
||||
> .label {
|
||||
margin-left: 28px;
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
220
src/client/components/ui/select.vue
Normal file
220
src/client/components/ui/select.vue
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<template>
|
||||
<div class="eiipwacr" :class="{ focused, disabled, filled, inline }">
|
||||
<div class="icon" ref="icon"><slot name="icon"></slot></div>
|
||||
<div class="input" @click="focus">
|
||||
<span class="label" ref="label"><slot name="label"></slot></span>
|
||||
<div class="prefix" ref="prefix"><slot name="prefix"></slot></div>
|
||||
<select ref="input"
|
||||
v-model="v"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
>
|
||||
<slot></slot>
|
||||
</select>
|
||||
<div class="suffix"><slot name="suffix"></slot></div>
|
||||
</div>
|
||||
<div class="text"><slot name="text"></slot></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: {
|
||||
required: false
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
focused: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
v: {
|
||||
get() {
|
||||
return this.value;
|
||||
},
|
||||
set(v) {
|
||||
this.$emit('input', v);
|
||||
}
|
||||
},
|
||||
filled(): boolean {
|
||||
return this.v != '' && this.v != null;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.$refs.prefix) {
|
||||
this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
focus() {
|
||||
this.$refs.input.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.eiipwacr {
|
||||
position: relative;
|
||||
margin: 32px 0;
|
||||
|
||||
> .icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
line-height: 32px;
|
||||
|
||||
&:not(:empty) + .input {
|
||||
margin-left: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
> .input {
|
||||
display: flex;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--inputBorder);
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
transform: scaleX(0.12);
|
||||
transition: border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: border opacity transform;
|
||||
}
|
||||
|
||||
> .label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
transition-duration: 0.3s;
|
||||
font-size: 16px;
|
||||
line-height: 32px;
|
||||
pointer-events: none;
|
||||
//will-change transform
|
||||
transform-origin: top left;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
> select {
|
||||
display: block;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-weight: normal;
|
||||
font-size: 16px;
|
||||
height: 32px;
|
||||
background: var(--panel);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
> .prefix,
|
||||
> .suffix {
|
||||
display: block;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
font-size: 16px;
|
||||
line-height: 32px;
|
||||
color: rgba(#000, 0.54);
|
||||
pointer-events: none;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> * {
|
||||
display: block;
|
||||
min-width: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
> .prefix {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
> .suffix {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .text {
|
||||
margin: 6px 0;
|
||||
font-size: 13px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.focused {
|
||||
> .input {
|
||||
&:after {
|
||||
opacity: 1;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
> .label {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.focused,
|
||||
&.filled {
|
||||
> .input {
|
||||
> .label {
|
||||
top: -17px;
|
||||
left: 0 !important;
|
||||
transform: scale(0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
150
src/client/components/ui/switch.vue
Normal file
150
src/client/components/ui/switch.vue
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<template>
|
||||
<div
|
||||
class="ziffeoms"
|
||||
:class="{ disabled, checked }"
|
||||
role="switch"
|
||||
:aria-checked="checked"
|
||||
:aria-disabled="disabled"
|
||||
@click="toggle"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
ref="input"
|
||||
:disabled="disabled"
|
||||
@keydown.enter="toggle"
|
||||
>
|
||||
<span class="button">
|
||||
<span></span>
|
||||
</span>
|
||||
<span class="label">
|
||||
<span :aria-hidden="!checked"><slot></slot></span>
|
||||
<p :aria-hidden="!checked">
|
||||
<slot name="desc"></slot>
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
export default Vue.extend({
|
||||
model: {
|
||||
prop: 'value',
|
||||
event: 'change'
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
checked(): boolean {
|
||||
return this.value;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggle() {
|
||||
if (this.disabled) return;
|
||||
this.$emit('change', !this.checked);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ziffeoms {
|
||||
position: relative;
|
||||
display: flex;
|
||||
margin: 32px 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
> * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.checked {
|
||||
> .button {
|
||||
background-color: var(--xxubwiul);
|
||||
border-color: var(--xxubwiul);
|
||||
|
||||
> * {
|
||||
background-color: var(--accent);
|
||||
transform: translateX(14px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> .button {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
margin: 3px 0 0 0;
|
||||
width: 34px;
|
||||
height: 14px;
|
||||
background: var(--nhzhphzx);
|
||||
outline: none;
|
||||
border-radius: 14px;
|
||||
transition: inherit;
|
||||
|
||||
> * {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: 0;
|
||||
border-radius: 100%;
|
||||
transition: background-color 0.3s, transform 0.3s;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 1px -1px rgba(#000, 0.2), 0 1px 1px 0 rgba(#000, 0.14), 0 1px 3px 0 rgba(#000, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
> .label {
|
||||
margin-left: 8px;
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: inherit;
|
||||
color: var(--fg);
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
line-height: 20px;
|
||||
transition: inherit;
|
||||
}
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
opacity: 0.7;
|
||||
font-size: 90%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
218
src/client/components/ui/textarea.vue
Normal file
218
src/client/components/ui/textarea.vue
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<template>
|
||||
<div class="adhpbeos" :class="{ focused, filled, tall, pre }">
|
||||
<div class="input">
|
||||
<span class="label" ref="label"><slot></slot></span>
|
||||
<textarea ref="input"
|
||||
:value="value"
|
||||
:required="required"
|
||||
:readonly="readonly"
|
||||
:pattern="pattern"
|
||||
:autocomplete="autocomplete"
|
||||
@input="onInput"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
></textarea>
|
||||
</div>
|
||||
<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button>
|
||||
<div class="desc"><slot name="desc"></slot></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: {
|
||||
required: false
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
pattern: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
autocomplete: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
tall: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
pre: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
save: {
|
||||
type: Function,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
focused: false,
|
||||
changed: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filled(): boolean {
|
||||
return this.value != '' && this.value != null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
focus() {
|
||||
this.$refs.input.focus();
|
||||
},
|
||||
onInput(ev) {
|
||||
this.changed = true;
|
||||
this.$emit('input', ev.target.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.adhpbeos {
|
||||
margin: 42px 0 32px 0;
|
||||
position: relative;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
> .input {
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: none;
|
||||
border: solid 1px var(--inputBorder);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: none;
|
||||
border: solid 2px var(--accent);
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> .label {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 12px;
|
||||
pointer-events: none;
|
||||
transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
transition-duration: 0.3s;
|
||||
font-size: 16px;
|
||||
line-height: 32px;
|
||||
pointer-events: none;
|
||||
//will-change transform
|
||||
transform-origin: top left;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
> textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 130px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
font-weight: normal;
|
||||
font-size: 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
color: var(--fg);
|
||||
}
|
||||
}
|
||||
|
||||
> .save {
|
||||
margin: 6px 0 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
> .desc {
|
||||
margin: 6px 0 0 0;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.focused {
|
||||
> .input {
|
||||
&:after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
> .label {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.focused,
|
||||
&.filled {
|
||||
> .input {
|
||||
> .label {
|
||||
top: -24px;
|
||||
left: 0 !important;
|
||||
transform: scale(0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.tall {
|
||||
> .input {
|
||||
> textarea {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.pre {
|
||||
> .input {
|
||||
> textarea {
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
242
src/client/components/uploader.vue
Normal file
242
src/client/components/uploader.vue
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
<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>
|
||||
<div class="top">
|
||||
<p class="name"><fa icon="spinner" pulse/>{{ ctx.name }}</p>
|
||||
<p class="status">
|
||||
<span class="initing" v-if="ctx.progress == undefined">{{ $t('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>
|
||||
</div>
|
||||
<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 i18n from '../i18n';
|
||||
import { apiUrl } from '../config';
|
||||
//import getMD5 from '../../scripts/get-md5';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
data() {
|
||||
return {
|
||||
uploads: []
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
checkExistence(fileData: ArrayBuffer): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = new FormData();
|
||||
data.append('md5', getMD5(fileData));
|
||||
|
||||
this.$root.api('drive/files/find-by-hash', {
|
||||
md5: getMD5(fileData)
|
||||
}).then(resp => {
|
||||
resolve(resp.length > 0 ? resp[0] : null);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
upload(file: File, folder: any, name?: string) {
|
||||
if (folder && typeof folder == 'object') folder = folder.id;
|
||||
|
||||
const id = Math.random();
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e: any) => {
|
||||
const ctx = {
|
||||
id: id,
|
||||
name: name || file.name || 'untitled',
|
||||
progress: undefined,
|
||||
img: window.URL.createObjectURL(file)
|
||||
};
|
||||
|
||||
this.uploads.push(ctx);
|
||||
this.$emit('change', this.uploads);
|
||||
|
||||
const data = new FormData();
|
||||
data.append('i', this.$store.state.i.token);
|
||||
data.append('force', 'true');
|
||||
data.append('file', file);
|
||||
|
||||
if (folder) data.append('folderId', folder);
|
||||
if (name) data.append('name', name);
|
||||
|
||||
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);
|
||||
}
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-uploader {
|
||||
overflow: auto;
|
||||
}
|
||||
.mk-uploader:empty {
|
||||
display: none;
|
||||
}
|
||||
.mk-uploader > ol {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.mk-uploader > ol > li {
|
||||
display: grid;
|
||||
margin: 8px 0 0 0;
|
||||
padding: 0;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
box-shadow: 0 -1px 0 var(--accentAlpha01);
|
||||
border-top: solid 8px transparent;
|
||||
grid-template-columns: 36px calc(100% - 44px);
|
||||
grid-template-rows: 1fr 8px;
|
||||
column-gap: 8px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.mk-uploader > ol > li:first-child {
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
border-top: none;
|
||||
}
|
||||
.mk-uploader > ol > li > .img {
|
||||
display: block;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
grid-column: 1/2;
|
||||
grid-row: 1/3;
|
||||
}
|
||||
.mk-uploader > ol > li > .top {
|
||||
display: flex;
|
||||
grid-column: 2/3;
|
||||
grid-row: 1/2;
|
||||
}
|
||||
.mk-uploader > ol > li > .top > .name {
|
||||
display: block;
|
||||
padding: 0 8px 0 0;
|
||||
margin: 0;
|
||||
font-size: 0.8em;
|
||||
color: var(--accentAlpha07);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.mk-uploader > ol > li > .top > .name > [data-icon] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.mk-uploader > ol > li > .top > .status {
|
||||
display: block;
|
||||
margin: 0 0 0 auto;
|
||||
padding: 0;
|
||||
font-size: 0.8em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mk-uploader > ol > li > .top > .status > .initing {
|
||||
color: var(--accentAlpha05);
|
||||
}
|
||||
.mk-uploader > ol > li > .top > .status > .kb {
|
||||
color: var(--accentAlpha05);
|
||||
}
|
||||
.mk-uploader > ol > li > .top > .status > .percentage {
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
text-align: right;
|
||||
color: var(--accentAlpha07);
|
||||
}
|
||||
.mk-uploader > ol > li > .top > .status > .percentage:after {
|
||||
content: '%';
|
||||
}
|
||||
.mk-uploader > ol > li > progress {
|
||||
display: block;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
grid-column: 2/3;
|
||||
grid-row: 2/3;
|
||||
z-index: 2;
|
||||
}
|
||||
.mk-uploader > ol > li > progress::-webkit-progress-value {
|
||||
background: var(--accent);
|
||||
}
|
||||
.mk-uploader > ol > li > progress::-webkit-progress-bar {
|
||||
background: var(--accentAlpha01);
|
||||
}
|
||||
.mk-uploader > ol > li > .progress {
|
||||
display: block;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(45deg, var(--accentLighten30) 25%, var(--accent) 25%, var(--accent) 50%, var(--accentLighten30) 50%, var(--accentLighten30) 75%, var(--accent) 75%, var(--accent));
|
||||
background-size: 32px 32px;
|
||||
animation: bg 1.5s linear infinite;
|
||||
grid-column: 2/3;
|
||||
grid-row: 2/3;
|
||||
z-index: 1;
|
||||
}
|
||||
.mk-uploader > ol > li > .progress.initing {
|
||||
opacity: 0.3;
|
||||
}
|
||||
@-moz-keyframes bg {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: -64px 32px;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes bg {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: -64px 32px;
|
||||
}
|
||||
}
|
||||
@-o-keyframes bg {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: -64px 32px;
|
||||
}
|
||||
}
|
||||
@keyframes bg {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: -64px 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
331
src/client/components/url-preview.vue
Normal file
331
src/client/components/url-preview.vue
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
<template>
|
||||
<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
|
||||
<button class="disablePlayer" @click="playerEnabled = false" :title="$t('disable-player')"><fa icon="times"/></button>
|
||||
<iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen />
|
||||
</div>
|
||||
<div v-else-if="tweetUrl && detail" class="twitter">
|
||||
<blockquote ref="tweet" class="twitter-tweet" :data-theme="$store.state.device.darkmode ? 'dark' : null">
|
||||
<a :href="url"></a>
|
||||
</blockquote>
|
||||
</div>
|
||||
<div v-else class="mk-url-preview" v-size="[{ max: 400 }, { max: 350 }]">
|
||||
<transition name="zoom" mode="out-in">
|
||||
<component :is="hasRoute ? 'router-link' : 'a'" :class="{ compact }" :[attr]="hasRoute ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url" v-if="!fetching">
|
||||
<div class="thumbnail" v-if="thumbnail" :style="`background-image: url('${thumbnail}')`">
|
||||
<button class="_button" v-if="!playerEnabled && player.url" @click.prevent="playerEnabled = true" :title="$t('enable-player')"><fa :icon="faPlayCircle"/></button>
|
||||
</div>
|
||||
<article>
|
||||
<header>
|
||||
<h1 :title="title">{{ title }}</h1>
|
||||
</header>
|
||||
<p v-if="description" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p>
|
||||
<footer>
|
||||
<img class="icon" v-if="icon" :src="icon"/>
|
||||
<p :title="sitename">{{ sitename }}</p>
|
||||
</footer>
|
||||
</article>
|
||||
</component>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faPlayCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import { url as local, lang } from '../config';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
require: true
|
||||
},
|
||||
|
||||
detail: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
|
||||
compact: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
const isSelf = this.url.startsWith(local);
|
||||
const hasRoute =
|
||||
(this.url.substr(local.length) === '/') ||
|
||||
this.url.substr(local.length).startsWith('/@') ||
|
||||
this.url.substr(local.length).startsWith('/notes/') ||
|
||||
this.url.substr(local.length).startsWith('/tags/');
|
||||
return {
|
||||
local,
|
||||
fetching: true,
|
||||
title: null,
|
||||
description: null,
|
||||
thumbnail: null,
|
||||
icon: null,
|
||||
sitename: null,
|
||||
player: {
|
||||
url: null,
|
||||
width: null,
|
||||
height: null
|
||||
},
|
||||
tweetUrl: null,
|
||||
playerEnabled: false,
|
||||
self: isSelf,
|
||||
hasRoute: hasRoute,
|
||||
attr: hasRoute ? 'to' : 'href',
|
||||
target: hasRoute ? null : '_blank',
|
||||
faPlayCircle
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
const requestUrl = new URL(this.url);
|
||||
|
||||
if (this.detail && requestUrl.hostname == 'twitter.com' && /^\/.+\/status(es)?\/\d+/.test(requestUrl.pathname)) {
|
||||
this.tweetUrl = requestUrl;
|
||||
const twttr = (window as any).twttr || {};
|
||||
const loadTweet = () => twttr.widgets.load(this.$refs.tweet);
|
||||
|
||||
if (twttr.widgets) {
|
||||
Vue.nextTick(loadTweet);
|
||||
} else {
|
||||
const wjsId = 'twitter-wjs';
|
||||
if (!document.getElementById(wjsId)) {
|
||||
const head = document.getElementsByTagName('head')[0];
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('id', wjsId);
|
||||
script.setAttribute('src', 'https://platform.twitter.com/widgets.js');
|
||||
head.appendChild(script);
|
||||
}
|
||||
twttr.ready = loadTweet;
|
||||
(window as any).twttr = twttr;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.hostname === 'music.youtube.com') {
|
||||
requestUrl.hostname = 'youtube.com';
|
||||
}
|
||||
|
||||
const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP');
|
||||
|
||||
requestUrl.hash = '';
|
||||
|
||||
fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
|
||||
res.json().then(info => {
|
||||
if (info.url == null) return;
|
||||
this.title = info.title;
|
||||
this.description = info.description;
|
||||
this.thumbnail = info.thumbnail;
|
||||
this.icon = info.icon;
|
||||
this.sitename = info.sitename;
|
||||
this.fetching = false;
|
||||
this.player = info.player;
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
> button {
|
||||
position: absolute;
|
||||
top: -1.5em;
|
||||
right: 0;
|
||||
font-size: 1em;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--fg);
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
> iframe {
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.mk-url-preview {
|
||||
&.max-width_400px {
|
||||
> a {
|
||||
font-size: 12px;
|
||||
|
||||
> .thumbnail {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_350px {
|
||||
> a {
|
||||
font-size: 10px;
|
||||
|
||||
> .thumbnail {
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 8px;
|
||||
|
||||
> header {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
> footer {
|
||||
margin-top: 4px;
|
||||
|
||||
> img {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.compact {
|
||||
> .thumbnail {
|
||||
position: absolute;
|
||||
width: 56px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
> article {
|
||||
left: 56px;
|
||||
width: calc(100% - 56px);
|
||||
padding: 4px;
|
||||
|
||||
> header {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
> footer {
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> a {
|
||||
position: relative;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 1px 4px var(--tyvedwbe);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
border-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
> article > header > h1 {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
height: 100%;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
> button {
|
||||
font-size: 3.5em;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
font-size: 4em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
& + article {
|
||||
left: 100px;
|
||||
width: calc(100% - 100px);
|
||||
}
|
||||
}
|
||||
|
||||
> article {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
|
||||
> header {
|
||||
margin-bottom: 8px;
|
||||
|
||||
> h1 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
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: var(--urlPreviewInfo);
|
||||
font-size: 0.8em;
|
||||
line-height: 16px;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.compact {
|
||||
> article {
|
||||
> header h1, p, footer {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
95
src/client/components/url.vue
Normal file
95
src/client/components/url.vue
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<component :is="hasRoute ? 'router-link' : 'a'" class="mk-url" :[attr]="hasRoute ? url.substr(local.length) : url" :rel="rel" :target="target">
|
||||
<template v-if="!self">
|
||||
<span class="schema">{{ schema }}//</span>
|
||||
<span class="hostname">{{ hostname }}</span>
|
||||
<span class="port" v-if="port != ''">:{{ port }}</span>
|
||||
</template>
|
||||
<template v-if="pathname === '/' && self">
|
||||
<span class="self">{{ hostname }}</span>
|
||||
</template>
|
||||
<span class="pathname" v-if="pathname != ''">{{ self ? pathname.substr(1) : pathname }}</span>
|
||||
<span class="query">{{ query }}</span>
|
||||
<span class="hash">{{ hash }}</span>
|
||||
<fa :icon="faExternalLinkSquareAlt" v-if="target === '_blank'" class="icon"/>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { toUnicode as decodePunycode } from 'punycode';
|
||||
import { url as local } from '../config';
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['url', 'rel'],
|
||||
data() {
|
||||
const isSelf = this.url.startsWith(local);
|
||||
const hasRoute = isSelf && (
|
||||
(this.url.substr(local.length) === '/') ||
|
||||
this.url.substr(local.length).startsWith('/@') ||
|
||||
this.url.substr(local.length).startsWith('/notes/') ||
|
||||
this.url.substr(local.length).startsWith('/tags/'));
|
||||
return {
|
||||
local,
|
||||
schema: null,
|
||||
hostname: null,
|
||||
port: null,
|
||||
pathname: null,
|
||||
query: null,
|
||||
hash: null,
|
||||
self: isSelf,
|
||||
hasRoute: hasRoute,
|
||||
attr: hasRoute ? 'to' : 'href',
|
||||
target: hasRoute ? null : '_blank',
|
||||
faExternalLinkSquareAlt
|
||||
};
|
||||
},
|
||||
created() {
|
||||
const url = new URL(this.url);
|
||||
this.schema = url.protocol;
|
||||
this.hostname = decodePunycode(url.hostname);
|
||||
this.port = url.port;
|
||||
this.pathname = decodeURIComponent(url.pathname);
|
||||
this.query = decodeURIComponent(url.search);
|
||||
this.hash = decodeURIComponent(url.hash);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-url {
|
||||
word-break: break-all;
|
||||
|
||||
> .icon {
|
||||
padding-left: 2px;
|
||||
font-size: .9em;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
> .self {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> .schema {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
> .hostname {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> .pathname {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
> .query {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
> .hash {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
148
src/client/components/user-list.vue
Normal file
148
src/client/components/user-list.vue
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<template>
|
||||
<mk-container :body-togglable="true" :expanded="expanded">
|
||||
<template #header><slot></slot></template>
|
||||
|
||||
<mk-error v-if="error" @retry="init()"/>
|
||||
|
||||
<div class="efvhhmdq">
|
||||
<div class="no-users" v-if="empty">
|
||||
<p>{{ $t('no-users') }}</p>
|
||||
</div>
|
||||
<div class="user" v-for="user in users" :key="user.id">
|
||||
<mk-avatar class="avatar" :user="user"/>
|
||||
<div class="body">
|
||||
<div class="name">
|
||||
<router-link class="name" :to="user | userPage" v-user-preview="user.id"><mk-user-name :user="user"/></router-link>
|
||||
<span class="username"><mk-acct :user="user"/></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
|
||||
<span v-else class="empty">{{ $t('noAccountDescription') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<x-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/>
|
||||
</div>
|
||||
<button class="more" :class="{ fetching: moreFetching }" v-if="more" @click="fetchMore()" :disabled="moreFetching">
|
||||
<template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>{{ moreFetching ? $t('@.loading') : $t('@.load-more') }}
|
||||
</button>
|
||||
</div>
|
||||
</mk-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import paging from '../scripts/paging';
|
||||
import MkContainer from './ui/container.vue';
|
||||
import XFollowButton from './follow-button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkContainer,
|
||||
XFollowButton,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
paging({}),
|
||||
],
|
||||
|
||||
props: {
|
||||
pagination: {
|
||||
required: true
|
||||
},
|
||||
extract: {
|
||||
required: false
|
||||
},
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
users() {
|
||||
return this.extract ? this.extract(this.items) : this.items;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.efvhhmdq {
|
||||
> .no-users {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
> .user {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
border-bottom: solid 1px var(--divider);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
margin: 0 12px 0 0;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
flex: 1;
|
||||
|
||||
> .name {
|
||||
font-weight: bold;
|
||||
|
||||
> .name {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
> .username {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
> .description {
|
||||
font-size: 90%;
|
||||
|
||||
> .empty {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .koudoku-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .more {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(#000, 0.025);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(#000, 0.05);
|
||||
}
|
||||
|
||||
&.fetching {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
188
src/client/components/user-menu.vue
Normal file
188
src/client/components/user-menu.vue
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<template>
|
||||
<x-menu :source="source" :items="items" @closed="$emit('closed')"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import XMenu from './menu.vue';
|
||||
import copyToClipboard from '../scripts/copy-to-clipboard';
|
||||
import { host } from '../config';
|
||||
import getAcct from '../../misc/acct/render';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XMenu
|
||||
},
|
||||
|
||||
props: ['user', 'source'],
|
||||
|
||||
data() {
|
||||
let menu = [{
|
||||
icon: faAt,
|
||||
text: this.$t('copyUsername'),
|
||||
action: () => {
|
||||
copyToClipboard(`@${this.user.username}@${this.user.host || host}`);
|
||||
}
|
||||
}, {
|
||||
icon: faEnvelope,
|
||||
text: this.$t('sendMessage'),
|
||||
action: () => {
|
||||
this.$root.post({ specified: this.user });
|
||||
}
|
||||
}, this.$store.state.i.id != this.user.id ? {
|
||||
type: 'link',
|
||||
to: `/my/messaging/${getAcct(this.user)}`,
|
||||
icon: faComments,
|
||||
text: this.$t('startMessaging'),
|
||||
} : undefined, null, {
|
||||
icon: faListUl,
|
||||
text: this.$t('addToList'),
|
||||
action: this.pushList
|
||||
}] as any;
|
||||
|
||||
if (this.$store.getters.isSignedIn && this.$store.state.i.id != this.user.id) {
|
||||
menu = menu.concat([null, {
|
||||
icon: this.user.isMuted ? faEye : faEyeSlash,
|
||||
text: this.user.isMuted ? this.$t('unmute') : this.$t('mute'),
|
||||
action: this.toggleMute
|
||||
}, {
|
||||
icon: faBan,
|
||||
text: this.user.isBlocking ? this.$t('unblock') : this.$t('block'),
|
||||
action: this.toggleBlock
|
||||
}]);
|
||||
|
||||
if (this.$store.state.i.isAdmin) {
|
||||
menu = menu.concat([null, {
|
||||
icon: faSnowflake,
|
||||
text: this.user.isSuspended ? this.$t('unsuspend') : this.$t('suspend'),
|
||||
action: this.toggleSuspend
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.$store.getters.isSignedIn && this.$store.state.i.id === this.user.id) {
|
||||
menu = menu.concat([null, {
|
||||
icon: faPencilAlt,
|
||||
text: this.$t('editProfile'),
|
||||
action: () => {
|
||||
this.$router.push('/my/settings');
|
||||
}
|
||||
}]);
|
||||
}
|
||||
|
||||
return {
|
||||
items: menu
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
async pushList() {
|
||||
const t = this.$t('selectList'); // なぜか後で参照すると null になるので最初にメモリに確保しておく
|
||||
const lists = await this.$root.api('users/lists/list');
|
||||
if (lists.length === 0) {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('youHaveNoLists')
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { canceled, result: listId } = await this.$root.dialog({
|
||||
type: null,
|
||||
title: t,
|
||||
select: {
|
||||
items: lists.map(list => ({
|
||||
value: list.id, text: list.name
|
||||
}))
|
||||
},
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
this.$root.api('users/lists/push', {
|
||||
listId: listId,
|
||||
userId: this.user.id
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
}).catch(e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async toggleMute() {
|
||||
this.$root.api(this.user.isMuted ? 'mute/delete' : 'mute/create', {
|
||||
userId: this.user.id
|
||||
}).then(() => {
|
||||
this.user.isMuted = !this.user.isMuted;
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
}, e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async toggleBlock() {
|
||||
if (!await this.getConfirmed(this.user.isBlocking ? this.$t('unblockConfirm') : this.$t('blockConfirm'))) return;
|
||||
|
||||
this.$root.api(this.user.isBlocking ? 'blocking/delete' : 'blocking/create', {
|
||||
userId: this.user.id
|
||||
}).then(() => {
|
||||
this.user.isBlocking = !this.user.isBlocking;
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
}, e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async toggleSuspend() {
|
||||
if (!await this.getConfirmed(this.$t(this.user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return;
|
||||
|
||||
this.$root.api(this.user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', {
|
||||
userId: this.user.id
|
||||
}).then(() => {
|
||||
this.user.isSuspended = !this.user.isSuspended;
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
}, e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async getConfirmed(text: string): Promise<Boolean> {
|
||||
const confirm = await this.$root.dialog({
|
||||
type: 'warning',
|
||||
showCancelButton: true,
|
||||
title: 'confirm',
|
||||
text,
|
||||
});
|
||||
|
||||
return !confirm.canceled;
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
108
src/client/components/user-moderate-dialog.vue
Normal file
108
src/client/components/user-moderate-dialog.vue
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<template>
|
||||
<x-window @closed="() => { $emit('closed'); destroyDom(); }" :avatar="user">
|
||||
<template #header><mk-user-name :user="user"/></template>
|
||||
<div class="vrcsvlkm">
|
||||
<mk-button @click="changePassword()">{{ $t('changePassword') }}</mk-button>
|
||||
<mk-switch @change="toggleSilence()" v-model="silenced">{{ $t('silence') }}</mk-switch>
|
||||
<mk-switch @change="toggleSuspend()" v-model="suspended">{{ $t('suspend') }}</mk-switch>
|
||||
</div>
|
||||
</x-window>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import MkButton from './ui/button.vue';
|
||||
import MkSwitch from './ui/switch.vue';
|
||||
import XWindow from './window.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
MkSwitch,
|
||||
XWindow,
|
||||
},
|
||||
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
silenced: this.user.isSilenced,
|
||||
suspended: this.user.isSuspended,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
async changePassword() {
|
||||
const { canceled: canceled, result: newPassword } = await this.$root.dialog({
|
||||
title: this.$t('newPassword'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
const dialog = this.$root.dialog({
|
||||
type: 'waiting',
|
||||
iconOnly: true
|
||||
});
|
||||
|
||||
this.$root.api('admin/change-password', {
|
||||
userId: this.user.id,
|
||||
newPassword
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
}).catch(e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
}).finally(() => {
|
||||
dialog.close();
|
||||
});
|
||||
},
|
||||
|
||||
async toggleSilence() {
|
||||
const confirm = await this.$root.dialog({
|
||||
type: 'warning',
|
||||
showCancelButton: true,
|
||||
text: this.silenced ? this.$t('silenceConfirm') : this.$t('unsilenceConfirm'),
|
||||
});
|
||||
if (confirm.canceled) {
|
||||
this.silenced = !this.silenced;
|
||||
} else {
|
||||
this.$root.api(this.silenced ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id });
|
||||
}
|
||||
},
|
||||
|
||||
async toggleSuspend() {
|
||||
const confirm = await this.$root.dialog({
|
||||
type: 'warning',
|
||||
showCancelButton: true,
|
||||
text: this.suspended ? this.$t('suspendConfirm') : this.$t('unsuspendConfirm'),
|
||||
});
|
||||
if (confirm.canceled) {
|
||||
this.suspended = !this.suspended;
|
||||
} else {
|
||||
this.$root.api(this.silenced ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vrcsvlkm {
|
||||
|
||||
}
|
||||
</style>
|
||||
20
src/client/components/user-name.vue
Normal file
20
src/client/components/user-name.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<template>
|
||||
<mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
nowrap: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
181
src/client/components/user-preview.vue
Normal file
181
src/client/components/user-preview.vue
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<template>
|
||||
<transition name="popup" appear @after-leave="() => { $emit('closed'); destroyDom(); }">
|
||||
<div v-if="show" class="fxxzrfni _panel" ref="content" :style="{ top: top + 'px', left: left + 'px' }" @mouseover="() => { $emit('mouseover'); }" @mouseleave="() => { $emit('mouseleave'); }">
|
||||
<div class="banner" :style="u.bannerUrl ? `background-image: url(${u.bannerUrl})` : ''"></div>
|
||||
<mk-avatar class="avatar" :user="u" :disable-preview="true"/>
|
||||
<div class="title">
|
||||
<router-link class="name" :to="u | userPage"><mk-user-name :user="u" :nowrap="false"/></router-link>
|
||||
<p class="username"><mk-acct :user="u"/></p>
|
||||
</div>
|
||||
<div class="description">
|
||||
<mfm v-if="u.description" :text="u.description" :author="u" :i="$store.state.i" :custom-emojis="u.emojis"/>
|
||||
</div>
|
||||
<div class="status">
|
||||
<div>
|
||||
<p>{{ $t('notes') }}</p><span>{{ u.notesCount }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>{{ $t('following') }}</p><span>{{ u.followingCount }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>{{ $t('followers') }}</p><span>{{ u.followersCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<x-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && u.id != $store.state.i.id" :user="u" mini/>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import parseAcct from '../../misc/acct/parse';
|
||||
import XFollowButton from './follow-button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XFollowButton
|
||||
},
|
||||
|
||||
props: {
|
||||
user: {
|
||||
type: [Object, String],
|
||||
required: true
|
||||
},
|
||||
source: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
u: null,
|
||||
show: false,
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (typeof this.user == 'object') {
|
||||
this.u = this.user;
|
||||
this.show = true;
|
||||
} else {
|
||||
const query = this.user.startsWith('@') ?
|
||||
parseAcct(this.user.substr(1)) :
|
||||
{ userId: this.user };
|
||||
|
||||
this.$root.api('users/show', query).then(user => {
|
||||
this.u = user;
|
||||
this.show = true;
|
||||
});
|
||||
}
|
||||
|
||||
const rect = this.source.getBoundingClientRect();
|
||||
const x = ((rect.left + (this.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset;
|
||||
const y = rect.top + this.source.offsetHeight + window.pageYOffset;
|
||||
|
||||
this.top = y;
|
||||
this.left = x;
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.show = false;
|
||||
(this.$refs.content as any).style.pointerEvents = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup-enter-active, .popup-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s !important;
|
||||
}
|
||||
.popup-enter, .popup-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.fxxzrfni {
|
||||
position: absolute;
|
||||
z-index: 11000;
|
||||
width: 300px;
|
||||
overflow: hidden;
|
||||
|
||||
> .banner {
|
||||
height: 84px;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 62px;
|
||||
left: 13px;
|
||||
z-index: 2;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border: solid 3px var(--face);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
> .title {
|
||||
display: block;
|
||||
padding: 8px 0 8px 82px;
|
||||
|
||||
> .name {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
line-height: 16px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
> .username {
|
||||
display: block;
|
||||
margin: 0;
|
||||
line-height: 16px;
|
||||
font-size: 0.8em;
|
||||
color: var(--text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
> .description {
|
||||
padding: 0 16px;
|
||||
font-size: 0.8em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
> .status {
|
||||
padding: 8px 16px;
|
||||
|
||||
> div {
|
||||
display: inline-block;
|
||||
width: 33%;
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
font-size: 0.7em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
> span {
|
||||
font-size: 1em;
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .koudoku-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
152
src/client/components/user-select.vue
Normal file
152
src/client/components/user-select.vue
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<template>
|
||||
<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="selected == null" @ok="ok()">
|
||||
<template #header>{{ $t('selectUser') }}</template>
|
||||
<div class="tbhwbxda">
|
||||
<div class="inputs">
|
||||
<mk-input v-model="username" class="input" @input="search" ref="username"><span>{{ $t('username') }}</span><template #prefix>@</template></mk-input>
|
||||
<mk-input v-model="host" class="input" @input="search"><span>{{ $t('host') }}</span><template #prefix>@</template></mk-input>
|
||||
</div>
|
||||
<div class="users">
|
||||
<div class="user" v-for="user in users" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()">
|
||||
<mk-avatar :user="user" class="avatar" :disable-link="true"/>
|
||||
<div class="body">
|
||||
<mk-user-name :user="user" class="name"/>
|
||||
<mk-acct :user="user" class="acct"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-window>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import { faTimes, faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkInput from './ui/input.vue';
|
||||
import XWindow from './window.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkInput,
|
||||
XWindow,
|
||||
},
|
||||
|
||||
props: {
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
username: '',
|
||||
host: '',
|
||||
users: [],
|
||||
selected: null,
|
||||
faTimes, faCheck
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.focus();
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.focus();
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
search() {
|
||||
if (this.username == '' && this.host == '') {
|
||||
this.users = [];
|
||||
return;
|
||||
}
|
||||
this.$root.api('users/search-by-username-and-host', {
|
||||
username: this.username,
|
||||
host: this.host,
|
||||
limit: 10,
|
||||
detail: false
|
||||
}).then(users => {
|
||||
this.users = users;
|
||||
});
|
||||
},
|
||||
|
||||
focus() {
|
||||
this.$refs.username.focus();
|
||||
},
|
||||
|
||||
close() {
|
||||
this.$refs.window.close();
|
||||
},
|
||||
|
||||
ok() {
|
||||
this.$emit('selected', this.selected);
|
||||
this.close();
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tbhwbxda {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
|
||||
> .inputs {
|
||||
margin-top: 16px;
|
||||
|
||||
> .input {
|
||||
display: inline-block;
|
||||
width: 50%;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .users {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
|
||||
> .user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bwqtlupy);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
padding: 0 8px;
|
||||
min-width: 0;
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> .acct {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
161
src/client/components/users-dialog.vue
Normal file
161
src/client/components/users-dialog.vue
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<template>
|
||||
<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }">
|
||||
<div class="mk-users-dialog">
|
||||
<div class="header">
|
||||
<span>{{ title }}</span>
|
||||
<button class="_button" @click="close()"><fa :icon="faTimes"/></button>
|
||||
</div>
|
||||
|
||||
<sequential-entrance class="users">
|
||||
<router-link v-for="(item, i) in items" class="user" :key="item.id" :data-index="i" :to="extract ? extract(item) : item | userPage">
|
||||
<mk-avatar :user="extract ? extract(item) : item" class="avatar" :disable-link="true"/>
|
||||
<div class="body">
|
||||
<mk-user-name :user="extract ? extract(item) : item" class="name"/>
|
||||
<mk-acct :user="extract ? extract(item) : item" class="acct"/>
|
||||
</div>
|
||||
</router-link>
|
||||
</sequential-entrance>
|
||||
|
||||
<button class="more _button" v-if="more" @click="fetchMore" :disabled="moreFetching">
|
||||
<template v-if="!moreFetching">{{ $t('loadMore') }}</template>
|
||||
<template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template>
|
||||
</button>
|
||||
|
||||
<p class="empty" v-if="empty">{{ $t('noUsers') }}</p>
|
||||
|
||||
<mk-error v-if="error" @retry="init()"/>
|
||||
</div>
|
||||
</x-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import paging from '../scripts/paging';
|
||||
import XModal from './modal.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XModal,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
paging({}),
|
||||
],
|
||||
|
||||
props: {
|
||||
title: {
|
||||
required: true
|
||||
},
|
||||
pagination: {
|
||||
required: true
|
||||
},
|
||||
extract: {
|
||||
required: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faTimes
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.$refs.modal.close();
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-users-dialog {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
|
||||
> button {
|
||||
height: 58px;
|
||||
width: 58px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
height: 42px;
|
||||
width: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
> span {
|
||||
flex: 1;
|
||||
line-height: 58px;
|
||||
padding-left: 32px;
|
||||
font-weight: bold;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
line-height: 42px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .users {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
padding: 8px 32px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> .acct {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .empty {
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue