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
106
src/client/pages/about.vue
Normal file
106
src/client/pages/about.vue
Normal file
|
@ -0,0 +1,106 @@
|
|||
<template>
|
||||
<div class="mmnnbwxb">
|
||||
<portal to="icon"><fa :icon="faInfoCircle"/></portal>
|
||||
<portal to="title">{{ $t('about') }}</portal>
|
||||
|
||||
<section class="_section info" v-if="meta">
|
||||
<div class="_title"><fa :icon="faInfoCircle"/> {{ $t('instanceInfo') }}</div>
|
||||
<div class="_content" v-if="meta.description">
|
||||
<div>{{ meta.description }}</div>
|
||||
</div>
|
||||
<div class="_content table">
|
||||
<div><b>{{ $t('administrator') }}</b><span>{{ meta.maintainerName }}</span></div>
|
||||
<div><b></b><span>{{ meta.maintainerEmail }}</span></div>
|
||||
</div>
|
||||
<div class="_content table" v-if="stats">
|
||||
<div><b>{{ $t('users') }}</b><span>{{ stats.originalUsersCount | number }}</span></div>
|
||||
<div><b>{{ $t('notes') }}</b><span>{{ stats.originalNotesCount | number }}</span></div>
|
||||
</div>
|
||||
<div class="_content table">
|
||||
<div><b>Misskey</b><span>v{{ version }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_section aboutMisskey">
|
||||
<div class="_title"><fa :icon="faInfoCircle"/> {{ $t('aboutMisskey') }}</div>
|
||||
<div class="_content">
|
||||
<div style="margin-bottom: 1em;">{{ $t('aboutMisskeyText') }}</div>
|
||||
<div>{{ $t('misskeyMembers') }}</div>
|
||||
<span class="members">
|
||||
<a href="https://github.com/syuilo" target="_blank">@syuilo</a>
|
||||
<a href="https://github.com/AyaMorisawa" target="_blank">@AyaMorisawa</a>
|
||||
<a href="https://github.com/mei23" target="_blank">@mei23</a>
|
||||
<a href="https://github.com/acid-chicken" target="_blank">@acid-chicken</a>
|
||||
<a href="https://github.com/tamaina" target="_blank">@tamaina</a>
|
||||
<a href="https://github.com/rinsuki" target="_blank">@rinsuki</a>
|
||||
</span>
|
||||
<div style="margin-top: 1em;">{{ $t('misskeySource') }}</div>
|
||||
<a href="https://github.com/syuilo/misskey" target="_blank" style="color: var(--link);">https://github.com/syuilo/misskey</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { version } from '../config';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('instance') as string
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
version,
|
||||
meta: null,
|
||||
stats: null,
|
||||
serverInfo: null,
|
||||
faInfoCircle
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$root.getMeta().then(meta => {
|
||||
this.meta = meta;
|
||||
});
|
||||
|
||||
this.$root.api('stats').then(res => {
|
||||
this.stats = res;
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mmnnbwxb {
|
||||
> .info {
|
||||
> .table {
|
||||
> div {
|
||||
display: flex;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .aboutMisskey {
|
||||
> ._content {
|
||||
> .members {
|
||||
> a {
|
||||
color: var(--link);
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
73
src/client/pages/announcements.vue
Normal file
73
src/client/pages/announcements.vue
Normal file
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<div>
|
||||
<portal to="icon"><fa :icon="faBroadcastTower"/></portal>
|
||||
<portal to="title">{{ $t('announcements') }}</portal>
|
||||
|
||||
<mk-pagination :pagination="pagination" #default="{items}" class="ruryvtyk" ref="list">
|
||||
<section class="_section announcement" v-for="(announcement, i) in items" :key="announcement.id" :data-index="i">
|
||||
<div class="_title"><span v-if="$store.getters.isSignedIn && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
|
||||
<div class="_content">
|
||||
<mfm :text="announcement.text"/>
|
||||
<img v-if="announcement.imageUrl" :src="announcement.imageUrl" alt=""/>
|
||||
</div>
|
||||
<div class="_footer" v-if="$store.getters.isSignedIn && !announcement.isRead">
|
||||
<mk-button @click="read(announcement)" primary><fa :icon="faCheck"/> {{ $t('gotIt') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faCheck, faBroadcastTower } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import MkPagination from '../components/ui/pagination.vue';
|
||||
import MkButton from '../components/ui/button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('announcements') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkPagination,
|
||||
MkButton
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination: {
|
||||
endpoint: 'announcements',
|
||||
limit: 10,
|
||||
},
|
||||
faCheck, faBroadcastTower
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
read(announcement) {
|
||||
announcement.isRead = true;
|
||||
this.$root.api('i/read-announcement', { announcementId: announcement.id });
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ruryvtyk {
|
||||
> .announcement {
|
||||
> ._content {
|
||||
> img {
|
||||
display: block;
|
||||
max-height: 300px;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
63
src/client/pages/auth.form.vue
Normal file
63
src/client/pages/auth.form.vue
Normal file
|
@ -0,0 +1,63 @@
|
|||
<template>
|
||||
<section class="_section">
|
||||
<div class="_title">{{ $t('_auth.shareAccess', { name: app.name }) }}</div>
|
||||
<div class="_content">
|
||||
<h2>{{ app.name }}</h2>
|
||||
<p class="id">{{ app.id }}</p>
|
||||
<p class="description">{{ app.description }}</p>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<h2>{{ $t('_auth.permissionAsk') }}</h2>
|
||||
<ul>
|
||||
<template v-for="p in app.permission">
|
||||
<li :key="p">{{ $t(`_permissions.${p}`) }}</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button @click="cancel" inline>{{ $t('cancel') }}</mk-button>
|
||||
<mk-button @click="accept" inline primary>{{ $t('accept') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import MkButton from '../components/ui/button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
components: {
|
||||
MkButton
|
||||
},
|
||||
props: ['session'],
|
||||
computed: {
|
||||
name(): string {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = this.app.name
|
||||
return el.innerHTML;
|
||||
},
|
||||
app(): any {
|
||||
return this.session.app;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancel() {
|
||||
this.$root.api('auth/deny', {
|
||||
token: this.session.token
|
||||
}).then(() => {
|
||||
this.$emit('denied');
|
||||
});
|
||||
},
|
||||
|
||||
accept() {
|
||||
this.$root.api('auth/accept', {
|
||||
token: this.session.token
|
||||
}).then(() => {
|
||||
this.$emit('accepted');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
93
src/client/pages/auth.vue
Normal file
93
src/client/pages/auth.vue
Normal file
|
@ -0,0 +1,93 @@
|
|||
<template>
|
||||
<div class="_panel" v-if="$store.getters.isSignedIn && fetching">
|
||||
<mk-loading/>
|
||||
</div>
|
||||
<div v-else-if="$store.getters.isSignedIn">
|
||||
<x-form
|
||||
class="form"
|
||||
ref="form"
|
||||
v-if="state == 'waiting'"
|
||||
:session="session"
|
||||
@denied="state = 'denied'"
|
||||
@accepted="accepted"
|
||||
/>
|
||||
<div class="denied _panel" v-if="state == 'denied'">
|
||||
<h1>{{ $t('denied') }}</h1>
|
||||
<p>{{ $t('denied-paragraph') }}</p>
|
||||
</div>
|
||||
<div class="accepted _panel" v-if="state == 'accepted'">
|
||||
<h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$t('allowed') }}</h1>
|
||||
<p v-if="session.app.callbackUrl">{{ $t('callback-url') }}<mk-ellipsis/></p>
|
||||
<p v-if="!session.app.callbackUrl">{{ $t('please-go-back') }}</p>
|
||||
</div>
|
||||
<div class="error _panel" v-if="state == 'fetch-session-error'">
|
||||
<p>{{ $t('error') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signin" v-else>
|
||||
<h1>{{ $t('sign-in') }}</h1>
|
||||
<mk-signin/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import XForm from './auth.form.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
components: {
|
||||
XForm
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
state: null,
|
||||
session: null,
|
||||
fetching: true
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
token(): string {
|
||||
return this.$route.params.token;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (!this.$store.getters.isSignedIn) return;
|
||||
|
||||
// Fetch session
|
||||
this.$root.api('auth/session/show', {
|
||||
token: this.token
|
||||
}).then(session => {
|
||||
this.session = session;
|
||||
this.fetching = false;
|
||||
|
||||
// 既に連携していた場合
|
||||
if (this.session.app.isAuthorized) {
|
||||
this.$root.api('auth/accept', {
|
||||
token: this.session.token
|
||||
}).then(() => {
|
||||
this.accepted();
|
||||
});
|
||||
} else {
|
||||
this.state = 'waiting';
|
||||
}
|
||||
}).catch(error => {
|
||||
this.state = 'fetch-session-error';
|
||||
this.fetching = false;
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
accepted() {
|
||||
this.state = 'accepted';
|
||||
if (this.session.app.callbackUrl) {
|
||||
location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
87
src/client/pages/drive.vue
Normal file
87
src/client/pages/drive.vue
Normal file
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<div>
|
||||
<portal to="header">
|
||||
<button @click="menu" class="_button _jmoebdiw_">
|
||||
<fa :icon="faCloud" style="margin-right: 8px;"/>
|
||||
<span v-if="folder">{{ $t('drive') }} ({{ folder.name }})</span>
|
||||
<span v-else>{{ $t('drive') }}</span>
|
||||
<fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
|
||||
</button>
|
||||
</portal>
|
||||
<x-drive ref="drive" @cd="x => folder = x"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faCloud, faAngleDown, faAngleUp, faFolderPlus, faUpload, faLink, faICursor, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import XDrive from '../components/drive.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('drive') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
XDrive
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
menuOpened: false,
|
||||
folder: null,
|
||||
faCloud, faAngleDown, faAngleUp
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
menu(ev) {
|
||||
this.menuOpened = true;
|
||||
this.$root.menu({
|
||||
items: [{
|
||||
text: this.$t('addFile'),
|
||||
type: 'label'
|
||||
}, {
|
||||
text: this.$t('upload'),
|
||||
icon: faUpload,
|
||||
action: () => { this.$refs.drive.selectLocalFile(); }
|
||||
}, {
|
||||
text: this.$t('fromUrl'),
|
||||
icon: faLink,
|
||||
action: () => { this.$refs.drive.urlUpload(); }
|
||||
}, null, {
|
||||
text: this.folder ? this.folder.name : this.$t('drive'),
|
||||
type: 'label'
|
||||
}, this.folder ? {
|
||||
text: this.$t('renameFolder'),
|
||||
icon: faICursor,
|
||||
action: () => { this.$refs.drive.renameFolder(); }
|
||||
} : undefined, this.folder ? {
|
||||
text: this.$t('deleteFolder'),
|
||||
icon: faTrashAlt,
|
||||
action: () => { this.$refs.drive.deleteFolder(); }
|
||||
} : undefined, {
|
||||
text: this.$t('createFolder'),
|
||||
icon: faFolderPlus,
|
||||
action: () => { this.$refs.drive.createFolder(); }
|
||||
}],
|
||||
fixed: true,
|
||||
noCenter: true,
|
||||
source: ev.currentTarget || ev.target
|
||||
}).then(() => {
|
||||
this.menuOpened = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
._jmoebdiw_ {
|
||||
height: 100%;
|
||||
padding: 0 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
212
src/client/pages/explore.vue
Normal file
212
src/client/pages/explore.vue
Normal file
|
@ -0,0 +1,212 @@
|
|||
<template>
|
||||
<div>
|
||||
<portal to="icon"><fa :icon="faHashtag"/></portal>
|
||||
<portal to="title">{{ $t('explore') }}</portal>
|
||||
|
||||
<div class="localfedi7 _panel" v-if="meta && stats && tag == null" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }">
|
||||
<header><span>{{ $t('explore', { host: meta.name || 'Misskey' }) }}</span></header>
|
||||
<div><span>{{ $t('exploreUsersCount', { count: num(stats.originalUsersCount) }) }}</span></div>
|
||||
</div>
|
||||
|
||||
<template v-if="tag == null">
|
||||
<x-user-list :pagination="pinnedUsers" :expanded="false">
|
||||
<fa :icon="faBookmark" fixed-width/>{{ $t('pinnedUsers') }}
|
||||
</x-user-list>
|
||||
<x-user-list :pagination="popularUsers" :expanded="false">
|
||||
<fa :icon="faChartLine" fixed-width/>{{ $t('popularUsers') }}
|
||||
</x-user-list>
|
||||
<x-user-list :pagination="recentlyUpdatedUsers" :expanded="false">
|
||||
<fa :icon="faCommentAlt" fixed-width/>{{ $t('recentlyUpdatedUsers') }}
|
||||
</x-user-list>
|
||||
<x-user-list :pagination="recentlyRegisteredUsers" :expanded="false">
|
||||
<fa :icon="faPlus" fixed-width/>{{ $t('recentlyRegisteredUsers') }}
|
||||
</x-user-list>
|
||||
</template>
|
||||
|
||||
<div class="localfedi7 _panel" v-if="tag == null" :style="{ backgroundImage: `url(/assets/fedi.jpg)`, marginTop: 'var(--margin)' }">
|
||||
<header><span>{{ $t('exploreFediverse') }}</span></header>
|
||||
</div>
|
||||
|
||||
<mk-container :body-togglable="true" :expanded="false" ref="tags">
|
||||
<template #header><fa :icon="faHashtag" fixed-width/>{{ $t('popularTags') }}</template>
|
||||
|
||||
<div class="vxjfqztj">
|
||||
<router-link v-for="tag in tagsLocal" :to="`/explore/tags/${tag.tag}`" :key="'local:' + tag.tag" class="local">{{ tag.tag }}</router-link>
|
||||
<router-link v-for="tag in tagsRemote" :to="`/explore/tags/${tag.tag}`" :key="'remote:' + tag.tag">{{ tag.tag }}</router-link>
|
||||
</div>
|
||||
</mk-container>
|
||||
|
||||
<x-user-list v-if="tag != null" :pagination="tagUsers" :key="`${tag}`">
|
||||
<fa :icon="faHashtag" fixed-width/>{{ tag }}
|
||||
</x-user-list>
|
||||
<template v-if="tag == null">
|
||||
<x-user-list :pagination="popularUsersF" :expanded="false">
|
||||
<fa :icon="faChartLine" fixed-width/>{{ $t('popularUsers') }}
|
||||
</x-user-list>
|
||||
<x-user-list :pagination="recentlyUpdatedUsersF" :expanded="false">
|
||||
<fa :icon="faCommentAlt" fixed-width/>{{ $t('recentlyUpdatedUsers') }}
|
||||
</x-user-list>
|
||||
<x-user-list :pagination="recentlyRegisteredUsersF" :expanded="false">
|
||||
<fa :icon="faRocket" fixed-width/>{{ $t('recentlyDiscoveredUsers') }}
|
||||
</x-user-list>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faChartLine, faPlus, faHashtag, faRocket } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faBookmark, faCommentAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import XUserList from '../components/user-list.vue';
|
||||
import MkContainer from '../components/ui/container.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('explore') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
XUserList,
|
||||
MkContainer,
|
||||
},
|
||||
|
||||
props: {
|
||||
tag: {
|
||||
type: String,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pinnedUsers: { endpoint: 'pinned-users' },
|
||||
popularUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
state: 'alive',
|
||||
origin: 'local',
|
||||
sort: '+follower',
|
||||
} },
|
||||
recentlyUpdatedUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
origin: 'local',
|
||||
sort: '+updatedAt',
|
||||
} },
|
||||
recentlyRegisteredUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
origin: 'local',
|
||||
state: 'alive',
|
||||
sort: '+createdAt',
|
||||
} },
|
||||
popularUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
state: 'alive',
|
||||
origin: 'remote',
|
||||
sort: '+follower',
|
||||
} },
|
||||
recentlyUpdatedUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
origin: 'combined',
|
||||
sort: '+updatedAt',
|
||||
} },
|
||||
recentlyRegisteredUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
origin: 'combined',
|
||||
sort: '+createdAt',
|
||||
} },
|
||||
tagsLocal: [],
|
||||
tagsRemote: [],
|
||||
stats: null,
|
||||
meta: null,
|
||||
num: Vue.filter('number'),
|
||||
faBookmark, faChartLine, faCommentAlt, faPlus, faHashtag, faRocket
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
tagUsers(): any {
|
||||
return {
|
||||
endpoint: 'hashtags/users',
|
||||
limit: 30,
|
||||
params: {
|
||||
tag: this.tag,
|
||||
origin: 'combined',
|
||||
sort: '+follower',
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
tag() {
|
||||
if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null);
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$root.api('hashtags/list', {
|
||||
sort: '+attachedLocalUsers',
|
||||
attachedToLocalUserOnly: true,
|
||||
limit: 30
|
||||
}).then(tags => {
|
||||
this.tagsLocal = tags;
|
||||
});
|
||||
this.$root.api('hashtags/list', {
|
||||
sort: '+attachedRemoteUsers',
|
||||
attachedToRemoteUserOnly: true,
|
||||
limit: 30
|
||||
}).then(tags => {
|
||||
this.tagsRemote = tags;
|
||||
});
|
||||
this.$root.api('stats').then(stats => {
|
||||
this.stats = stats;
|
||||
});
|
||||
this.$root.getMeta().then(meta => {
|
||||
this.meta = meta;
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.localfedi7 {
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
height: 80px;
|
||||
background-position: 50%;
|
||||
background-size: cover;
|
||||
margin-bottom: var(--margin);
|
||||
|
||||
> * {
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
> span {
|
||||
display: inline-block;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
> header {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> div {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.vxjfqztj {
|
||||
padding: 16px;
|
||||
|
||||
> * {
|
||||
margin-right: 16px;
|
||||
|
||||
&.local {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
48
src/client/pages/favorites.vue
Normal file
48
src/client/pages/favorites.vue
Normal file
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div>
|
||||
<portal to="icon"><fa :icon="faStar"/></portal>
|
||||
<portal to="title">{{ $t('favorites') }}</portal>
|
||||
<x-notes :pagination="pagination" :detail="true" :extract="items => items.map(item => item.note)" @before="before()" @after="after()"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faStar } from '@fortawesome/free-solid-svg-icons';
|
||||
import Progress from '../scripts/loading';
|
||||
import XNotes from '../components/notes.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('favorites') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
XNotes
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination: {
|
||||
endpoint: 'i/favorites',
|
||||
limit: 10,
|
||||
params: () => ({
|
||||
})
|
||||
},
|
||||
faStar
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
before() {
|
||||
Progress.start();
|
||||
},
|
||||
|
||||
after() {
|
||||
Progress.done();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
47
src/client/pages/featured.vue
Normal file
47
src/client/pages/featured.vue
Normal file
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<div>
|
||||
<portal to="icon"><fa :icon="faFireAlt"/></portal>
|
||||
<portal to="title">{{ $t('featured') }}</portal>
|
||||
<x-notes ref="notes" :pagination="pagination" @before="before" @after="after"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faFireAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import Progress from '../scripts/loading';
|
||||
import XNotes from '../components/notes.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('featured') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
XNotes
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination: {
|
||||
endpoint: 'notes/featured',
|
||||
limit: 10,
|
||||
offsetMode: true
|
||||
},
|
||||
faFireAlt
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
before() {
|
||||
Progress.start();
|
||||
},
|
||||
|
||||
after() {
|
||||
Progress.done();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
142
src/client/pages/follow-requests.vue
Normal file
142
src/client/pages/follow-requests.vue
Normal file
|
@ -0,0 +1,142 @@
|
|||
<template>
|
||||
<mk-pagination :pagination="pagination" #default="{items}" class="mk-follow-requests" ref="list">
|
||||
<div class="user _panel" v-for="(req, i) in items" :key="req.id" :data-index="i">
|
||||
<mk-avatar class="avatar" :user="req.follower"/>
|
||||
<div class="body">
|
||||
<div class="name">
|
||||
<router-link class="name" :to="req.follower | userPage" v-user-preview="req.follower.id"><mk-user-name :user="req.follower"/></router-link>
|
||||
<p class="acct">@{{ req.follower | acct }}</p>
|
||||
</div>
|
||||
<div class="description" v-if="req.follower.description" :title="req.follower.description">
|
||||
<mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$store.state.i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="_button" @click="accept(req.follower)"><fa :icon="faCheck"/></button>
|
||||
<button class="_button" @click="reject(req.follower)"><fa :icon="faTimes"/></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mk-pagination>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faCheck, faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkPagination from '../components/ui/pagination.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('followRequests') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkPagination
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination: {
|
||||
endpoint: 'following/requests/list',
|
||||
limit: 10,
|
||||
},
|
||||
faCheck, faTimes
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
accept(user) {
|
||||
this.$root.api('following/requests/accept', { userId: user.id }).then(() => {
|
||||
this.$refs.list.reload();
|
||||
});
|
||||
},
|
||||
reject(user) {
|
||||
this.$root.api('following/requests/reject', { userId: user.id }).then(() => {
|
||||
this.$refs.list.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-follow-requests {
|
||||
> .user {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
|
||||
> .avatar {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
margin: 0 12px 0 0;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
display: flex;
|
||||
width: calc(100% - 54px);
|
||||
position: relative;
|
||||
|
||||
> .name {
|
||||
width: 45%;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> .name,
|
||||
> .acct {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> .name {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
> .acct {
|
||||
font-size: 15px;
|
||||
line-height: 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
> .description {
|
||||
width: 55%;
|
||||
line-height: 42px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.7;
|
||||
font-size: 14px;
|
||||
padding-right: 40px;
|
||||
padding-left: 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: auto 0;
|
||||
|
||||
> button {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
98
src/client/pages/follow.vue
Normal file
98
src/client/pages/follow.vue
Normal file
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<div class="mk-follow-page">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
created() {
|
||||
const acct = new URL(location.href).searchParams.get('acct');
|
||||
if (acct == null) return;
|
||||
|
||||
const dialog = this.$root.dialog({
|
||||
type: 'waiting',
|
||||
text: this.$t('fetchingAsApObject') + '...',
|
||||
showOkButton: false,
|
||||
showCancelButton: false,
|
||||
cancelableByBgClick: false
|
||||
});
|
||||
|
||||
if (acct.startsWith('https://')) {
|
||||
this.$root.api('ap/show', {
|
||||
uri: acct
|
||||
}).then(res => {
|
||||
if (res.type == 'User') {
|
||||
this.follow(res.object);
|
||||
} else {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: 'Not a user'
|
||||
}).then(() => {
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
}).catch(e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
}).then(() => {
|
||||
window.close();
|
||||
});
|
||||
}).finally(() => {
|
||||
dialog.close();
|
||||
});
|
||||
} else {
|
||||
this.$root.api('users/show', parseAcct(acct)).then(user => {
|
||||
this.follow(user);
|
||||
}).catch(e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
}).then(() => {
|
||||
window.close();
|
||||
});
|
||||
}).finally(() => {
|
||||
dialog.close();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async follow(user) {
|
||||
const { canceled } = await this.$root.dialog({
|
||||
type: 'question',
|
||||
text: this.$t('followConfirm', { name: user.name || user.username }),
|
||||
showCancelButton: true
|
||||
});
|
||||
|
||||
if (canceled) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.$root.api('following/create', {
|
||||
userId: user.id
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
}).then(() => {
|
||||
window.close();
|
||||
});
|
||||
}).catch(e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
}).then(() => {
|
||||
window.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
190
src/client/pages/index.home.vue
Normal file
190
src/client/pages/index.home.vue
Normal file
|
@ -0,0 +1,190 @@
|
|||
<template>
|
||||
<div class="mk-home" v-hotkey.global="keymap">
|
||||
<portal to="header">
|
||||
<button @click="choose" class="_button _kjvfvyph_">
|
||||
<i><fa v-if="$store.state.i.hasUnreadAntenna" :icon="faCircle"/></i>
|
||||
<fa v-if="src === 'home'" :icon="faHome"/>
|
||||
<fa v-if="src === 'local'" :icon="faComments"/>
|
||||
<fa v-if="src === 'social'" :icon="faShareAlt"/>
|
||||
<fa v-if="src === 'global'" :icon="faGlobe"/>
|
||||
<fa v-if="src === 'list'" :icon="faListUl"/>
|
||||
<fa v-if="src === 'antenna'" :icon="faSatellite"/>
|
||||
<span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : $t('_timelines.' + src) }}</span>
|
||||
<fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
|
||||
</button>
|
||||
</portal>
|
||||
<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list" :antenna="antenna" @before="before()" @after="after()"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faComments } from '@fortawesome/free-regular-svg-icons';
|
||||
import Progress from '../scripts/loading';
|
||||
import XTimeline from '../components/timeline.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('timeline') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
XTimeline
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
src: 'home',
|
||||
list: null,
|
||||
antenna: null,
|
||||
menuOpened: false,
|
||||
faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faCircle
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
keymap(): any {
|
||||
return {
|
||||
't': this.focus
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
src() {
|
||||
this.showNav = false;
|
||||
this.saveSrc();
|
||||
},
|
||||
list(x) {
|
||||
this.showNav = false;
|
||||
this.saveSrc();
|
||||
if (x != null) this.antenna = null;
|
||||
},
|
||||
antenna(x) {
|
||||
this.showNav = false;
|
||||
this.saveSrc();
|
||||
if (x != null) this.list = null;
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$root.getMeta().then((meta: Record<string, any>) => {
|
||||
if (!(
|
||||
this.enableGlobalTimeline = !meta.disableGlobalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin
|
||||
) && this.src === 'global') this.src = 'local';
|
||||
if (!(
|
||||
this.enableLocalTimeline = !meta.disableLocalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin
|
||||
) && ['local', 'social'].includes(this.src)) this.src = 'home';
|
||||
});
|
||||
if (this.$store.state.device.tl) {
|
||||
this.src = this.$store.state.device.tl.src;
|
||||
if (this.src === 'list') {
|
||||
this.list = this.$store.state.device.tl.arg;
|
||||
} else if (this.src === 'antenna') {
|
||||
this.antenna = this.$store.state.device.tl.arg;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
before() {
|
||||
Progress.start();
|
||||
},
|
||||
|
||||
after() {
|
||||
Progress.done();
|
||||
},
|
||||
|
||||
async choose(ev) {
|
||||
this.menuOpened = true;
|
||||
const [antennas, lists] = await Promise.all([
|
||||
this.$root.api('antennas/list'),
|
||||
this.$root.api('users/lists/list')
|
||||
]);
|
||||
const antennaItems = antennas.map(antenna => ({
|
||||
text: antenna.name,
|
||||
icon: faSatellite,
|
||||
indicate: antenna.hasUnreadNote,
|
||||
action: () => {
|
||||
this.antenna = antenna;
|
||||
this.setSrc('antenna');
|
||||
}
|
||||
}));
|
||||
const listItems = lists.map(list => ({
|
||||
text: list.name,
|
||||
icon: faListUl,
|
||||
action: () => {
|
||||
this.list = list;
|
||||
this.setSrc('list');
|
||||
}
|
||||
}));
|
||||
this.$root.menu({
|
||||
items: [{
|
||||
text: this.$t('_timelines.home'),
|
||||
icon: faHome,
|
||||
action: () => { this.setSrc('home') }
|
||||
}, {
|
||||
text: this.$t('_timelines.local'),
|
||||
icon: faComments,
|
||||
action: () => { this.setSrc('local') }
|
||||
}, {
|
||||
text: this.$t('_timelines.social'),
|
||||
icon: faShareAlt,
|
||||
action: () => { this.setSrc('social') }
|
||||
}, {
|
||||
text: this.$t('_timelines.global'),
|
||||
icon: faGlobe,
|
||||
action: () => { this.setSrc('global') }
|
||||
}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems],
|
||||
fixed: true,
|
||||
noCenter: true,
|
||||
source: ev.currentTarget || ev.target
|
||||
}).then(() => {
|
||||
this.menuOpened = false;
|
||||
});
|
||||
},
|
||||
|
||||
setSrc(src) {
|
||||
this.src = src;
|
||||
},
|
||||
|
||||
saveSrc() {
|
||||
this.$store.commit('device/setTl', {
|
||||
src: this.src,
|
||||
arg: this.src == 'list' ? this.list : this.antenna
|
||||
});
|
||||
},
|
||||
|
||||
focus() {
|
||||
(this.$refs.tl as any).focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@keyframes blink {
|
||||
0% { opacity: 1; }
|
||||
30% { opacity: 1; }
|
||||
90% { opacity: 0; }
|
||||
}
|
||||
|
||||
._kjvfvyph_ {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 0 16px;
|
||||
font-weight: bold;
|
||||
|
||||
> i {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 8px;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
}
|
||||
</style>
|
15
src/client/pages/index.vue
Normal file
15
src/client/pages/index.vue
Normal file
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<component :is="$store.getters.isSignedIn ? 'home' : 'welcome'"></component>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Home from './index.home.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Home,
|
||||
Welcome: () => import('./index.welcome.vue').then(m => m.default),
|
||||
}
|
||||
});
|
||||
</script>
|
103
src/client/pages/index.welcome.entrance.vue
Normal file
103
src/client/pages/index.welcome.entrance.vue
Normal file
|
@ -0,0 +1,103 @@
|
|||
<template>
|
||||
<div class="rsqzvsbo">
|
||||
<div class="_panel about">
|
||||
<div class="banner" :style="{ backgroundImage: `url(${ banner })` }"></div>
|
||||
<div class="body">
|
||||
<h1 class="name" v-html="name || host"></h1>
|
||||
<div class="desc" v-html="description || $t('introMisskey')"></div>
|
||||
<mk-button @click="signup()" style="display: inline-block; margin-right: 16px;" primary>{{ $t('signup') }}</mk-button>
|
||||
<mk-button @click="signin()" style="display: inline-block;">{{ $t('login') }}</mk-button>
|
||||
</div>
|
||||
</div>
|
||||
<x-notes :pagination="featuredPagination"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { toUnicode } from 'punycode';
|
||||
import XSigninDialog from '../components/signin-dialog.vue';
|
||||
import XSignupDialog from '../components/signup-dialog.vue';
|
||||
import MkButton from '../components/ui/button.vue';
|
||||
import XNotes from '../components/notes.vue';
|
||||
import i18n from '../i18n';
|
||||
import { host } from '../config';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
XNotes,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
featuredPagination: {
|
||||
endpoint: 'notes/featured',
|
||||
limit: 10,
|
||||
noPaging: true,
|
||||
},
|
||||
host: toUnicode(host),
|
||||
meta: null,
|
||||
name: null,
|
||||
description: null,
|
||||
banner: null,
|
||||
announcements: [],
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$root.getMeta().then(meta => {
|
||||
this.meta = meta;
|
||||
this.name = meta.name;
|
||||
this.description = meta.description;
|
||||
this.announcements = meta.announcements;
|
||||
this.banner = meta.bannerUrl;
|
||||
});
|
||||
|
||||
this.$root.api('stats').then(stats => {
|
||||
this.stats = stats;
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
signin() {
|
||||
this.$root.new(XSigninDialog, {
|
||||
autoSet: true
|
||||
});
|
||||
},
|
||||
|
||||
signup() {
|
||||
this.$root.new(XSignupDialog);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rsqzvsbo {
|
||||
> .about {
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--margin);
|
||||
|
||||
> .banner {
|
||||
height: 170px;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
}
|
||||
|
||||
> .body {
|
||||
padding: 32px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
> .name {
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
102
src/client/pages/index.welcome.setup.vue
Normal file
102
src/client/pages/index.welcome.setup.vue
Normal file
|
@ -0,0 +1,102 @@
|
|||
<template>
|
||||
<form class="mk-setup" @submit.prevent="submit()">
|
||||
<h1>Welcome to Misskey!</h1>
|
||||
<div>
|
||||
<p>{{ $t('intro') }}</p>
|
||||
<mk-input v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" spellcheck="false" required>
|
||||
<span>{{ $t('username') }}</span>
|
||||
<template #prefix>@</template>
|
||||
<template #suffix>@{{ host }}</template>
|
||||
</mk-input>
|
||||
<mk-input v-model="password" type="password">
|
||||
<span>{{ $t('password') }}</span>
|
||||
<template #prefix><fa :icon="faLock"/></template>
|
||||
</mk-input>
|
||||
<footer>
|
||||
<mk-button primary type="submit" :disabled="submitting">{{ submitting ? $t('processing') : $t('done') }}<mk-ellipsis v-if="submitting"/></mk-button>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkButton from '../components/ui/button.vue';
|
||||
import MkInput from '../components/ui/input.vue';
|
||||
import { host } from '../config';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
MkInput,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
submitting: false,
|
||||
host,
|
||||
faLock
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
submit() {
|
||||
if (this.submitting) return;
|
||||
this.submitting = true;
|
||||
|
||||
this.$root.api('admin/accounts/create', {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
}).then(res => {
|
||||
localStorage.setItem('i', res.token);
|
||||
location.href = '/';
|
||||
}).catch(() => {
|
||||
this.submitting = false;
|
||||
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('some-error')
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-setup {
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
|
||||
> h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> div {
|
||||
padding: 32px;
|
||||
background: var(--panel);
|
||||
|
||||
> p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> footer {
|
||||
> * {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
34
src/client/pages/index.welcome.vue
Normal file
34
src/client/pages/index.welcome.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<div v-if="meta" class="mk-welcome">
|
||||
<portal to="title">{{ instanceName }}</portal>
|
||||
<x-setup v-if="meta.requireSetup"/>
|
||||
<x-entrance v-else/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XSetup from './index.welcome.setup.vue';
|
||||
import XEntrance from './index.welcome.entrance.vue';
|
||||
import { getInstanceName } from '../scripts/get-instance-name';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XSetup,
|
||||
XEntrance,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
meta: null,
|
||||
instanceName: getInstanceName(),
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$root.getMeta().then(meta => {
|
||||
this.meta = meta;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
129
src/client/pages/instance/announcements.vue
Normal file
129
src/client/pages/instance/announcements.vue
Normal file
|
@ -0,0 +1,129 @@
|
|||
<template>
|
||||
<div class="ztgjmzrw">
|
||||
<portal to="icon"><fa :icon="faBroadcastTower"/></portal>
|
||||
<portal to="title">{{ $t('announcements') }}</portal>
|
||||
<mk-button @click="add()" primary style="margin: 0 auto 16px auto;"><fa :icon="faPlus"/> {{ $t('add') }}</mk-button>
|
||||
<section class="_section announcements">
|
||||
<div class="_content announcement" v-for="announcement in announcements">
|
||||
<mk-input v-model="announcement.title" style="margin-top: 8px;">
|
||||
<span>{{ $t('title') }}</span>
|
||||
</mk-input>
|
||||
<mk-textarea v-model="announcement.text">
|
||||
<span>{{ $t('text') }}</span>
|
||||
</mk-textarea>
|
||||
<mk-input v-model="announcement.imageUrl">
|
||||
<span>{{ $t('imageUrl') }}</span>
|
||||
</mk-input>
|
||||
<p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p>
|
||||
<div class="buttons">
|
||||
<mk-button class="button" inline @click="save(announcement)" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
<mk-button class="button" inline @click="remove(announcement)"><fa :icon="faTrashAlt"/> {{ $t('remove') }}</mk-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faBroadcastTower, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../../i18n';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkInput from '../../components/ui/input.vue';
|
||||
import MkTextarea from '../../components/ui/textarea.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('announcements') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
MkInput,
|
||||
MkTextarea,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
announcements: [],
|
||||
faBroadcastTower, faSave, faTrashAlt, faPlus
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$root.api('admin/announcements/list').then(announcements => {
|
||||
this.announcements = announcements;
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
add() {
|
||||
this.announcements.unshift({
|
||||
id: null,
|
||||
title: '',
|
||||
text: '',
|
||||
imageUrl: null
|
||||
});
|
||||
},
|
||||
|
||||
remove(announcement) {
|
||||
this.$root.dialog({
|
||||
type: 'warning',
|
||||
text: this.$t('removeAreYouSure', { x: announcement.title }),
|
||||
showCancelButton: true
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
this.announcements = this.announcements.filter(x => x != announcement);
|
||||
this.$root.api('admin/announcements/delete', announcement);
|
||||
});
|
||||
},
|
||||
|
||||
save(announcement) {
|
||||
if (announcement.id == null) {
|
||||
this.$root.api('admin/announcements/create', announcement).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
text: this.$t('saved')
|
||||
});
|
||||
}).catch(e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.$root.api('admin/announcements/update', announcement).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
text: this.$t('saved')
|
||||
});
|
||||
}).catch(e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ztgjmzrw {
|
||||
> .announcements {
|
||||
> .announcement {
|
||||
> .buttons {
|
||||
> .button:first-child {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
253
src/client/pages/instance/emojis.vue
Normal file
253
src/client/pages/instance/emojis.vue
Normal file
|
@ -0,0 +1,253 @@
|
|||
<template>
|
||||
<div class="mk-instance-emojis">
|
||||
<portal to="icon"><fa :icon="faLaugh"/></portal>
|
||||
<portal to="title">{{ $t('customEmojis') }}</portal>
|
||||
<section class="_section local">
|
||||
<div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojis') }}</div>
|
||||
<div class="_content">
|
||||
<input ref="file" type="file" style="display: none;" @change="onChangeFile"/>
|
||||
<mk-pagination :pagination="pagination" class="emojis" ref="emojis">
|
||||
<template #empty><span>{{ $t('noCustomEmojis') }}</span></template>
|
||||
<template #default="{items}">
|
||||
<div class="emoji" v-for="(emoji, i) in items" :key="emoji.id" :data-index="i" @click="selected = emoji" :class="{ selected: selected && (selected.id === emoji.id) }">
|
||||
<img :src="emoji.url" class="img" :alt="emoji.name"/>
|
||||
<div class="body">
|
||||
<span class="name">{{ emoji.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button inline primary @click="add()"><fa :icon="faPlus"/> {{ $t('addEmoji') }}</mk-button>
|
||||
<mk-button inline :disabled="selected == null" @click="del()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="_section remote">
|
||||
<div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojisOfRemote') }}</div>
|
||||
<div class="_content">
|
||||
<mk-input v-model="host" :debounce="true" style="margin-top: 0;"><span>{{ $t('host') }}</span></mk-input>
|
||||
<mk-pagination :pagination="remotePagination" class="emojis" ref="remoteEmojis">
|
||||
<template #empty><span>{{ $t('noCustomEmojis') }}</span></template>
|
||||
<template #default="{items}">
|
||||
<div class="emoji" v-for="(emoji, i) in items" :key="emoji.id" :data-index="i" @click="selectedRemote = emoji" :class="{ selected: selectedRemote && (selectedRemote.id === emoji.id) }">
|
||||
<img :src="emoji.url" class="img" :alt="emoji.name"/>
|
||||
<div class="body">
|
||||
<span class="name">{{ emoji.name }}</span>
|
||||
<span class="host">{{ emoji.host }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button inline primary :disabled="selectedRemote == null" @click="im()"><fa :icon="faPlus"/> {{ $t('import') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faTrashAlt, faLaugh } from '@fortawesome/free-regular-svg-icons';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkInput from '../../components/ui/input.vue';
|
||||
import MkPagination from '../../components/ui/pagination.vue';
|
||||
import { apiUrl } from '../../config';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: `${this.$t('customEmojis')} | ${this.$t('instance')}`
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
MkInput,
|
||||
MkPagination,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
name: null,
|
||||
selected: null,
|
||||
selectedRemote: null,
|
||||
host: '',
|
||||
pagination: {
|
||||
endpoint: 'admin/emoji/list',
|
||||
limit: 10,
|
||||
},
|
||||
remotePagination: {
|
||||
endpoint: 'admin/emoji/list-remote',
|
||||
limit: 10,
|
||||
params: () => ({
|
||||
host: this.host ? this.host : null
|
||||
})
|
||||
},
|
||||
faTrashAlt, faPlus, faLaugh
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
host() {
|
||||
this.$refs.remoteEmojis.reload();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async add() {
|
||||
const { canceled: canceled, result: name } = await this.$root.dialog({
|
||||
title: this.$t('emojiName'),
|
||||
input: true
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
this.name = name;
|
||||
|
||||
(this.$refs.file as any).click();
|
||||
},
|
||||
|
||||
onChangeFile() {
|
||||
const [file] = Array.from((this.$refs.file as any).files);
|
||||
if (file == null) return;
|
||||
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
data.append('name', this.name);
|
||||
data.append('i', this.$store.state.i.token);
|
||||
|
||||
const dialog = this.$root.dialog({
|
||||
type: 'waiting',
|
||||
text: this.$t('uploading') + '...',
|
||||
showOkButton: false,
|
||||
showCancelButton: false,
|
||||
cancelableByBgClick: false
|
||||
});
|
||||
|
||||
fetch(apiUrl + '/admin/emoji/add', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(f => {
|
||||
this.$refs.emojis.reload();
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
dialog.close();
|
||||
});
|
||||
},
|
||||
|
||||
async del() {
|
||||
const { canceled } = await this.$root.dialog({
|
||||
type: 'warning',
|
||||
text: this.$t('removeAreYouSure', { x: this.selected.name }),
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
this.$root.api('admin/emoji/remove', {
|
||||
id: this.selected.id
|
||||
}).then(() => {
|
||||
this.$refs.emojis.reload();
|
||||
});
|
||||
},
|
||||
|
||||
im() {
|
||||
this.$root.api('admin/emoji/copy', {
|
||||
emojiId: this.selectedRemote.id,
|
||||
}).then(() => {
|
||||
this.$refs.emojis.reload();
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
}).catch(e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-instance-emojis {
|
||||
> .local {
|
||||
> ._content {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
|
||||
> .emojis {
|
||||
> .emoji {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.selected {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 8px var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> .img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
padding: 8px;
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .remote {
|
||||
> ._content {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
|
||||
> .emojis {
|
||||
> .emoji {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.selected {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 8px var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> .img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
padding: 0 8px;
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
> .host {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
576
src/client/pages/instance/federation.instance.vue
Normal file
576
src/client/pages/instance/federation.instance.vue
Normal file
|
@ -0,0 +1,576 @@
|
|||
<template>
|
||||
<x-window @closed="() => { $emit('closed'); destroyDom(); }" :no-padding="true">
|
||||
<template #header>{{ instance.host }}</template>
|
||||
<div class="mk-instance-info">
|
||||
<div class="table info">
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label">{{ $t('software') }}</div>
|
||||
<div class="data">{{ instance.softwareName || '?' }}</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="label">{{ $t('version') }}</div>
|
||||
<div class="data">{{ instance.softwareVersion || '?' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table data">
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faCrosshairs" fixed-width class="icon"/>{{ $t('registeredAt') }}</div>
|
||||
<div class="data">{{ new Date(instance.caughtAt).toLocaleString() }} (<mk-time :time="instance.caughtAt"/>)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faCloudDownloadAlt" fixed-width class="icon"/>{{ $t('following') }}</div>
|
||||
<div class="data clickable" @click="showFollowing()">{{ instance.followingCount | number }}</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faCloudUploadAlt" fixed-width class="icon"/>{{ $t('followers') }}</div>
|
||||
<div class="data clickable" @click="showFollowers()">{{ instance.followersCount | number }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faUsers" fixed-width class="icon"/>{{ $t('users') }}</div>
|
||||
<div class="data clickable" @click="showUsers()">{{ instance.usersCount | number }}</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faPencilAlt" fixed-width class="icon"/>{{ $t('notes') }}</div>
|
||||
<div class="data">{{ instance.notesCount | number }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faFileImage" fixed-width class="icon"/>{{ $t('files') }}</div>
|
||||
<div class="data">{{ instance.driveFiles | number }}</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faDatabase" fixed-width class="icon"/>{{ $t('storageUsage') }}</div>
|
||||
<div class="data">{{ instance.driveUsage | bytes }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faLongArrowAltUp" fixed-width class="icon"/>{{ $t('latestRequestSentAt') }}</div>
|
||||
<div class="data"><mk-time v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faTrafficLight" fixed-width class="icon"/>{{ $t('latestStatus') }}</div>
|
||||
<div class="data">{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="label"><fa :icon="faLongArrowAltDown" fixed-width class="icon"/>{{ $t('latestRequestReceivedAt') }}</div>
|
||||
<div class="data"><mk-time v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<div class="header">
|
||||
<span class="label">{{ $t('charts') }}</span>
|
||||
<div class="selects">
|
||||
<mk-select v-model="chartSrc" style="margin: 0; flex: 1;">
|
||||
<option value="requests">{{ $t('_instanceCharts.requests') }}</option>
|
||||
<option value="users">{{ $t('_instanceCharts.users') }}</option>
|
||||
<option value="users-total">{{ $t('_instanceCharts.usersTotal') }}</option>
|
||||
<option value="notes">{{ $t('_instanceCharts.notes') }}</option>
|
||||
<option value="notes-total">{{ $t('_instanceCharts.notesTotal') }}</option>
|
||||
<option value="ff">{{ $t('_instanceCharts.ff') }}</option>
|
||||
<option value="ff-total">{{ $t('_instanceCharts.ffTotal') }}</option>
|
||||
<option value="drive-usage">{{ $t('_instanceCharts.cacheSize') }}</option>
|
||||
<option value="drive-usage-total">{{ $t('_instanceCharts.cacheSizeTotal') }}</option>
|
||||
<option value="drive-files">{{ $t('_instanceCharts.files') }}</option>
|
||||
<option value="drive-files-total">{{ $t('_instanceCharts.filesTotal') }}</option>
|
||||
</mk-select>
|
||||
<mk-select v-model="chartSpan" style="margin: 0;">
|
||||
<option value="hour">{{ $t('perHour') }}</option>
|
||||
<option value="day">{{ $t('perDay') }}</option>
|
||||
</mk-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<canvas ref="chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operations">
|
||||
<span class="label">{{ $t('operations') }}</span>
|
||||
<mk-switch v-model="isSuspended" class="switch">{{ $t('stopActivityDelivery') }}</mk-switch>
|
||||
<mk-switch v-model="isBlocked" class="switch">{{ $t('blockThisInstance') }}</mk-switch>
|
||||
</div>
|
||||
<details class="metadata">
|
||||
<summary class="label">{{ $t('metadata') }}</summary>
|
||||
<pre><code>{{ JSON.stringify(instance.metadata, null, 2) }}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
</x-window>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Chart from 'chart.js';
|
||||
import i18n from '../../i18n';
|
||||
import { faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown } from '@fortawesome/free-solid-svg-icons';
|
||||
import XWindow from '../../components/window.vue';
|
||||
import MkUsersDialog from '../../components/users-dialog.vue';
|
||||
import MkSelect from '../../components/ui/select.vue';
|
||||
import MkSwitch from '../../components/ui/switch.vue';
|
||||
|
||||
const chartLimit = 90;
|
||||
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
|
||||
const negate = arr => arr.map(x => -x);
|
||||
const alpha = hex => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
|
||||
const r = parseInt(result[1], 16);
|
||||
const g = parseInt(result[2], 16);
|
||||
const b = parseInt(result[3], 16);
|
||||
return `rgba(${r}, ${g}, ${b}, 0.1)`;
|
||||
};
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XWindow,
|
||||
MkSelect,
|
||||
MkSwitch,
|
||||
},
|
||||
|
||||
props: {
|
||||
instance: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
meta: null,
|
||||
isSuspended: false,
|
||||
isBlocked: false,
|
||||
now: null,
|
||||
chart: null,
|
||||
chartInstance: null,
|
||||
chartSrc: 'requests',
|
||||
chartSpan: 'hour',
|
||||
faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
data(): any {
|
||||
if (this.chart == null) return null;
|
||||
switch (this.chartSrc) {
|
||||
case 'requests': return this.requestsChart();
|
||||
case 'users': return this.usersChart(false);
|
||||
case 'users-total': return this.usersChart(true);
|
||||
case 'notes': return this.notesChart(false);
|
||||
case 'notes-total': return this.notesChart(true);
|
||||
case 'ff': return this.ffChart(false);
|
||||
case 'ff-total': return this.ffChart(true);
|
||||
case 'drive-usage': return this.driveUsageChart(false);
|
||||
case 'drive-usage-total': return this.driveUsageChart(true);
|
||||
case 'drive-files': return this.driveFilesChart(false);
|
||||
case 'drive-files-total': return this.driveFilesChart(true);
|
||||
}
|
||||
},
|
||||
|
||||
stats(): any[] {
|
||||
const stats =
|
||||
this.chartSpan == 'day' ? this.chart.perDay :
|
||||
this.chartSpan == 'hour' ? this.chart.perHour :
|
||||
null;
|
||||
|
||||
return stats;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
isSuspended() {
|
||||
this.$root.api('admin/federation/update-instance', {
|
||||
host: this.instance.host,
|
||||
isSuspended: this.isSuspended
|
||||
});
|
||||
},
|
||||
|
||||
isBlocked() {
|
||||
this.$root.api('admin/update-meta', {
|
||||
blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host)
|
||||
});
|
||||
},
|
||||
|
||||
chartSrc() {
|
||||
this.renderChart();
|
||||
},
|
||||
|
||||
chartSpan() {
|
||||
this.renderChart();
|
||||
}
|
||||
},
|
||||
|
||||
async created() {
|
||||
this.$root.getMeta().then(meta => {
|
||||
this.meta = meta;
|
||||
this.isSuspended = this.instance.isSuspended;
|
||||
this.isBlocked = this.meta.blockedHosts.includes(this.instance.host);
|
||||
});
|
||||
|
||||
this.now = new Date();
|
||||
|
||||
const [perHour, perDay] = await Promise.all([
|
||||
this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }),
|
||||
this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }),
|
||||
]);
|
||||
|
||||
const chart = {
|
||||
perHour: perHour,
|
||||
perDay: perDay
|
||||
};
|
||||
|
||||
this.chart = chart;
|
||||
|
||||
this.renderChart();
|
||||
},
|
||||
|
||||
methods: {
|
||||
setSrc(src) {
|
||||
this.chartSrc = src;
|
||||
},
|
||||
|
||||
renderChart() {
|
||||
if (this.chartInstance) {
|
||||
this.chartInstance.destroy();
|
||||
}
|
||||
|
||||
Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
|
||||
this.chartInstance = new Chart(this.$refs.chart, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(),
|
||||
datasets: this.data.series.map(x => ({
|
||||
label: x.name,
|
||||
data: x.data.slice().reverse(),
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: x.color,
|
||||
backgroundColor: alpha(x.color),
|
||||
}))
|
||||
},
|
||||
options: {
|
||||
aspectRatio: 2.5,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 16,
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
gridLines: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
display: false
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
position: 'right',
|
||||
ticks: {
|
||||
display: false
|
||||
}
|
||||
}]
|
||||
},
|
||||
tooltips: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getDate(ago: number) {
|
||||
const y = this.now.getFullYear();
|
||||
const m = this.now.getMonth();
|
||||
const d = this.now.getDate();
|
||||
const h = this.now.getHours();
|
||||
|
||||
return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
|
||||
},
|
||||
|
||||
format(arr) {
|
||||
return arr;
|
||||
},
|
||||
|
||||
requestsChart(): any {
|
||||
return {
|
||||
series: [{
|
||||
name: 'In',
|
||||
color: '#008FFB',
|
||||
data: this.format(this.stats.requests.received)
|
||||
}, {
|
||||
name: 'Out (succ)',
|
||||
color: '#00E396',
|
||||
data: this.format(this.stats.requests.succeeded)
|
||||
}, {
|
||||
name: 'Out (fail)',
|
||||
color: '#FEB019',
|
||||
data: this.format(this.stats.requests.failed)
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
usersChart(total: boolean): any {
|
||||
return {
|
||||
series: [{
|
||||
name: 'Users',
|
||||
color: '#008FFB',
|
||||
data: this.format(total
|
||||
? this.stats.users.total
|
||||
: sum(this.stats.users.inc, negate(this.stats.users.dec))
|
||||
)
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
notesChart(total: boolean): any {
|
||||
return {
|
||||
series: [{
|
||||
name: 'Notes',
|
||||
color: '#008FFB',
|
||||
data: this.format(total
|
||||
? this.stats.notes.total
|
||||
: sum(this.stats.notes.inc, negate(this.stats.notes.dec))
|
||||
)
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
ffChart(total: boolean): any {
|
||||
return {
|
||||
series: [{
|
||||
name: 'Following',
|
||||
color: '#008FFB',
|
||||
data: this.format(total
|
||||
? this.stats.following.total
|
||||
: sum(this.stats.following.inc, negate(this.stats.following.dec))
|
||||
)
|
||||
}, {
|
||||
name: 'Followers',
|
||||
color: '#00E396',
|
||||
data: this.format(total
|
||||
? this.stats.followers.total
|
||||
: sum(this.stats.followers.inc, negate(this.stats.followers.dec))
|
||||
)
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
driveUsageChart(total: boolean): any {
|
||||
return {
|
||||
bytes: true,
|
||||
series: [{
|
||||
name: 'Drive usage',
|
||||
color: '#008FFB',
|
||||
data: this.format(total
|
||||
? this.stats.drive.totalUsage
|
||||
: sum(this.stats.drive.incUsage, negate(this.stats.drive.decUsage))
|
||||
)
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
driveFilesChart(total: boolean): any {
|
||||
return {
|
||||
series: [{
|
||||
name: 'Drive files',
|
||||
color: '#008FFB',
|
||||
data: this.format(total
|
||||
? this.stats.drive.totalFiles
|
||||
: sum(this.stats.drive.incFiles, negate(this.stats.drive.decFiles))
|
||||
)
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
showFollowing() {
|
||||
this.$root.new(MkUsersDialog, {
|
||||
title: this.$t('instanceFollowing'),
|
||||
pagination: {
|
||||
endpoint: 'federation/following',
|
||||
limit: 10,
|
||||
params: {
|
||||
host: this.instance.host
|
||||
}
|
||||
},
|
||||
extract: item => item.follower
|
||||
});
|
||||
},
|
||||
|
||||
showFollowers() {
|
||||
this.$root.new(MkUsersDialog, {
|
||||
title: this.$t('instanceFollowers'),
|
||||
pagination: {
|
||||
endpoint: 'federation/followers',
|
||||
limit: 10,
|
||||
params: {
|
||||
host: this.instance.host
|
||||
}
|
||||
},
|
||||
extract: item => item.followee
|
||||
});
|
||||
},
|
||||
|
||||
showUsers() {
|
||||
this.$root.new(MkUsersDialog, {
|
||||
title: this.$t('instanceUsers'),
|
||||
pagination: {
|
||||
endpoint: 'federation/users',
|
||||
limit: 10,
|
||||
params: {
|
||||
host: this.instance.host
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-instance-info {
|
||||
overflow: auto;
|
||||
|
||||
> .table {
|
||||
padding: 0 32px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
> .row {
|
||||
display: flex;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
> .cell {
|
||||
flex: 1;
|
||||
|
||||
> .label {
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
|
||||
> .icon {
|
||||
margin-right: 4px;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .data.clickable {
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .data {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: solid 1px var(--divider);
|
||||
|
||||
@media (max-width: 500px) {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .chart {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: solid 1px var(--divider);
|
||||
|
||||
@media (max-width: 500px) {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
> .header {
|
||||
padding: 0 32px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
> .label {
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> .selects {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
> .chart {
|
||||
padding: 0 16px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .operations {
|
||||
padding: 16px 32px 16px 32px;
|
||||
margin-top: 8px;
|
||||
border-top: solid 1px var(--divider);
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 8px 16px 8px 16px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> .label {
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> .switch {
|
||||
margin: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .metadata {
|
||||
padding: 16px 32px 16px 32px;
|
||||
border-top: solid 1px var(--divider);
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 8px 16px 8px 16px;
|
||||
}
|
||||
|
||||
> .label {
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> pre > code {
|
||||
display: block;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
165
src/client/pages/instance/federation.vue
Normal file
165
src/client/pages/instance/federation.vue
Normal file
|
@ -0,0 +1,165 @@
|
|||
<template>
|
||||
<div class="mk-federation">
|
||||
<section class="_section instances">
|
||||
<div class="_title"><fa :icon="faGlobe"/> {{ $t('instances') }}</div>
|
||||
<div class="_content">
|
||||
<div class="inputs" style="display: flex;">
|
||||
<mk-input v-model="host" :debounce="true" style="margin: 0; flex: 1;"><span>{{ $t('host') }}</span></mk-input>
|
||||
<mk-select v-model="state" style="margin: 0;">
|
||||
<option value="all">{{ $t('all') }}</option>
|
||||
<option value="federating">{{ $t('federating') }}</option>
|
||||
<option value="subscribing">{{ $t('subscribing') }}</option>
|
||||
<option value="publishing">{{ $t('publishing') }}</option>
|
||||
<option value="suspended">{{ $t('suspended') }}</option>
|
||||
<option value="blocked">{{ $t('blocked') }}</option>
|
||||
<option value="notResponding">{{ $t('notResponding') }}</option>
|
||||
</mk-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<mk-pagination :pagination="pagination" #default="{items}" class="instances" ref="instances" :key="host + state">
|
||||
<div class="instance" v-for="(instance, i) in items" :key="instance.id" :data-index="i" @click="info(instance)">
|
||||
<div class="host"><fa :icon="faCircle" class="indicator" :class="getStatus(instance)"/><b>{{ instance.host }}</b></div>
|
||||
<div class="status">
|
||||
<span class="sub" v-if="instance.followersCount > 0"><fa :icon="faCaretDown" class="icon"/>Sub</span>
|
||||
<span class="sub" v-else><fa :icon="faCaretDown" class="icon"/>-</span>
|
||||
<span class="pub" v-if="instance.followingCount > 0"><fa :icon="faCaretUp" class="icon"/>Pub</span>
|
||||
<span class="pub" v-else><fa :icon="faCaretUp" class="icon"/>-</span>
|
||||
<span class="lastCommunicatedAt"><fa :icon="faExchangeAlt" class="icon"/><mk-time :time="instance.lastCommunicatedAt"/></span>
|
||||
<span class="latestStatus"><fa :icon="faTrafficLight" class="icon"/>{{ instance.latestStatus || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faGlobe, faCircle, faExchangeAlt, faCaretDown, faCaretUp, faTrafficLight } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../i18n';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkInput from '../../components/ui/input.vue';
|
||||
import MkSelect from '../../components/ui/select.vue';
|
||||
import MkPagination from '../../components/ui/pagination.vue';
|
||||
import MkInstanceInfo from './federation.instance.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('federation') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
MkInput,
|
||||
MkSelect,
|
||||
MkPagination,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
host: '',
|
||||
state: 'federating',
|
||||
sort: '+pubSub',
|
||||
pagination: {
|
||||
endpoint: 'federation/instances',
|
||||
limit: 10,
|
||||
offsetMode: true,
|
||||
params: () => ({
|
||||
sort: this.sort,
|
||||
host: this.host != '' ? this.host : null,
|
||||
...(
|
||||
this.state === 'federating' ? { federating: true } :
|
||||
this.state === 'subscribing' ? { subscribing: true } :
|
||||
this.state === 'publishing' ? { publishing: true } :
|
||||
this.state === 'suspended' ? { suspended: true } :
|
||||
this.state === 'blocked' ? { blocked: true } :
|
||||
this.state === 'notResponding' ? { notResponding: true } :
|
||||
{})
|
||||
})
|
||||
},
|
||||
faGlobe, faCircle, faExchangeAlt, faCaretDown, faCaretUp, faTrafficLight
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
host() {
|
||||
this.$refs.instances.reload();
|
||||
},
|
||||
state() {
|
||||
this.$refs.instances.reload();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
getStatus(instance) {
|
||||
if (instance.isSuspended) return 'off';
|
||||
if (instance.isNotResponding) return 'red';
|
||||
return 'green';
|
||||
},
|
||||
|
||||
info(instance) {
|
||||
this.$root.new(MkInstanceInfo, {
|
||||
instance: instance
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-federation {
|
||||
> .instances {
|
||||
> ._content {
|
||||
> .instances {
|
||||
> .instance {
|
||||
cursor: pointer;
|
||||
|
||||
> .host {
|
||||
> .indicator {
|
||||
font-size: 70%;
|
||||
vertical-align: baseline;
|
||||
margin-right: 4px;
|
||||
|
||||
&.green {
|
||||
color: #49c5ba;
|
||||
}
|
||||
|
||||
&.yellow {
|
||||
color: #c5a549;
|
||||
}
|
||||
|
||||
&.red {
|
||||
color: #c54949;
|
||||
}
|
||||
|
||||
&.off {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 90%;
|
||||
|
||||
> span {
|
||||
flex: 1;
|
||||
|
||||
> .icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
54
src/client/pages/instance/files.vue
Normal file
54
src/client/pages/instance/files.vue
Normal file
|
@ -0,0 +1,54 @@
|
|||
<template>
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div>
|
||||
<div class="_content">
|
||||
<mk-button primary @click="clear()"><fa :icon="faTrashAlt"/> {{ $t('clearCachedFiles') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faCloud } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkPagination from '../../components/ui/pagination.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: `${this.$t('files')} | ${this.$t('instance')}`
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
MkPagination,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faTrashAlt, faCloud
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
clear() {
|
||||
this.$root.dialog({
|
||||
type: 'warning',
|
||||
text: this.$t('clearCachedFilesConfirm'),
|
||||
showCancelButton: true
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
|
||||
this.$root.api('admin/drive/clean-remote-files', {}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
393
src/client/pages/instance/index.vue
Normal file
393
src/client/pages/instance/index.vue
Normal file
|
@ -0,0 +1,393 @@
|
|||
<template>
|
||||
<div v-if="meta" class="mk-instance-page">
|
||||
<portal to="icon"><fa :icon="faServer"/></portal>
|
||||
<portal to="title">{{ $t('instance') }}</portal>
|
||||
|
||||
<section class="_section info">
|
||||
<div class="_title"><fa :icon="faInfoCircle"/> {{ $t('basicInfo') }}</div>
|
||||
<div class="_content">
|
||||
<mk-input v-model="name" style="margin-top: 8px;">{{ $t('instanceName') }}</mk-input>
|
||||
<mk-textarea v-model="description">{{ $t('instanceDescription') }}</mk-textarea>
|
||||
<mk-input v-model="iconUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('iconUrl') }}</mk-input>
|
||||
<mk-input v-model="bannerUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('bannerUrl') }}</mk-input>
|
||||
<mk-input v-model="tosUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('tosUrl') }}</mk-input>
|
||||
<mk-input v-model="maintainerName">{{ $t('maintainerName') }}</mk-input>
|
||||
<mk-input v-model="maintainerEmail" type="email"><template #icon><fa :icon="faEnvelope"/></template>{{ $t('maintainerEmail') }}</mk-input>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_section info">
|
||||
<div class="_content">
|
||||
<mk-switch v-model="enableLocalTimeline" @change="save()">{{ $t('enableLocalTimeline') }}</mk-switch>
|
||||
<mk-switch v-model="enableGlobalTimeline" @change="save()">{{ $t('enableGlobalTimeline') }}</mk-switch>
|
||||
<mk-info>{{ $t('disablingTimelinesInfo') }}</mk-info>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_section info">
|
||||
<div class="_title"><fa :icon="faUser"/> {{ $t('registration') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="enableRegistration" @change="save()">{{ $t('enableRegistration') }}</mk-switch>
|
||||
<mk-button v-if="!enableRegistration" @click="invite">{{ $t('invite') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="enableRecaptcha">{{ $t('enableRecaptcha') }}</mk-switch>
|
||||
<template v-if="enableRecaptcha">
|
||||
<mk-info>{{ $t('recaptcha-info') }}</mk-info>
|
||||
<mk-info warn>{{ $t('recaptcha-info2') }}</mk-info>
|
||||
<mk-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSiteKey') }}</mk-input>
|
||||
<mk-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSecretKey') }}</mk-input>
|
||||
</template>
|
||||
</div>
|
||||
<div class="_content" v-if="enableRecaptcha && recaptchaSiteKey">
|
||||
<header>{{ $t('preview') }}</header>
|
||||
<div ref="recaptcha" style="margin: 16px 0 0 0;" :key="recaptchaSiteKey"></div>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faBolt"/> {{ $t('serviceworker') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="enableServiceWorker">{{ $t('enableServiceworker') }}<template #desc>{{ $t('serviceworker-info') }}</template></mk-switch>
|
||||
<template v-if="enableServiceWorker">
|
||||
<mk-info>{{ $t('vapid-info') }}<br><code>npm i web-push -g<br>web-push generate-vapid-keys</code></mk-info>
|
||||
<mk-horizon-group inputs class="fit-bottom">
|
||||
<mk-input v-model="swPublicKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>{{ $t('vapid-publickey') }}</mk-input>
|
||||
<mk-input v-model="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>{{ $t('vapid-privatekey') }}</mk-input>
|
||||
</mk-horizon-group>
|
||||
</template>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faThumbtack"/> {{ $t('pinnedUsers') }}</div>
|
||||
<div class="_content">
|
||||
<mk-textarea v-model="pinnedUsers" style="margin-top: 0;">
|
||||
<template #desc>{{ $t('pinnedUsersDescription') }} <button class="_textButton" @click="addPinUser">{{ $t('addUser') }}</button></template>
|
||||
</mk-textarea>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="cacheRemoteFiles">{{ $t('cacheRemoteFiles') }}<template #desc>{{ $t('cacheRemoteFilesDescription') }}</template></mk-switch>
|
||||
<mk-switch v-model="proxyRemoteFiles">{{ $t('proxyRemoteFiles') }}<template #desc>{{ $t('proxyRemoteFilesDescription') }}</template></mk-switch>
|
||||
<mk-input v-model="localDriveCapacityMb" type="number">{{ $t('driveCapacityPerLocalAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input>
|
||||
<mk-input v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" style="margin-bottom: 0;">{{ $t('driveCapacityPerRemoteAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faGhost"/> {{ $t('proxyAccount') }}</div>
|
||||
<div class="_content">
|
||||
<mk-input v-model="proxyAccount" style="margin: 0;"><template #prefix>@</template>{{ $t('proxyAccount') }}<template #desc>{{ $t('proxyAccountDescription') }}</template></mk-input>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faBan"/> {{ $t('blockedInstances') }}</div>
|
||||
<div class="_content">
|
||||
<mk-textarea v-model="blockedHosts" style="margin-top: 0;">
|
||||
<template #desc>{{ $t('blockedInstancesDescription') }}</template>
|
||||
</mk-textarea>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div>
|
||||
<div class="_content">
|
||||
<header><fa :icon="faTwitter"/> {{ $t('twitter-integration-config') }}</header>
|
||||
<mk-switch v-model="enableTwitterIntegration">{{ $t('enable-twitter-integration') }}</mk-switch>
|
||||
<template v-if="enableTwitterIntegration">
|
||||
<mk-input v-model="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('twitter-integration-consumer-key') }}</mk-input>
|
||||
<mk-input v-model="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('twitter-integration-consumer-secret') }}</mk-input>
|
||||
<mk-info>{{ $t('twitter-integration-info', { url: `${url}/api/tw/cb` }) }}</mk-info>
|
||||
</template>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<header><fa :icon="faGithub"/> {{ $t('github-integration-config') }}</header>
|
||||
<mk-switch v-model="enableGithubIntegration">{{ $t('enable-github-integration') }}</mk-switch>
|
||||
<template v-if="enableGithubIntegration">
|
||||
<mk-input v-model="githubClientId" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('github-integration-client-id') }}</mk-input>
|
||||
<mk-input v-model="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('github-integration-client-secret') }}</mk-input>
|
||||
<mk-info>{{ $t('github-integration-info', { url: `${url}/api/gh/cb` }) }}</mk-info>
|
||||
</template>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<header><fa :icon="faDiscord"/> {{ $t('discord-integration-config') }}</header>
|
||||
<mk-switch v-model="enableDiscordIntegration">{{ $t('enable-discord-integration') }}</mk-switch>
|
||||
<template v-if="enableDiscordIntegration">
|
||||
<mk-input v-model="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('discord-integration-client-id') }}</mk-input>
|
||||
<mk-input v-model="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('discord-integration-client-secret') }}</mk-input>
|
||||
<mk-info>{{ $t('discord-integration-info', { url: `${url}/api/dc/cb` }) }}</mk-info>
|
||||
</template>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_section info">
|
||||
<div class="_title"><fa :icon="faInfoCircle"/> {{ $t('instanceInfo') }}</div>
|
||||
<div class="_content table" v-if="stats">
|
||||
<div><b>{{ $t('users') }}</b><span>{{ stats.originalUsersCount | number }}</span></div>
|
||||
<div><b>{{ $t('notes') }}</b><span>{{ stats.originalNotesCount | number }}</span></div>
|
||||
</div>
|
||||
<div class="_content table">
|
||||
<div><b>Misskey</b><span>v{{ version }}</span></div>
|
||||
</div>
|
||||
<div class="_content table" v-if="serverInfo">
|
||||
<div><b>Node.js</b><span>{{ serverInfo.node }}</span></div>
|
||||
<div><b>PostgreSQL</b><span>v{{ serverInfo.psql }}</span></div>
|
||||
<div><b>Redis</b><span>v{{ serverInfo.redis }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faShareAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faThumbtack, faUser, faShieldAlt, faKey, faBolt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faTrashAlt, faEnvelope } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkInput from '../../components/ui/input.vue';
|
||||
import MkTextarea from '../../components/ui/textarea.vue';
|
||||
import MkSwitch from '../../components/ui/switch.vue';
|
||||
import MkInfo from '../../components/ui/info.vue';
|
||||
import MkUserSelect from '../../components/user-select.vue';
|
||||
import { version } from '../../config';
|
||||
import i18n from '../../i18n';
|
||||
import getAcct from '../../../misc/acct/render';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('instance') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
MkInput,
|
||||
MkTextarea,
|
||||
MkSwitch,
|
||||
MkInfo,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
version,
|
||||
meta: null,
|
||||
stats: null,
|
||||
serverInfo: null,
|
||||
proxyAccount: null,
|
||||
cacheRemoteFiles: false,
|
||||
proxyRemoteFiles: false,
|
||||
localDriveCapacityMb: 0,
|
||||
remoteDriveCapacityMb: 0,
|
||||
blockedHosts: '',
|
||||
pinnedUsers: '',
|
||||
maintainerName: null,
|
||||
maintainerEmail: null,
|
||||
name: null,
|
||||
description: null,
|
||||
tosUrl: null,
|
||||
bannerUrl: null,
|
||||
iconUrl: null,
|
||||
enableRegistration: false,
|
||||
enableLocalTimeline: false,
|
||||
enableGlobalTimeline: false,
|
||||
enableRecaptcha: false,
|
||||
recaptchaSiteKey: null,
|
||||
recaptchaSecretKey: null,
|
||||
enableServiceWorker: false,
|
||||
swPublicKey: null,
|
||||
swPrivateKey: null,
|
||||
enableTwitterIntegration: false,
|
||||
twitterConsumerKey: null,
|
||||
twitterConsumerSecret: null,
|
||||
enableGithubIntegration: false,
|
||||
githubClientId: null,
|
||||
githubClientSecret: null,
|
||||
enableDiscordIntegration: false,
|
||||
discordClientId: null,
|
||||
discordClientSecret: null,
|
||||
faTwitter, faDiscord, faGithub, faShareAlt, faTrashAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faEnvelope, faThumbtack, faUser, faShieldAlt, faKey, faBolt
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$root.getMeta().then(meta => {
|
||||
this.meta = meta;
|
||||
this.name = this.meta.name;
|
||||
this.description = this.meta.description;
|
||||
this.tosUrl = this.meta.tosUrl;
|
||||
this.bannerUrl = this.meta.bannerUrl;
|
||||
this.iconUrl = this.meta.iconUrl;
|
||||
this.maintainerName = this.meta.maintainerName;
|
||||
this.maintainerEmail = this.meta.maintainerEmail;
|
||||
this.enableRegistration = !this.meta.disableRegistration;
|
||||
this.enableLocalTimeline = !this.meta.disableLocalTimeline;
|
||||
this.enableGlobalTimeline = !this.meta.disableGlobalTimeline;
|
||||
this.enableRecaptcha = this.meta.enableRecaptcha;
|
||||
this.recaptchaSiteKey = this.meta.recaptchaSiteKey;
|
||||
this.recaptchaSecretKey = this.meta.recaptchaSecretKey;
|
||||
this.proxyAccount = this.meta.proxyAccount;
|
||||
this.cacheRemoteFiles = this.meta.cacheRemoteFiles;
|
||||
this.proxyRemoteFiles = this.meta.proxyRemoteFiles;
|
||||
this.localDriveCapacityMb = this.meta.driveCapacityPerLocalUserMb;
|
||||
this.remoteDriveCapacityMb = this.meta.driveCapacityPerRemoteUserMb;
|
||||
this.blockedHosts = this.meta.blockedHosts.join('\n');
|
||||
this.pinnedUsers = this.meta.pinnedUsers.join('\n');
|
||||
this.enableServiceWorker = this.meta.enableServiceWorker;
|
||||
this.swPublicKey = this.meta.swPublickey;
|
||||
this.swPrivateKey = this.meta.swPrivateKey;
|
||||
this.enableTwitterIntegration = this.meta.enableTwitterIntegration;
|
||||
this.twitterConsumerKey = this.meta.twitterConsumerKey;
|
||||
this.twitterConsumerSecret = this.meta.twitterConsumerSecret;
|
||||
this.enableGithubIntegration = this.meta.enableGithubIntegration;
|
||||
this.githubClientId = this.meta.githubClientId;
|
||||
this.githubClientSecret = this.meta.githubClientSecret;
|
||||
this.enableDiscordIntegration = this.meta.enableDiscordIntegration;
|
||||
this.discordClientId = this.meta.discordClientId;
|
||||
this.discordClientSecret = this.meta.discordClientSecret;
|
||||
});
|
||||
|
||||
this.$root.api('admin/server-info').then(res => {
|
||||
this.serverInfo = res;
|
||||
});
|
||||
|
||||
this.$root.api('stats').then(res => {
|
||||
this.stats = res;
|
||||
});
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const renderRecaptchaPreview = () => {
|
||||
if (!(window as any).grecaptcha) return;
|
||||
if (!this.$refs.recaptcha) return;
|
||||
if (!this.recaptchaSiteKey) return;
|
||||
(window as any).grecaptcha.render(this.$refs.recaptcha, {
|
||||
sitekey: this.recaptchaSiteKey
|
||||
});
|
||||
};
|
||||
window.onRecaotchaLoad = () => {
|
||||
renderRecaptchaPreview();
|
||||
};
|
||||
const head = document.getElementsByTagName('head')[0];
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?onload=onRecaotchaLoad');
|
||||
head.appendChild(script);
|
||||
this.$watch('enableRecaptcha', () => {
|
||||
renderRecaptchaPreview();
|
||||
});
|
||||
this.$watch('recaptchaSiteKey', () => {
|
||||
renderRecaptchaPreview();
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
addPinUser() {
|
||||
this.$root.new(MkUserSelect, {}).$once('selected', user => {
|
||||
this.pinnedUsers = this.pinnedUsers.trim();
|
||||
this.pinnedUsers += '\n@' + getAcct(user);
|
||||
this.pinnedUsers = this.pinnedUsers.trim();
|
||||
});
|
||||
},
|
||||
|
||||
save(withDialog = false) {
|
||||
this.$root.api('admin/update-meta', {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
tosUrl: this.tosUrl,
|
||||
bannerUrl: this.bannerUrl,
|
||||
iconUrl: this.iconUrl,
|
||||
maintainerName: this.maintainerName,
|
||||
maintainerEmail: this.maintainerEmail,
|
||||
disableRegistration: !this.enableRegistration,
|
||||
disableLocalTimeline: !this.enableLocalTimeline,
|
||||
disableGlobalTimeline: !this.enableGlobalTimeline,
|
||||
enableRecaptcha: this.enableRecaptcha,
|
||||
recaptchaSiteKey: this.recaptchaSiteKey,
|
||||
recaptchaSecretKey: this.recaptchaSecretKey,
|
||||
proxyAccount: this.proxyAccount,
|
||||
cacheRemoteFiles: this.cacheRemoteFiles,
|
||||
proxyRemoteFiles: this.proxyRemoteFiles,
|
||||
localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10),
|
||||
remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10),
|
||||
blockedHosts: this.blockedHosts.split('\n') || [],
|
||||
pinnedUsers: this.pinnedUsers ? this.pinnedUsers.split('\n') : [],
|
||||
enableServiceWorker: this.enableServiceWorker,
|
||||
swPublicKey: this.swPublicKey,
|
||||
swPrivateKey: this.swPrivateKey,
|
||||
enableTwitterIntegration: this.enableTwitterIntegration,
|
||||
twitterConsumerKey: this.twitterConsumerKey,
|
||||
twitterConsumerSecret: this.twitterConsumerSecret,
|
||||
enableGithubIntegration: this.enableGithubIntegration,
|
||||
githubClientId: this.githubClientId,
|
||||
githubClientSecret: this.githubClientSecret,
|
||||
enableDiscordIntegration: this.enableDiscordIntegration,
|
||||
discordClientId: this.discordClientId,
|
||||
discordClientSecret: this.discordClientSecret,
|
||||
}).then(() => {
|
||||
if (withDialog) {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
}
|
||||
}).catch(e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-instance-page {
|
||||
> .info {
|
||||
> .table {
|
||||
> div {
|
||||
display: flex;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
381
src/client/pages/instance/monitor.vue
Normal file
381
src/client/pages/instance/monitor.vue
Normal file
|
@ -0,0 +1,381 @@
|
|||
<template>
|
||||
<div class="mk-instance-monitor">
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faMicrochip"/> {{ $t('cpuAndMemory') }}</div>
|
||||
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
|
||||
<canvas ref="cpumem"></canvas>
|
||||
</div>
|
||||
<div class="_content" v-if="serverInfo">
|
||||
<div class="table">
|
||||
<div class="row">
|
||||
<div class="cell"><div class="label">CPU</div>{{ serverInfo.cpu.model }}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell"><div class="label">MEM total</div>{{ serverInfo.mem.total | bytes }}</div>
|
||||
<div class="cell"><div class="label">MEM used</div>{{ memUsage | bytes }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
|
||||
<div class="cell"><div class="label">MEM free</div>{{ serverInfo.mem.total - memUsage | bytes }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faHdd"/> {{ $t('disk') }}</div>
|
||||
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
|
||||
<canvas ref="disk"></canvas>
|
||||
</div>
|
||||
<div class="_content" v-if="serverInfo">
|
||||
<div class="table">
|
||||
<div class="row">
|
||||
<div class="cell"><div class="label">Disk total</div>{{ serverInfo.fs.total | bytes }}</div>
|
||||
<div class="cell"><div class="label">Disk used</div>{{ serverInfo.fs.used | bytes }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
|
||||
<div class="cell"><div class="label">Disk free</div>{{ serverInfo.fs.total - serverInfo.fs.used | bytes }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faExchangeAlt"/> {{ $t('network') }}</div>
|
||||
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
|
||||
<canvas ref="net"></canvas>
|
||||
</div>
|
||||
<div class="_content" v-if="serverInfo">
|
||||
<div class="table">
|
||||
<div class="row">
|
||||
<div class="cell"><div class="label">Interface</div>{{ serverInfo.net.interface }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faTachometerAlt, faExchangeAlt, faMicrochip, faHdd } from '@fortawesome/free-solid-svg-icons';
|
||||
import Chart from 'chart.js';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
const alpha = (hex, a) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
|
||||
const r = parseInt(result[1], 16);
|
||||
const g = parseInt(result[2], 16);
|
||||
const b = parseInt(result[3], 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
};
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
metaInfo() {
|
||||
return {
|
||||
title: `${this.$t('monitor')} | ${this.$t('instance')}`
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
connection: null,
|
||||
serverInfo: null,
|
||||
memUsage: 0,
|
||||
chartCpuMem: null,
|
||||
chartNet: null,
|
||||
faTachometerAlt, faExchangeAlt, faMicrochip, faHdd
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
|
||||
|
||||
this.chartCpuMem = new Chart(this.$refs.cpumem, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'CPU',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: '#86b300',
|
||||
backgroundColor: alpha('#86b300', 0.1),
|
||||
data: []
|
||||
}, {
|
||||
label: 'MEM (active)',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: '#935dbf',
|
||||
backgroundColor: alpha('#935dbf', 0.02),
|
||||
data: []
|
||||
}, {
|
||||
label: 'MEM (used)',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: '#935dbf',
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
data: []
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
aspectRatio: 3,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 8,
|
||||
bottom: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 16,
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
gridLines: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
display: false
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
position: 'right',
|
||||
ticks: {
|
||||
display: false,
|
||||
max: 100
|
||||
}
|
||||
}]
|
||||
},
|
||||
tooltips: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.chartNet = new Chart(this.$refs.net, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'In',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: '#94a029',
|
||||
backgroundColor: alpha('#94a029', 0.1),
|
||||
data: []
|
||||
}, {
|
||||
label: 'Out',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: '#ff9156',
|
||||
backgroundColor: alpha('#ff9156', 0.1),
|
||||
data: []
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
aspectRatio: 3,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 8,
|
||||
bottom: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 16,
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
gridLines: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
display: false
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
position: 'right',
|
||||
ticks: {
|
||||
display: false,
|
||||
}
|
||||
}]
|
||||
},
|
||||
tooltips: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.chartDisk = new Chart(this.$refs.disk, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Read',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: '#94a029',
|
||||
backgroundColor: alpha('#94a029', 0.1),
|
||||
data: []
|
||||
}, {
|
||||
label: 'Write',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: '#ff9156',
|
||||
backgroundColor: alpha('#ff9156', 0.1),
|
||||
data: []
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
aspectRatio: 3,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 8,
|
||||
bottom: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 16,
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
gridLines: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
display: false
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
position: 'right',
|
||||
ticks: {
|
||||
display: false,
|
||||
}
|
||||
}]
|
||||
},
|
||||
tooltips: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.$root.api('admin/server-info', {}).then(res => {
|
||||
this.serverInfo = res;
|
||||
|
||||
this.connection = this.$root.stream.useSharedConnection('serverStats');
|
||||
this.connection.on('stats', this.onStats);
|
||||
this.connection.on('statsLog', this.onStatsLog);
|
||||
this.connection.send('requestLog', {
|
||||
id: Math.random().toString().substr(2, 8),
|
||||
length: 150
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.connection.off('stats', this.onStats);
|
||||
this.connection.off('statsLog', this.onStatsLog);
|
||||
this.connection.dispose();
|
||||
},
|
||||
|
||||
methods: {
|
||||
onStats(stats) {
|
||||
const cpu = (stats.cpu * 100).toFixed(0);
|
||||
const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
|
||||
const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
|
||||
this.memUsage = stats.mem.active;
|
||||
|
||||
this.chartCpuMem.data.labels.push('');
|
||||
this.chartCpuMem.data.datasets[0].data.push(cpu);
|
||||
this.chartCpuMem.data.datasets[1].data.push(memActive);
|
||||
this.chartCpuMem.data.datasets[2].data.push(memUsed);
|
||||
this.chartNet.data.labels.push('');
|
||||
this.chartNet.data.datasets[0].data.push(stats.net.rx);
|
||||
this.chartNet.data.datasets[1].data.push(stats.net.tx);
|
||||
this.chartDisk.data.labels.push('');
|
||||
this.chartDisk.data.datasets[0].data.push(stats.fs.r);
|
||||
this.chartDisk.data.datasets[1].data.push(stats.fs.w);
|
||||
if (this.chartCpuMem.data.datasets[0].data.length > 150) {
|
||||
this.chartCpuMem.data.labels.shift();
|
||||
this.chartCpuMem.data.datasets[0].data.shift();
|
||||
this.chartCpuMem.data.datasets[1].data.shift();
|
||||
this.chartCpuMem.data.datasets[2].data.shift();
|
||||
this.chartNet.data.labels.shift();
|
||||
this.chartNet.data.datasets[0].data.shift();
|
||||
this.chartNet.data.datasets[1].data.shift();
|
||||
this.chartDisk.data.labels.shift();
|
||||
this.chartDisk.data.datasets[0].data.shift();
|
||||
this.chartDisk.data.datasets[1].data.shift();
|
||||
}
|
||||
this.chartCpuMem.update();
|
||||
this.chartNet.update();
|
||||
this.chartDisk.update();
|
||||
},
|
||||
|
||||
onStatsLog(statsLog) {
|
||||
for (const stats of statsLog.reverse()) {
|
||||
this.onStats(stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-instance-monitor {
|
||||
> section {
|
||||
> ._content {
|
||||
> .table {
|
||||
> .row {
|
||||
display: flex;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .cell {
|
||||
flex: 1;
|
||||
|
||||
> .label {
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
|
||||
> .icon {
|
||||
margin-right: 4px;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
204
src/client/pages/instance/queue.queue.vue
Normal file
204
src/client/pages/instance/queue.queue.vue
Normal file
|
@ -0,0 +1,204 @@
|
|||
<template>
|
||||
<section class="_section mk-queue-queue">
|
||||
<div class="_title"><slot name="title"></slot></div>
|
||||
<div class="_content status">
|
||||
<div class="cell"><div class="label">Process</div>{{ activeSincePrevTick | number }}</div>
|
||||
<div class="cell"><div class="label">Active</div>{{ active | number }}</div>
|
||||
<div class="cell"><div class="label">Waiting</div>{{ waiting | number }}</div>
|
||||
<div class="cell"><div class="label">Delayed</div>{{ delayed | number }}</div>
|
||||
</div>
|
||||
<div class="_content" style="margin-bottom: -8px;">
|
||||
<canvas ref="chart"></canvas>
|
||||
</div>
|
||||
<div class="_content" style="max-height: 180px; overflow: auto;">
|
||||
<sequential-entrance :delay="15" v-if="jobs.length > 0">
|
||||
<div v-for="(job, i) in jobs" :key="job[0]" :data-index="i">
|
||||
<span>{{ job[0] }}</span>
|
||||
<span style="margin-left: 8px; opacity: 0.7;">({{ job[1] | number }} jobs)</span>
|
||||
</div>
|
||||
</sequential-entrance>
|
||||
<span v-else style="opacity: 0.5;">{{ $t('noJobs') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Chart from 'chart.js';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
const alpha = (hex, a) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
|
||||
const r = parseInt(result[1], 16);
|
||||
const g = parseInt(result[2], 16);
|
||||
const b = parseInt(result[3], 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
};
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
props: {
|
||||
domain: {
|
||||
required: true
|
||||
},
|
||||
connection: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
chart: null,
|
||||
jobs: [],
|
||||
activeSincePrevTick: 0,
|
||||
active: 0,
|
||||
waiting: 0,
|
||||
delayed: 0,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.fetchJobs();
|
||||
|
||||
Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
|
||||
|
||||
this.chart = new Chart(this.$refs.chart, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Process',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: '#00E396',
|
||||
backgroundColor: alpha('#00E396', 0.1),
|
||||
data: []
|
||||
}, {
|
||||
label: 'Active',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: '#00BCD4',
|
||||
backgroundColor: alpha('#00BCD4', 0.1),
|
||||
data: []
|
||||
}, {
|
||||
label: 'Waiting',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: '#FFB300',
|
||||
backgroundColor: alpha('#FFB300', 0.1),
|
||||
data: []
|
||||
}, {
|
||||
label: 'Delayed',
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: '#E53935',
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
data: []
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
aspectRatio: 3,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 8,
|
||||
bottom: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 16,
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
gridLines: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
display: false
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
position: 'right',
|
||||
ticks: {
|
||||
display: false,
|
||||
}
|
||||
}]
|
||||
},
|
||||
tooltips: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.connection.on('stats', this.onStats);
|
||||
this.connection.on('statsLog', this.onStatsLog);
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.connection.off('stats', this.onStats);
|
||||
this.connection.off('statsLog', this.onStatsLog);
|
||||
},
|
||||
|
||||
methods: {
|
||||
onStats(stats) {
|
||||
this.activeSincePrevTick = stats[this.domain].activeSincePrevTick;
|
||||
this.active = stats[this.domain].active;
|
||||
this.waiting = stats[this.domain].waiting;
|
||||
this.delayed = stats[this.domain].delayed;
|
||||
this.chart.data.labels.push('');
|
||||
this.chart.data.datasets[0].data.push(stats[this.domain].activeSincePrevTick);
|
||||
this.chart.data.datasets[1].data.push(stats[this.domain].active);
|
||||
this.chart.data.datasets[2].data.push(stats[this.domain].waiting);
|
||||
this.chart.data.datasets[3].data.push(stats[this.domain].delayed);
|
||||
if (this.chart.data.datasets[0].data.length > 200) {
|
||||
this.chart.data.labels.shift();
|
||||
this.chart.data.datasets[0].data.shift();
|
||||
this.chart.data.datasets[1].data.shift();
|
||||
this.chart.data.datasets[2].data.shift();
|
||||
this.chart.data.datasets[3].data.shift();
|
||||
}
|
||||
this.chart.update();
|
||||
},
|
||||
|
||||
onStatsLog(statsLog) {
|
||||
for (const stats of statsLog.reverse()) {
|
||||
this.onStats(stats);
|
||||
}
|
||||
},
|
||||
|
||||
fetchJobs() {
|
||||
this.$root.api(this.domain === 'inbox' ? 'admin/queue/inbox-delayed' : this.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(jobs => {
|
||||
this.jobs = jobs;
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-queue-queue {
|
||||
> .status {
|
||||
display: flex;
|
||||
|
||||
> .cell {
|
||||
flex: 1;
|
||||
|
||||
> .label {
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
79
src/client/pages/instance/queue.vue
Normal file
79
src/client/pages/instance/queue.vue
Normal file
|
@ -0,0 +1,79 @@
|
|||
<template>
|
||||
<div>
|
||||
<x-queue :connection="connection" domain="inbox">
|
||||
<template #title><fa :icon="faExchangeAlt"/> In</template>
|
||||
</x-queue>
|
||||
<x-queue :connection="connection" domain="deliver">
|
||||
<template #title><fa :icon="faExchangeAlt"/> Out</template>
|
||||
</x-queue>
|
||||
<section class="_section">
|
||||
<div class="_content">
|
||||
<mk-button @click="clear()"><fa :icon="faTrashAlt"/> {{ $t('clearQueue') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faExchangeAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../../i18n';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import XQueue from './queue.queue.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
metaInfo() {
|
||||
return {
|
||||
title: `${this.$t('jobQueue')} | ${this.$t('instance')}`
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
XQueue,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
connection: this.$root.stream.useSharedConnection('queueStats'),
|
||||
faExchangeAlt, faTrashAlt
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.connection.send('requestLog', {
|
||||
id: Math.random().toString().substr(2, 8),
|
||||
length: 200
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.connection.dispose();
|
||||
},
|
||||
|
||||
methods: {
|
||||
clear() {
|
||||
this.$root.dialog({
|
||||
type: 'warning',
|
||||
title: this.$t('clearQueueConfirmTitle'),
|
||||
text: this.$t('clearQueueConfirmText'),
|
||||
showCancelButton: true
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
|
||||
this.$root.api('admin/queue/clear', {}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
491
src/client/pages/instance/stats.vue
Normal file
491
src/client/pages/instance/stats.vue
Normal file
|
@ -0,0 +1,491 @@
|
|||
<template>
|
||||
<div class="mk-instance-stats">
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faChartBar"/> {{ $t('statistics') }}</div>
|
||||
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
|
||||
<div class="selects" style="display: flex;">
|
||||
<mk-select v-model="chartSrc" style="margin: 0; flex: 1;">
|
||||
<optgroup :label="$t('federation')">
|
||||
<option value="federation-instances">{{ $t('_charts.federationInstancesIncDec') }}</option>
|
||||
<option value="federation-instances-total">{{ $t('_charts.federationInstancesTotal') }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="$t('users')">
|
||||
<option value="users">{{ $t('_charts.usersIncDec') }}</option>
|
||||
<option value="users-total">{{ $t('_charts.usersTotal') }}</option>
|
||||
<option value="active-users">{{ $t('_charts.activeUsers') }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="$t('notes')">
|
||||
<option value="notes">{{ $t('_charts.notesIncDec') }}</option>
|
||||
<option value="local-notes">{{ $t('_charts.localNotesIncDec') }}</option>
|
||||
<option value="remote-notes">{{ $t('_charts.remoteNotesIncDec') }}</option>
|
||||
<option value="notes-total">{{ $t('_charts.notesTotal') }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="$t('drive')">
|
||||
<option value="drive-files">{{ $t('_charts.filesIncDec') }}</option>
|
||||
<option value="drive-files-total">{{ $t('_charts.filesTotal') }}</option>
|
||||
<option value="drive">{{ $t('_charts.storageUsageIncDec') }}</option>
|
||||
<option value="drive-total">{{ $t('_charts.storageUsageTotal') }}</option>
|
||||
</optgroup>
|
||||
</mk-select>
|
||||
<mk-select v-model="chartSpan" style="margin: 0;">
|
||||
<option value="hour">{{ $t('perHour') }}</option>
|
||||
<option value="day">{{ $t('perDay') }}</option>
|
||||
</mk-select>
|
||||
</div>
|
||||
<canvas ref="chart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faChartBar } from '@fortawesome/free-solid-svg-icons';
|
||||
import Chart from 'chart.js';
|
||||
import i18n from '../../i18n';
|
||||
import MkSelect from '../../components/ui/select.vue';
|
||||
|
||||
const chartLimit = 90;
|
||||
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
|
||||
const negate = arr => arr.map(x => -x);
|
||||
const alpha = (hex, a) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
|
||||
const r = parseInt(result[1], 16);
|
||||
const g = parseInt(result[2], 16);
|
||||
const b = parseInt(result[3], 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
};
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
metaInfo() {
|
||||
return {
|
||||
title: `${this.$t('statistics')} | ${this.$t('instance')}`
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkSelect
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
now: null,
|
||||
chart: null,
|
||||
chartInstance: null,
|
||||
chartSrc: 'notes',
|
||||
chartSpan: 'hour',
|
||||
faChartBar
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
data(): any {
|
||||
if (this.chart == null) return null;
|
||||
switch (this.chartSrc) {
|
||||
case 'federation-instances': return this.federationInstancesChart(false);
|
||||
case 'federation-instances-total': return this.federationInstancesChart(true);
|
||||
case 'users': return this.usersChart(false);
|
||||
case 'users-total': return this.usersChart(true);
|
||||
case 'active-users': return this.activeUsersChart();
|
||||
case 'notes': return this.notesChart('combined');
|
||||
case 'local-notes': return this.notesChart('local');
|
||||
case 'remote-notes': return this.notesChart('remote');
|
||||
case 'notes-total': return this.notesTotalChart();
|
||||
case 'drive': return this.driveChart();
|
||||
case 'drive-total': return this.driveTotalChart();
|
||||
case 'drive-files': return this.driveFilesChart();
|
||||
case 'drive-files-total': return this.driveFilesTotalChart();
|
||||
}
|
||||
},
|
||||
|
||||
stats(): any[] {
|
||||
const stats =
|
||||
this.chartSpan == 'day' ? this.chart.perDay :
|
||||
this.chartSpan == 'hour' ? this.chart.perHour :
|
||||
null;
|
||||
|
||||
return stats;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
chartSrc() {
|
||||
this.renderChart();
|
||||
},
|
||||
|
||||
chartSpan() {
|
||||
this.renderChart();
|
||||
}
|
||||
},
|
||||
|
||||
async created() {
|
||||
this.now = new Date();
|
||||
|
||||
const [perHour, perDay] = await Promise.all([Promise.all([
|
||||
this.$root.api('charts/federation', { limit: chartLimit, span: 'hour' }),
|
||||
this.$root.api('charts/users', { limit: chartLimit, span: 'hour' }),
|
||||
this.$root.api('charts/active-users', { limit: chartLimit, span: 'hour' }),
|
||||
this.$root.api('charts/notes', { limit: chartLimit, span: 'hour' }),
|
||||
this.$root.api('charts/drive', { limit: chartLimit, span: 'hour' }),
|
||||
]), Promise.all([
|
||||
this.$root.api('charts/federation', { limit: chartLimit, span: 'day' }),
|
||||
this.$root.api('charts/users', { limit: chartLimit, span: 'day' }),
|
||||
this.$root.api('charts/active-users', { limit: chartLimit, span: 'day' }),
|
||||
this.$root.api('charts/notes', { limit: chartLimit, span: 'day' }),
|
||||
this.$root.api('charts/drive', { limit: chartLimit, span: 'day' }),
|
||||
])]);
|
||||
|
||||
const chart = {
|
||||
perHour: {
|
||||
federation: perHour[0],
|
||||
users: perHour[1],
|
||||
activeUsers: perHour[2],
|
||||
notes: perHour[3],
|
||||
drive: perHour[4],
|
||||
},
|
||||
perDay: {
|
||||
federation: perDay[0],
|
||||
users: perDay[1],
|
||||
activeUsers: perDay[2],
|
||||
notes: perDay[3],
|
||||
drive: perDay[4],
|
||||
}
|
||||
};
|
||||
|
||||
this.chart = chart;
|
||||
|
||||
this.renderChart();
|
||||
},
|
||||
|
||||
methods: {
|
||||
renderChart() {
|
||||
if (this.chartInstance) {
|
||||
this.chartInstance.destroy();
|
||||
}
|
||||
|
||||
Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
|
||||
this.chartInstance = new Chart(this.$refs.chart, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(),
|
||||
datasets: this.data.series.map(x => ({
|
||||
label: x.name,
|
||||
data: x.data.slice().reverse(),
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: x.color,
|
||||
backgroundColor: alpha(x.color, 0.1),
|
||||
hidden: !!x.hidden
|
||||
}))
|
||||
},
|
||||
options: {
|
||||
aspectRatio: 2.5,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 16,
|
||||
bottom: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 16,
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
gridLines: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
display: false
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
position: 'right',
|
||||
ticks: {
|
||||
display: false
|
||||
}
|
||||
}]
|
||||
},
|
||||
tooltips: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getDate(ago: number) {
|
||||
const y = this.now.getFullYear();
|
||||
const m = this.now.getMonth();
|
||||
const d = this.now.getDate();
|
||||
const h = this.now.getHours();
|
||||
|
||||
return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
|
||||
},
|
||||
|
||||
format(arr) {
|
||||
return arr;
|
||||
},
|
||||
|
||||
federationInstancesChart(total: boolean): any {
|
||||
return {
|
||||
series: [{
|
||||
name: 'Instances',
|
||||
color: '#008FFB',
|
||||
data: this.format(total
|
||||
? this.stats.federation.instance.total
|
||||
: sum(this.stats.federation.instance.inc, negate(this.stats.federation.instance.dec))
|
||||
)
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
notesChart(type: string): any {
|
||||
return {
|
||||
series: [{
|
||||
name: 'All',
|
||||
type: 'line',
|
||||
color: '#008FFB',
|
||||
data: this.format(type == 'combined'
|
||||
? sum(this.stats.notes.local.inc, negate(this.stats.notes.local.dec), this.stats.notes.remote.inc, negate(this.stats.notes.remote.dec))
|
||||
: sum(this.stats.notes[type].inc, negate(this.stats.notes[type].dec))
|
||||
)
|
||||
}, {
|
||||
name: 'Renotes',
|
||||
type: 'area',
|
||||
color: '#00E396',
|
||||
data: this.format(type == 'combined'
|
||||
? sum(this.stats.notes.local.diffs.renote, this.stats.notes.remote.diffs.renote)
|
||||
: this.stats.notes[type].diffs.renote
|
||||
)
|
||||
}, {
|
||||
name: 'Replies',
|
||||
type: 'area',
|
||||
color: '#FEB019',
|
||||
data: this.format(type == 'combined'
|
||||
? sum(this.stats.notes.local.diffs.reply, this.stats.notes.remote.diffs.reply)
|
||||
: this.stats.notes[type].diffs.reply
|
||||
)
|
||||
}, {
|
||||
name: 'Normal',
|
||||
type: 'area',
|
||||
color: '#FF4560',
|
||||
data: this.format(type == 'combined'
|
||||
? sum(this.stats.notes.local.diffs.normal, this.stats.notes.remote.diffs.normal)
|
||||
: this.stats.notes[type].diffs.normal
|
||||
)
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
notesTotalChart(): any {
|
||||
return {
|
||||
series: [{
|
||||
name: 'Combined',
|
||||
type: 'line',
|
||||
color: '#008FFB',
|
||||
data: this.format(sum(this.stats.notes.local.total, this.stats.notes.remote.total))
|
||||
}, {
|
||||
name: 'Local',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
hidden: true,
|
||||
data: this.format(this.stats.notes.local.total)
|
||||
}, {
|
||||
name: 'Remote',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
hidden: true,
|
||||
data: this.format(this.stats.notes.remote.total)
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
usersChart(total: boolean): any {
|
||||
return {
|
||||
series: [{
|
||||
name: 'Combined',
|
||||
type: 'line',
|
||||
color: '#008FFB',
|
||||
data: this.format(total
|
||||
? sum(this.stats.users.local.total, this.stats.users.remote.total)
|
||||
: sum(this.stats.users.local.inc, negate(this.stats.users.local.dec), this.stats.users.remote.inc, negate(this.stats.users.remote.dec))
|
||||
)
|
||||
}, {
|
||||
name: 'Local',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
hidden: true,
|
||||
data: this.format(total
|
||||
? this.stats.users.local.total
|
||||
: sum(this.stats.users.local.inc, negate(this.stats.users.local.dec))
|
||||
)
|
||||
}, {
|
||||
name: 'Remote',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
hidden: true,
|
||||
data: this.format(total
|
||||
? this.stats.users.remote.total
|
||||
: sum(this.stats.users.remote.inc, negate(this.stats.users.remote.dec))
|
||||
)
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
activeUsersChart(): any {
|
||||
return {
|
||||
series: [{
|
||||
name: 'Combined',
|
||||
type: 'line',
|
||||
color: '#008FFB',
|
||||
data: this.format(sum(this.stats.activeUsers.local.count, this.stats.activeUsers.remote.count))
|
||||
}, {
|
||||
name: 'Local',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
hidden: true,
|
||||
data: this.format(this.stats.activeUsers.local.count)
|
||||
}, {
|
||||
name: 'Remote',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
hidden: true,
|
||||
data: this.format(this.stats.activeUsers.remote.count)
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
driveChart(): any {
|
||||
return {
|
||||
bytes: true,
|
||||
series: [{
|
||||
name: 'All',
|
||||
type: 'line',
|
||||
color: '#008FFB',
|
||||
data: this.format(
|
||||
sum(
|
||||
this.stats.drive.local.incSize,
|
||||
negate(this.stats.drive.local.decSize),
|
||||
this.stats.drive.remote.incSize,
|
||||
negate(this.stats.drive.remote.decSize)
|
||||
)
|
||||
)
|
||||
}, {
|
||||
name: 'Local +',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: this.format(this.stats.drive.local.incSize)
|
||||
}, {
|
||||
name: 'Local -',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: this.format(negate(this.stats.drive.local.decSize))
|
||||
}, {
|
||||
name: 'Remote +',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: this.format(this.stats.drive.remote.incSize)
|
||||
}, {
|
||||
name: 'Remote -',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: this.format(negate(this.stats.drive.remote.decSize))
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
driveTotalChart(): any {
|
||||
return {
|
||||
bytes: true,
|
||||
series: [{
|
||||
name: 'Combined',
|
||||
type: 'line',
|
||||
color: '#008FFB',
|
||||
data: this.format(sum(this.stats.drive.local.totalSize, this.stats.drive.remote.totalSize))
|
||||
}, {
|
||||
name: 'Local',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
hidden: true,
|
||||
data: this.format(this.stats.drive.local.totalSize)
|
||||
}, {
|
||||
name: 'Remote',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
hidden: true,
|
||||
data: this.format(this.stats.drive.remote.totalSize)
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
driveFilesChart(): any {
|
||||
return {
|
||||
series: [{
|
||||
name: 'All',
|
||||
type: 'line',
|
||||
color: '#008FFB',
|
||||
data: this.format(
|
||||
sum(
|
||||
this.stats.drive.local.incCount,
|
||||
negate(this.stats.drive.local.decCount),
|
||||
this.stats.drive.remote.incCount,
|
||||
negate(this.stats.drive.remote.decCount)
|
||||
)
|
||||
)
|
||||
}, {
|
||||
name: 'Local +',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: this.format(this.stats.drive.local.incCount)
|
||||
}, {
|
||||
name: 'Local -',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: this.format(negate(this.stats.drive.local.decCount))
|
||||
}, {
|
||||
name: 'Remote +',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: this.format(this.stats.drive.remote.incCount)
|
||||
}, {
|
||||
name: 'Remote -',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: this.format(negate(this.stats.drive.remote.decCount))
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
driveFilesTotalChart(): any {
|
||||
return {
|
||||
series: [{
|
||||
name: 'Combined',
|
||||
type: 'line',
|
||||
color: '#008FFB',
|
||||
data: this.format(sum(this.stats.drive.local.totalCount, this.stats.drive.remote.totalCount))
|
||||
}, {
|
||||
name: 'Local',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
hidden: true,
|
||||
data: this.format(this.stats.drive.local.totalCount)
|
||||
}, {
|
||||
name: 'Remote',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
hidden: true,
|
||||
data: this.format(this.stats.drive.remote.totalCount)
|
||||
}]
|
||||
};
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
203
src/client/pages/instance/users.vue
Normal file
203
src/client/pages/instance/users.vue
Normal file
|
@ -0,0 +1,203 @@
|
|||
<template>
|
||||
<div class="mk-instance-users">
|
||||
<portal to="icon"><fa :icon="faUsers"/></portal>
|
||||
<portal to="title">{{ $t('users') }}</portal>
|
||||
|
||||
<section class="_section lookup">
|
||||
<div class="_title"><fa :icon="faSearch"/> {{ $t('lookup') }}</div>
|
||||
<div class="_content">
|
||||
<mk-input class="target" v-model="target" type="text" @enter="showUser()" style="margin-top: 0;">
|
||||
<span>{{ $t('usernameOrUserId') }}</span>
|
||||
</mk-input>
|
||||
<mk-button @click="showUser()" primary><fa :icon="faSearch"/> {{ $t('lookup') }}</mk-button>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button inline primary @click="search()"><fa :icon="faSearch"/> {{ $t('search') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_section users">
|
||||
<div class="_title"><fa :icon="faUsers"/> {{ $t('users') }}</div>
|
||||
<div class="_content _list">
|
||||
<mk-pagination :pagination="pagination" #default="{items}" class="users" ref="users" :auto-margin="false">
|
||||
<button class="user _button _listItem" v-for="(user, i) in items" :key="user.id" :data-index="i" @click="show(user)">
|
||||
<mk-avatar :user="user" class="avatar"/>
|
||||
<div class="body">
|
||||
<mk-user-name :user="user" class="name"/>
|
||||
<mk-acct :user="user" class="acct"/>
|
||||
</div>
|
||||
</button>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button inline primary @click="addUser()"><fa :icon="faPlus"/> {{ $t('addUser') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faPlus, faUsers, faSearch } from '@fortawesome/free-solid-svg-icons';
|
||||
import parseAcct from '../../../misc/acct/parse';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkInput from '../../components/ui/input.vue';
|
||||
import MkPagination from '../../components/ui/pagination.vue';
|
||||
import MkUserModerateDialog from '../../components/user-moderate-dialog.vue';
|
||||
import MkUserSelect from '../../components/user-select.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: `${this.$t('users')} | ${this.$t('instance')}`
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
MkInput,
|
||||
MkPagination,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination: {
|
||||
endpoint: 'admin/show-users',
|
||||
limit: 10,
|
||||
offsetMode: true
|
||||
},
|
||||
target: '',
|
||||
faPlus, faUsers, faSearch
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/** テキストエリアのユーザーを解決する */
|
||||
fetchUser() {
|
||||
return new Promise((res) => {
|
||||
const usernamePromise = this.$root.api('users/show', parseAcct(this.target));
|
||||
const idPromise = this.$root.api('users/show', { userId: this.target });
|
||||
let _notFound = false;
|
||||
const notFound = () => {
|
||||
if (_notFound) {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('noSuchUser')
|
||||
});
|
||||
} else {
|
||||
_notFound = true;
|
||||
}
|
||||
};
|
||||
usernamePromise.then(res).catch(e => {
|
||||
if (e.code === 'NO_SUCH_USER') {
|
||||
notFound();
|
||||
}
|
||||
});
|
||||
idPromise.then(res).catch(e => {
|
||||
notFound();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/** テキストエリアから処理対象ユーザーを設定する */
|
||||
async showUser() {
|
||||
const user = await this.fetchUser();
|
||||
this.$root.api('admin/show-user', { userId: user.id }).then(info => {
|
||||
this.show(user, info);
|
||||
});
|
||||
this.target = '';
|
||||
},
|
||||
|
||||
async addUser() {
|
||||
const { canceled: canceled1, result: username } = await this.$root.dialog({
|
||||
title: this.$t('username'),
|
||||
input: true
|
||||
});
|
||||
if (canceled1) return;
|
||||
|
||||
const { canceled: canceled2, result: password } = await this.$root.dialog({
|
||||
title: this.$t('password'),
|
||||
input: { type: 'password' }
|
||||
});
|
||||
if (canceled2) return;
|
||||
|
||||
const dialog = this.$root.dialog({
|
||||
type: 'waiting',
|
||||
iconOnly: true
|
||||
});
|
||||
|
||||
this.$root.api('admin/accounts/create', {
|
||||
username: username,
|
||||
password: password,
|
||||
}).then(res => {
|
||||
this.$refs.users.reload();
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
}).catch(e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e.id
|
||||
});
|
||||
}).finally(() => {
|
||||
dialog.close();
|
||||
});
|
||||
},
|
||||
|
||||
async show(user, info) {
|
||||
if (info == null) info = await this.$root.api('admin/show-user', { userId: user.id });
|
||||
this.$root.new(MkUserModerateDialog, {
|
||||
user: { ...user, ...info }
|
||||
});
|
||||
},
|
||||
|
||||
search() {
|
||||
this.$root.new(MkUserSelect, {}).$once('selected', user => {
|
||||
this.$root.api('admin/show-user', { userId: user.id }).then(info => {
|
||||
this.show(user, info);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-instance-users {
|
||||
> .users {
|
||||
> ._content {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
|
||||
> .users {
|
||||
> .user {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
|
||||
> .avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
padding: 8px;
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> .acct {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
46
src/client/pages/mentions.vue
Normal file
46
src/client/pages/mentions.vue
Normal file
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<div>
|
||||
<portal to="icon"><fa :icon="faAt"/></portal>
|
||||
<portal to="title">{{ $t('mentions') }}</portal>
|
||||
<x-notes :pagination="pagination" :detail="true" @before="before()" @after="after()"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faAt } from '@fortawesome/free-solid-svg-icons';
|
||||
import Progress from '../scripts/loading';
|
||||
import XNotes from '../components/notes.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('mentions') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
XNotes
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination: {
|
||||
endpoint: 'notes/mentions',
|
||||
limit: 10,
|
||||
},
|
||||
faAt
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
before() {
|
||||
Progress.start();
|
||||
},
|
||||
|
||||
after() {
|
||||
Progress.done();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
49
src/client/pages/messages.vue
Normal file
49
src/client/pages/messages.vue
Normal file
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<div>
|
||||
<portal to="icon"><fa :icon="faEnvelope"/></portal>
|
||||
<portal to="title">{{ $t('directNotes') }}</portal>
|
||||
<x-notes :pagination="pagination" :detail="true" @before="before()" @after="after()"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
|
||||
import Progress from '../scripts/loading';
|
||||
import XNotes from '../components/notes.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('directNotes') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
XNotes
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination: {
|
||||
endpoint: 'notes/mentions',
|
||||
limit: 10,
|
||||
params: () => ({
|
||||
visibility: 'specified'
|
||||
})
|
||||
},
|
||||
faEnvelope
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
before() {
|
||||
Progress.start();
|
||||
},
|
||||
|
||||
after() {
|
||||
Progress.done();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
357
src/client/pages/messaging-room.form.vue
Normal file
357
src/client/pages/messaging-room.form.vue
Normal file
|
@ -0,0 +1,357 @@
|
|||
<template>
|
||||
<div class="mk-messaging-form _panel"
|
||||
@dragover.stop="onDragover"
|
||||
@drop.stop="onDrop"
|
||||
>
|
||||
<textarea
|
||||
v-model="text"
|
||||
ref="textarea"
|
||||
@keypress="onKeypress"
|
||||
@paste="onPaste"
|
||||
:placeholder="$t('input-message-here')"
|
||||
v-autocomplete="{ model: 'text' }"
|
||||
></textarea>
|
||||
<div class="file" @click="file = null" v-if="file">{{ file.name }}</div>
|
||||
<x-uploader ref="uploader" @uploaded="onUploaded"/>
|
||||
<button class="send _button" @click="send" :disabled="!canSend || sending" :title="$t('send')">
|
||||
<template v-if="!sending"><fa :icon="faPaperPlane"/></template><template v-if="sending"><fa icon="spinner .spin"/></template>
|
||||
</button>
|
||||
<button class="attach-from-local _button" @click="chooseFile" :title="$t('attach-from-local')">
|
||||
<fa :icon="faUpload"/>
|
||||
</button>
|
||||
<button class="attach-from-drive _button" @click="chooseFileFromDrive" :title="$t('attach-from-drive')">
|
||||
<fa :icon="faCloud"/>
|
||||
</button>
|
||||
<input ref="file" type="file" @change="onChangeFile"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faPaperPlane, faUpload, faCloud } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import * as autosize from 'autosize';
|
||||
import { formatTimeString } from '../../misc/format-time-string';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
components: {
|
||||
XUploader: () => import('../components/uploader.vue').then(m => m.default),
|
||||
},
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
requird: false,
|
||||
},
|
||||
group: {
|
||||
type: Object,
|
||||
requird: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
text: null,
|
||||
file: null,
|
||||
sending: false,
|
||||
faPaperPlane, faUpload, faCloud
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
draftId(): string {
|
||||
return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
|
||||
},
|
||||
canSend(): boolean {
|
||||
return (this.text != null && this.text != '') || this.file != null;
|
||||
},
|
||||
room(): any {
|
||||
return this.$parent;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
text() {
|
||||
this.saveDraft();
|
||||
},
|
||||
file() {
|
||||
this.saveDraft();
|
||||
|
||||
if (this.room.isBottom()) {
|
||||
this.room.scrollToBottom();
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
autosize(this.$refs.textarea);
|
||||
|
||||
// 書きかけの投稿を復元
|
||||
const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftId];
|
||||
if (draft) {
|
||||
this.text = draft.data.text;
|
||||
this.file = draft.data.file;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async onPaste(e: ClipboardEvent) {
|
||||
const data = e.clipboardData;
|
||||
const items = data.items;
|
||||
|
||||
if (items.length == 1) {
|
||||
if (items[0].kind == 'file') {
|
||||
const file = items[0].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, '1')}${ext}`;
|
||||
const name = this.$store.state.settings.pasteDialog
|
||||
? await this.$root.dialog({
|
||||
title: this.$t('@.post-form.enter-file-name'),
|
||||
input: {
|
||||
default: formatted
|
||||
},
|
||||
allowEmpty: false
|
||||
}).then(({ canceled, result }) => canceled ? false : result)
|
||||
: formatted;
|
||||
if (name) this.upload(file, name);
|
||||
}
|
||||
} else {
|
||||
if (items[0].kind == 'file') {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('only-one-file-attached')
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onDragover(e) {
|
||||
const isFile = e.dataTransfer.items[0].kind == 'file';
|
||||
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
|
||||
if (isFile || isDriveFile) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
|
||||
}
|
||||
},
|
||||
|
||||
onDrop(e): void {
|
||||
// ファイルだったら
|
||||
if (e.dataTransfer.files.length == 1) {
|
||||
e.preventDefault();
|
||||
this.upload(e.dataTransfer.files[0]);
|
||||
return;
|
||||
} else if (e.dataTransfer.files.length > 1) {
|
||||
e.preventDefault();
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('only-one-file-attached')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = e.dataTransfer.getData('mk_drive_file');
|
||||
if (driveFile != null && driveFile != '') {
|
||||
this.file = JSON.parse(driveFile);
|
||||
e.preventDefault();
|
||||
}
|
||||
//#endregion
|
||||
},
|
||||
|
||||
onKeypress(e) {
|
||||
if ((e.which == 10 || e.which == 13) && e.ctrlKey && this.canSend) {
|
||||
this.send();
|
||||
}
|
||||
},
|
||||
|
||||
chooseFile() {
|
||||
(this.$refs.file as any).click();
|
||||
},
|
||||
|
||||
chooseFileFromDrive() {
|
||||
this.$chooseDriveFile({
|
||||
multiple: false
|
||||
}).then(file => {
|
||||
this.file = file;
|
||||
});
|
||||
},
|
||||
|
||||
onChangeFile() {
|
||||
this.upload((this.$refs.file as any).files[0]);
|
||||
},
|
||||
|
||||
upload(file: File, name?: string) {
|
||||
(this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name);
|
||||
},
|
||||
|
||||
onUploaded(file) {
|
||||
this.file = file;
|
||||
},
|
||||
|
||||
send() {
|
||||
this.sending = true;
|
||||
this.$root.api('messaging/messages/create', {
|
||||
userId: this.user ? this.user.id : undefined,
|
||||
groupId: this.group ? this.group.id : undefined,
|
||||
text: this.text ? this.text : undefined,
|
||||
fileId: this.file ? this.file.id : undefined
|
||||
}).then(message => {
|
||||
this.clear();
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
}).then(() => {
|
||||
this.sending = false;
|
||||
});
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.text = '';
|
||||
this.file = null;
|
||||
this.deleteDraft();
|
||||
},
|
||||
|
||||
saveDraft() {
|
||||
const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
data[this.draftId] = {
|
||||
updatedAt: new Date(),
|
||||
data: {
|
||||
text: this.text,
|
||||
file: this.file
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('message_drafts', JSON.stringify(data));
|
||||
},
|
||||
|
||||
deleteDraft() {
|
||||
const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
|
||||
|
||||
delete data[this.draftId];
|
||||
|
||||
localStorage.setItem('message_drafts', JSON.stringify(data));
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-messaging-form {
|
||||
position: relative;
|
||||
|
||||
> textarea {
|
||||
cursor: auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
height: 80px;
|
||||
margin: 0;
|
||||
padding: 16px 16px 0 16px;
|
||||
resize: none;
|
||||
font-size: 1em;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
> .file {
|
||||
padding: 8px;
|
||||
color: #444;
|
||||
background: #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> .send {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-size: 1em;
|
||||
color: #aaa;
|
||||
transition: color 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--accentDarken);
|
||||
transition: color 0s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.files {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
list-style: none;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
> li {
|
||||
display: block;
|
||||
float: left;
|
||||
margin: 4px;
|
||||
padding: 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-color: #eee;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
cursor: move;
|
||||
|
||||
&:hover {
|
||||
> .remove {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
> .remove {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: -6px;
|
||||
top: -6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attach-from-local,
|
||||
.attach-from-drive {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-size: 1em;
|
||||
font-weight: normal;
|
||||
text-decoration: none;
|
||||
color: #aaa;
|
||||
transition: color 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--accentDarken);
|
||||
transition: color 0s ease;
|
||||
}
|
||||
}
|
||||
|
||||
input[type=file] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
336
src/client/pages/messaging-room.message.vue
Normal file
336
src/client/pages/messaging-room.message.vue
Normal file
|
@ -0,0 +1,336 @@
|
|||
<template>
|
||||
<div class="thvuemwp" :data-is-me="isMe">
|
||||
<mk-avatar class="avatar" :user="message.user"/>
|
||||
<div class="content">
|
||||
<div class="balloon _panel" :data-no-text="message.text == null">
|
||||
<button class="delete-button" v-if="isMe" :title="$t('@.delete')" @click="del">
|
||||
<img src="/assets/desktop/remove.png" alt="Delete"/>
|
||||
</button>
|
||||
<div class="content" v-if="!message.isDeleted">
|
||||
<mfm class="text" v-if="message.text" ref="text" :text="message.text" :i="$store.state.i"/>
|
||||
<div class="file" v-if="message.file">
|
||||
<a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name">
|
||||
<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"
|
||||
:style="{ backgroundColor: message.file.properties.avgColor || 'transparent' }"/>
|
||||
<p v-else>{{ message.file.name }}</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content" v-else>
|
||||
<p class="is-deleted">{{ $t('deleted') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
|
||||
<footer>
|
||||
<template v-if="isGroup">
|
||||
<span class="read" v-if="message.reads.length > 0">{{ $t('messageRead') }} {{ message.reads.length }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="read" v-if="isMe && message.isRead">{{ $t('messageRead') }}</span>
|
||||
</template>
|
||||
<mk-time :time="message.createdAt"/>
|
||||
<template v-if="message.is_edited"><fa icon="pencil-alt"/></template>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import { parse } from '../../mfm/parse';
|
||||
import { unique } from '../../prelude/array';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
props: {
|
||||
message: {
|
||||
required: true
|
||||
},
|
||||
isGroup: {
|
||||
required: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isMe(): boolean {
|
||||
return this.message.userId == this.$store.state.i.id;
|
||||
},
|
||||
urls(): string[] {
|
||||
if (this.message.text) {
|
||||
const ast = parse(this.message.text);
|
||||
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 null;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
del() {
|
||||
this.$root.api('messaging/messages/delete', {
|
||||
messageId: this.message.id
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.thvuemwp {
|
||||
$me-balloon-color: var(--accent);
|
||||
|
||||
position: relative;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
|
||||
> .avatar {
|
||||
display: block;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
transition: all 0.1s ease;
|
||||
|
||||
@media (max-width: 400px) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
min-width: 0;
|
||||
|
||||
> .balloon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
min-height: 38px;
|
||||
border-radius: 16px;
|
||||
max-width: 100%;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
& + * {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> .delete-button {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
> .delete-button {
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
|
||||
> img {
|
||||
vertical-align: bottom;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
max-width: 100%;
|
||||
|
||||
> .is-deleted {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
font-size: 1em;
|
||||
color: rgba(#000, 0.5);
|
||||
}
|
||||
|
||||
> .text {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 12px 18px;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
font-size: 1em;
|
||||
color: rgba(#000, 0.8);
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
& + .file {
|
||||
> a {
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .file {
|
||||
> a {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
> p {
|
||||
background: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
> * {
|
||||
display: block;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
max-height: 512px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
> p {
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
color: #555;
|
||||
background: #ddd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .mk-url-preview {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
> footer {
|
||||
display: block;
|
||||
margin: 2px 0 0 0;
|
||||
font-size: 10px;
|
||||
color: var(--messagingRoomMessageInfo);
|
||||
|
||||
> .read {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
> [data-icon] {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not([data-is-me]) {
|
||||
|
||||
> .content {
|
||||
padding-left: 16px;
|
||||
padding-right: 32px;
|
||||
|
||||
> .balloon {
|
||||
$color: var(--panel);
|
||||
background: $color;
|
||||
|
||||
&[data-no-text] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&:not([data-no-text]):before {
|
||||
left: -14px;
|
||||
border-top: solid 8px transparent;
|
||||
border-right: solid 8px $color;
|
||||
border-bottom: solid 8px transparent;
|
||||
border-left: solid 8px transparent;
|
||||
}
|
||||
|
||||
> .content {
|
||||
> .text {
|
||||
color: var(--fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-is-me] {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
> .content {
|
||||
padding-right: 16px;
|
||||
padding-left: 32px;
|
||||
text-align: right;
|
||||
|
||||
> .balloon {
|
||||
background: $me-balloon-color;
|
||||
text-align: left;
|
||||
|
||||
&[data-no-text] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&:not([data-no-text]):before {
|
||||
right: -14px;
|
||||
left: auto;
|
||||
border-top: solid 8px transparent;
|
||||
border-right: solid 8px transparent;
|
||||
border-bottom: solid 8px transparent;
|
||||
border-left: solid 8px $me-balloon-color;
|
||||
}
|
||||
|
||||
> .content {
|
||||
|
||||
> p.is-deleted {
|
||||
color: rgba(#fff, 0.5);
|
||||
}
|
||||
|
||||
> .text {
|
||||
&, * {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
text-align: right;
|
||||
|
||||
> .read {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-is-deleted] {
|
||||
> .balloon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
395
src/client/pages/messaging-room.vue
Normal file
395
src/client/pages/messaging-room.vue
Normal file
|
@ -0,0 +1,395 @@
|
|||
<template>
|
||||
<div class="mk-messaging-room"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@drop.prevent.stop="onDrop"
|
||||
>
|
||||
<template v-if="!fetching && user">
|
||||
<portal to="title"><mk-user-name :user="user" :nowrap="false" class="name"/></portal>
|
||||
<portal to="avatar"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal>
|
||||
</template>
|
||||
<template v-if="!fetching && group">
|
||||
<portal to="title">{{ group.name }}</portal>
|
||||
</template>
|
||||
|
||||
<div class="body">
|
||||
<mk-loading v-if="fetching"/>
|
||||
<p class="empty" v-if="!fetching && messages.length == 0"><fa icon="info-circle"/>{{ user ? $t('not-talked-user') : $t('not-talked-group') }}</p>
|
||||
<p class="no-history" v-if="!fetching && messages.length > 0 && !existMoreMessages"><fa :icon="faFlag"/>{{ $t('noMoreHistory') }}</p>
|
||||
<button class="more _button" :class="{ fetching: fetchingMoreMessages }" v-if="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages">
|
||||
<template v-if="fetchingMoreMessages"><fa icon="spinner" pulse fixed-width/></template>{{ fetchingMoreMessages ? $t('@.loading') : $t('@.load-more') }}
|
||||
</button>
|
||||
<x-list class="messages" :items="messages" v-slot="{ item: message, i }" direction="up">
|
||||
<x-message :message="message" :is-group="group != null" :key="message.id" :data-index="messages.length - i"/>
|
||||
</x-list>
|
||||
</div>
|
||||
<footer>
|
||||
<transition name="fade">
|
||||
<div class="new-message" v-show="showIndicator">
|
||||
<button class="_buttonPrimary" @click="onIndicatorClick"><i><fa :icon="faArrowCircleDown"/></i>{{ $t('new-message') }}</button>
|
||||
</div>
|
||||
</transition>
|
||||
<x-form v-if="!fetching" :user="user" :group="group" ref="form"/>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faArrowCircleDown, faFlag } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import XList from '../components/date-separated-list.vue';
|
||||
import XMessage from './messaging-room.message.vue';
|
||||
import XForm from './messaging-room.form.vue';
|
||||
import { url } from '../config';
|
||||
import parseAcct from '../../misc/acct/parse';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XMessage,
|
||||
XForm,
|
||||
XList,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
fetching: true,
|
||||
user: null,
|
||||
group: null,
|
||||
fetchingMoreMessages: false,
|
||||
messages: [],
|
||||
existMoreMessages: false,
|
||||
connection: null,
|
||||
showIndicator: false,
|
||||
timer: null,
|
||||
faArrowCircleDown, faFlag
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
form(): any {
|
||||
return this.$refs.form;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route: 'fetch'
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.fetch();
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.connection.dispose();
|
||||
|
||||
window.removeEventListener('scroll', this.onScroll);
|
||||
|
||||
document.removeEventListener('visibilitychange', this.onVisibilitychange);
|
||||
},
|
||||
|
||||
methods: {
|
||||
async fetch() {
|
||||
this.fetching = true;
|
||||
if (this.$route.params.user) {
|
||||
const user = await this.$root.api('users/show', parseAcct(this.$route.params.user));
|
||||
this.user = user;
|
||||
} else {
|
||||
const group = await this.$root.api('users/groups/show', { groupId: this.$route.params.group });
|
||||
this.group = group;
|
||||
}
|
||||
|
||||
this.connection = this.$root.stream.connectToChannel('messaging', {
|
||||
otherparty: this.user ? this.user.id : undefined,
|
||||
group: this.group ? this.group.id : undefined,
|
||||
});
|
||||
|
||||
this.connection.on('message', this.onMessage);
|
||||
this.connection.on('read', this.onRead);
|
||||
this.connection.on('deleted', this.onDeleted);
|
||||
|
||||
window.addEventListener('scroll', this.onScroll, { passive: true });
|
||||
|
||||
document.addEventListener('visibilitychange', this.onVisibilitychange);
|
||||
|
||||
this.fetchMessages().then(() => {
|
||||
this.fetching = false;
|
||||
this.scrollToBottom();
|
||||
});
|
||||
},
|
||||
|
||||
onDragover(e) {
|
||||
const isFile = e.dataTransfer.items[0].kind == 'file';
|
||||
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
|
||||
|
||||
if (isFile || isDriveFile) {
|
||||
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
|
||||
} else {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
onDrop(e): void {
|
||||
// ファイルだったら
|
||||
if (e.dataTransfer.files.length == 1) {
|
||||
this.form.upload(e.dataTransfer.files[0]);
|
||||
return;
|
||||
} else if (e.dataTransfer.files.length > 1) {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('only-one-file-attached')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = e.dataTransfer.getData('mk_drive_file');
|
||||
if (driveFile != null && driveFile != '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
this.form.file = file;
|
||||
}
|
||||
//#endregion
|
||||
},
|
||||
|
||||
fetchMessages() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const max = this.existMoreMessages ? 20 : 10;
|
||||
|
||||
this.$root.api('messaging/messages', {
|
||||
userId: this.user ? this.user.id : undefined,
|
||||
groupId: this.group ? this.group.id : undefined,
|
||||
limit: max + 1,
|
||||
untilId: this.existMoreMessages ? this.messages[0].id : undefined
|
||||
}).then(messages => {
|
||||
if (messages.length == max + 1) {
|
||||
this.existMoreMessages = true;
|
||||
messages.pop();
|
||||
} else {
|
||||
this.existMoreMessages = false;
|
||||
}
|
||||
|
||||
this.messages.unshift.apply(this.messages, messages.reverse());
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
fetchMoreMessages() {
|
||||
this.fetchingMoreMessages = true;
|
||||
this.fetchMessages().then(() => {
|
||||
this.fetchingMoreMessages = false;
|
||||
});
|
||||
},
|
||||
|
||||
onMessage(message) {
|
||||
// サウンドを再生する
|
||||
if (this.$store.state.device.enableSounds) {
|
||||
const sound = new Audio(`${url}/assets/message.mp3`);
|
||||
sound.volume = this.$store.state.device.soundVolume;
|
||||
sound.play();
|
||||
}
|
||||
|
||||
const isBottom = this.isBottom();
|
||||
|
||||
this.messages.push(message);
|
||||
if (message.userId != this.$store.state.i.id && !document.hidden) {
|
||||
this.connection.send('read', {
|
||||
id: message.id
|
||||
});
|
||||
}
|
||||
|
||||
if (isBottom) {
|
||||
// Scroll to bottom
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
} else if (message.userId != this.$store.state.i.id) {
|
||||
// Notify
|
||||
this.notifyNewMessage();
|
||||
}
|
||||
},
|
||||
|
||||
onRead(x) {
|
||||
if (this.user) {
|
||||
if (!Array.isArray(x)) x = [x];
|
||||
for (const id of x) {
|
||||
if (this.messages.some(x => x.id == id)) {
|
||||
const exist = this.messages.map(x => x.id).indexOf(id);
|
||||
this.messages[exist].isRead = true;
|
||||
}
|
||||
}
|
||||
} else if (this.group) {
|
||||
for (const id of x.ids) {
|
||||
if (this.messages.some(x => x.id == id)) {
|
||||
const exist = this.messages.map(x => x.id).indexOf(id);
|
||||
this.messages[exist].reads.push(x.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onDeleted(id) {
|
||||
const msg = this.messages.find(m => m.id === id);
|
||||
if (msg) {
|
||||
this.messages = this.messages.filter(m => m.id !== msg.id);
|
||||
}
|
||||
},
|
||||
|
||||
isBottom() {
|
||||
const asobi = 64;
|
||||
const current = this.isNaked
|
||||
? window.scrollY + window.innerHeight
|
||||
: this.$el.scrollTop + this.$el.offsetHeight;
|
||||
const max = this.isNaked
|
||||
? document.body.offsetHeight
|
||||
: this.$el.scrollHeight;
|
||||
return current > (max - asobi);
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
window.scroll(0, document.body.offsetHeight);
|
||||
},
|
||||
|
||||
onIndicatorClick() {
|
||||
this.showIndicator = false;
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
notifyNewMessage() {
|
||||
this.showIndicator = true;
|
||||
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
this.showIndicator = false;
|
||||
}, 4000);
|
||||
},
|
||||
|
||||
onScroll() {
|
||||
const el = this.isNaked ? window.document.documentElement : this.$el;
|
||||
const current = el.scrollTop + el.clientHeight;
|
||||
if (current > el.scrollHeight - 1) {
|
||||
this.showIndicator = false;
|
||||
}
|
||||
},
|
||||
|
||||
onVisibilitychange() {
|
||||
if (document.hidden) return;
|
||||
for (const message of this.messages) {
|
||||
if (message.userId !== this.$store.state.i.id && !message.isRead) {
|
||||
this.connection.send('read', {
|
||||
id: message.id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-messaging-room {
|
||||
|
||||
> .body {
|
||||
width: 100%;
|
||||
|
||||
> .empty {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 16px 8px 8px 8px;
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.5;
|
||||
|
||||
[data-icon] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .no-history {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
color: var(--messagingRoomInfo);
|
||||
opacity: 0.5;
|
||||
|
||||
[data-icon] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .more {
|
||||
display: block;
|
||||
margin: 16px auto;
|
||||
padding: 0 12px;
|
||||
line-height: 24px;
|
||||
color: #fff;
|
||||
background: rgba(#000, 0.3);
|
||||
border-radius: 12px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(#000, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(#000, 0.5);
|
||||
}
|
||||
|
||||
&.fetching {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .messages {
|
||||
> ::v-deep * {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
width: 100%;
|
||||
|
||||
> .new-message {
|
||||
position: absolute;
|
||||
top: -48px;
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
text-align: center;
|
||||
|
||||
> button {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0 12px 0 30px;
|
||||
line-height: 32px;
|
||||
font-size: 12px;
|
||||
border-radius: 16px;
|
||||
|
||||
> i {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 10px;
|
||||
line-height: 32px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.fade-enter, .fade-leave-to {
|
||||
transition: opacity 0.5s;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
328
src/client/pages/messaging.vue
Normal file
328
src/client/pages/messaging.vue
Normal file
|
@ -0,0 +1,328 @@
|
|||
<template>
|
||||
<div class="mk-messaging">
|
||||
<portal to="icon"><fa :icon="faComments"/></portal>
|
||||
<portal to="title">{{ $t('messaging') }}</portal>
|
||||
|
||||
<mk-button @click="start" primary class="start"><fa :icon="faPlus"/> {{ $t('startMessaging') }}</mk-button>
|
||||
|
||||
<sequential-entrance class="history" v-if="messages.length > 0" :delay="30">
|
||||
<router-link v-for="(message, i) in messages"
|
||||
class="message _panel"
|
||||
:to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
|
||||
:data-is-me="isMe(message)"
|
||||
:data-is-read="message.groupId ? message.reads.includes($store.state.i.id) : message.isRead"
|
||||
:data-index="i"
|
||||
:key="message.id"
|
||||
>
|
||||
<div>
|
||||
<mk-avatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user"/>
|
||||
<header v-if="message.groupId">
|
||||
<span class="name">{{ message.group.name }}</span>
|
||||
<mk-time :time="message.createdAt"/>
|
||||
</header>
|
||||
<header v-else>
|
||||
<span class="name"><mk-user-name :user="isMe(message) ? message.recipient : message.user"/></span>
|
||||
<span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span>
|
||||
<mk-time :time="message.createdAt"/>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</sequential-entrance>
|
||||
<p class="no-history" v-if="!fetching && messages.length == 0">{{ $t('no-history') }}</p>
|
||||
<p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faUser, faUsers, faComments, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import getAcct from '../../misc/acct/render';
|
||||
import MkButton from '../components/ui/button.vue';
|
||||
import MkUserSelect from '../components/user-select.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkButton
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
fetching: true,
|
||||
moreFetching: false,
|
||||
messages: [],
|
||||
connection: null,
|
||||
faUser, faUsers, faComments, faPlus
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.connection = this.$root.stream.useSharedConnection('messagingIndex');
|
||||
|
||||
this.connection.on('message', this.onMessage);
|
||||
this.connection.on('read', this.onRead);
|
||||
|
||||
this.$root.api('messaging/history', { group: false }).then(userMessages => {
|
||||
this.$root.api('messaging/history', { group: true }).then(groupMessages => {
|
||||
const messages = userMessages.concat(groupMessages);
|
||||
messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
this.messages = messages;
|
||||
this.fetching = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.connection.dispose();
|
||||
},
|
||||
|
||||
methods: {
|
||||
getAcct,
|
||||
|
||||
isMe(message) {
|
||||
return message.userId == this.$store.state.i.id;
|
||||
},
|
||||
|
||||
onMessage(message) {
|
||||
if (message.recipientId) {
|
||||
this.messages = this.messages.filter(m => !(
|
||||
(m.recipientId == message.recipientId && m.userId == message.userId) ||
|
||||
(m.recipientId == message.userId && m.userId == message.recipientId)));
|
||||
|
||||
this.messages.unshift(message);
|
||||
} else if (message.groupId) {
|
||||
this.messages = this.messages.filter(m => m.groupId !== message.groupId);
|
||||
this.messages.unshift(message);
|
||||
}
|
||||
},
|
||||
|
||||
onRead(ids) {
|
||||
for (const id of ids) {
|
||||
const found = this.messages.find(m => m.id == id);
|
||||
if (found) {
|
||||
if (found.recipientId) {
|
||||
found.isRead = true;
|
||||
} else if (found.groupId) {
|
||||
found.reads.push(this.$store.state.i.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
start(ev) {
|
||||
this.$root.menu({
|
||||
items: [{
|
||||
text: this.$t('withUser'),
|
||||
action: () => { this.startUser() }
|
||||
}, {
|
||||
text: this.$t('withGroup'),
|
||||
action: () => { this.startGroup() }
|
||||
}],
|
||||
noCenter: true,
|
||||
source: ev.currentTarget || ev.target,
|
||||
});
|
||||
},
|
||||
|
||||
async startUser() {
|
||||
this.$root.new(MkUserSelect, {}).$once('selected', user => {
|
||||
this.$router.push(`/my/messaging/${getAcct(user)}`);
|
||||
});
|
||||
},
|
||||
|
||||
async startGroup() {
|
||||
const groups1 = await this.$root.api('users/groups/owned');
|
||||
const groups2 = await this.$root.api('users/groups/joined');
|
||||
const { canceled, result: group } = await this.$root.dialog({
|
||||
type: null,
|
||||
title: this.$t('select-group'),
|
||||
select: {
|
||||
items: groups1.concat(groups2).map(group => ({
|
||||
value: group, text: group.name
|
||||
}))
|
||||
},
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
this.navigateGroup(group);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-messaging {
|
||||
|
||||
> .start {
|
||||
margin: 0 auto 16px auto;
|
||||
}
|
||||
|
||||
> .history {
|
||||
> .message {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.avatar {
|
||||
filter: saturate(200%);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
}
|
||||
|
||||
&[data-is-read],
|
||||
&[data-is-me] {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:not([data-is-me]):not([data-is-read]) {
|
||||
> div {
|
||||
background-image: url("/assets/unread.svg");
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 center;
|
||||
}
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
> div {
|
||||
padding: 20px 30px;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
> header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
> .name {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
> .username {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
> .mk-time {
|
||||
margin: 0 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
float: left;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
margin: 0 16px 0 0;
|
||||
border-radius: 8px;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
> .body {
|
||||
|
||||
> .text {
|
||||
display: block;
|
||||
margin: 0 0 0 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
font-size: 1.1em;
|
||||
color: var(--faceText);
|
||||
|
||||
.me {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
> .image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 512px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .no-history {
|
||||
margin: 0;
|
||||
padding: 2em 1em;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
> .fetching {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
> .search {
|
||||
> .result {
|
||||
> .users {
|
||||
> li {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .history {
|
||||
> .message {
|
||||
&:not([data-is-me]):not([data-is-read]) {
|
||||
> div {
|
||||
background-image: none;
|
||||
border-left: solid 4px #3aa2dc;
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
|
||||
> .avatar {
|
||||
margin: 0 12px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
174
src/client/pages/my-antennas/index.antenna.vue
Normal file
174
src/client/pages/my-antennas/index.antenna.vue
Normal file
|
@ -0,0 +1,174 @@
|
|||
<template>
|
||||
<div class="shaynizk _section">
|
||||
<div class="_title" v-if="antenna.name">{{ antenna.name }}</div>
|
||||
<div class="_content body">
|
||||
<mk-input v-model="name" style="margin-top: 8px;">
|
||||
<span>{{ $t('name') }}</span>
|
||||
</mk-input>
|
||||
<mk-select v-model="src">
|
||||
<template #label>{{ $t('antennaSource') }}</template>
|
||||
<option value="all">{{ $t('_antennaSources.all') }}</option>
|
||||
<option value="home">{{ $t('_antennaSources.homeTimeline') }}</option>
|
||||
<option value="users">{{ $t('_antennaSources.users') }}</option>
|
||||
<option value="list">{{ $t('_antennaSources.userList') }}</option>
|
||||
</mk-select>
|
||||
<mk-select v-model="userListId" v-if="src === 'list'">
|
||||
<template #label>{{ $t('userList') }}</template>
|
||||
<option v-for="list in userLists" :value="list.id" :key="list.id">{{ list.name }}</option>
|
||||
</mk-select>
|
||||
<mk-textarea v-model="users" v-if="src === 'users'">
|
||||
<span>{{ $t('users') }}</span>
|
||||
<template #desc>{{ $t('antennaUsersDescription') }} <button class="_textButton" @click="addUser">{{ $t('addUser') }}</button></template>
|
||||
</mk-textarea>
|
||||
<mk-switch v-model="withReplies">{{ $t('withReplies') }}</mk-switch>
|
||||
<mk-textarea v-model="keywords">
|
||||
<span>{{ $t('antennaKeywords') }}</span>
|
||||
<template #desc>{{ $t('antennaKeywordsDescription') }}</template>
|
||||
</mk-textarea>
|
||||
<mk-switch v-model="caseSensitive">{{ $t('caseSensitive') }}</mk-switch>
|
||||
<mk-switch v-model="withFile">{{ $t('withFileAntenna') }}</mk-switch>
|
||||
<mk-switch v-model="notify">{{ $t('notifyAntenna') }}</mk-switch>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button inline @click="saveAntenna()" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
<mk-button inline @click="deleteAntenna()" v-if="antenna.id != null"><fa :icon="faTrash"/> {{ $t('delete') }}</mk-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faSave, faTrash } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../i18n';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkInput from '../../components/ui/input.vue';
|
||||
import MkTextarea from '../../components/ui/textarea.vue';
|
||||
import MkSelect from '../../components/ui/select.vue';
|
||||
import MkSwitch from '../../components/ui/switch.vue';
|
||||
import MkUserSelect from '../../components/user-select.vue';
|
||||
import getAcct from '../../../misc/acct/render';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkButton, MkInput, MkTextarea, MkSelect, MkSwitch
|
||||
},
|
||||
|
||||
props: {
|
||||
antenna: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
name: '',
|
||||
src: '',
|
||||
userListId: null,
|
||||
users: '',
|
||||
keywords: '',
|
||||
caseSensitive: false,
|
||||
withReplies: false,
|
||||
withFile: false,
|
||||
notify: false,
|
||||
userLists: null,
|
||||
faSave, faTrash
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
async src() {
|
||||
if (this.src === 'list' && this.userLists === null) {
|
||||
this.userLists = await this.$root.api('users/lists/list');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.name = this.antenna.name;
|
||||
this.src = this.antenna.src;
|
||||
this.userListId = this.antenna.userListId;
|
||||
this.users = this.antenna.users.join('\n');
|
||||
this.keywords = this.antenna.keywords.map(x => x.join(' ')).join('\n');
|
||||
this.caseSensitive = this.antenna.caseSensitive;
|
||||
this.withReplies = this.antenna.withReplies;
|
||||
this.withFile = this.antenna.withFile;
|
||||
this.notify = this.antenna.notify;
|
||||
},
|
||||
|
||||
methods: {
|
||||
async saveAntenna() {
|
||||
if (this.antenna.id == null) {
|
||||
await this.$root.api('antennas/create', {
|
||||
name: this.name,
|
||||
src: this.src,
|
||||
userListId: this.userListId,
|
||||
withReplies: this.withReplies,
|
||||
withFile: this.withFile,
|
||||
notify: this.notify,
|
||||
caseSensitive: this.caseSensitive,
|
||||
users: this.users.trim().split('\n').map(x => x.trim()),
|
||||
keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' '))
|
||||
});
|
||||
this.$emit('created');
|
||||
} else {
|
||||
await this.$root.api('antennas/update', {
|
||||
antennaId: this.antenna.id,
|
||||
name: this.name,
|
||||
src: this.src,
|
||||
userListId: this.userListId,
|
||||
withReplies: this.withReplies,
|
||||
withFile: this.withFile,
|
||||
notify: this.notify,
|
||||
caseSensitive: this.caseSensitive,
|
||||
users: this.users.trim().split('\n').map(x => x.trim()),
|
||||
keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' '))
|
||||
});
|
||||
}
|
||||
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
},
|
||||
|
||||
async deleteAntenna() {
|
||||
const { canceled } = await this.$root.dialog({
|
||||
type: 'warning',
|
||||
text: this.$t('removeAreYouSure', { x: this.antenna.name }),
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
await this.$root.api('antennas/delete', {
|
||||
antennaId: this.antenna.id,
|
||||
});
|
||||
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
this.$emit('deleted');
|
||||
},
|
||||
|
||||
addUser() {
|
||||
this.$root.new(MkUserSelect, {}).$once('selected', user => {
|
||||
this.users = this.users.trim();
|
||||
this.users += '\n@' + getAcct(user);
|
||||
this.users = this.users.trim();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.shaynizk {
|
||||
> .body {
|
||||
max-height: 250px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
80
src/client/pages/my-antennas/index.vue
Normal file
80
src/client/pages/my-antennas/index.vue
Normal file
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<div class="ieepwinx">
|
||||
<portal to="icon"><fa :icon="faSatellite"/></portal>
|
||||
<portal to="title">{{ $t('manageAntennas') }}</portal>
|
||||
|
||||
<mk-button @click="create" primary class="add"><fa :icon="faPlus"/> {{ $t('createAntenna') }}</mk-button>
|
||||
|
||||
<x-antenna v-if="draft" :antenna="draft" @created="onAntennaCreated" style="margin-bottom: var(--margin);"/>
|
||||
|
||||
<mk-pagination :pagination="pagination" #default="{items}" class="antennas" ref="list">
|
||||
<x-antenna v-for="(antenna, i) in items" :key="antenna.id" :data-index="i" :antenna="antenna" @created="onAntennaDeleted"/>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faSatellite, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkPagination from '../../components/ui/pagination.vue';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import XAntenna from './index.antenna.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('manageAntennas') as string,
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkPagination,
|
||||
MkButton,
|
||||
XAntenna,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination: {
|
||||
endpoint: 'antennas/list',
|
||||
limit: 10,
|
||||
},
|
||||
draft: null,
|
||||
faSatellite, faPlus
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
create() {
|
||||
this.draft = {
|
||||
name: '',
|
||||
src: 'all',
|
||||
userListId: null,
|
||||
users: [],
|
||||
keywords: [],
|
||||
withReplies: false,
|
||||
caseSensitive: false,
|
||||
withFile: false,
|
||||
notify: false
|
||||
};
|
||||
},
|
||||
|
||||
onAntennaCreated() {
|
||||
this.$refs.list.reload();
|
||||
this.draft = null;
|
||||
},
|
||||
|
||||
onAntennaDeleted() {
|
||||
this.$refs.list.reload();
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ieepwinx {
|
||||
> .add {
|
||||
margin: 0 auto 16px auto;
|
||||
}
|
||||
}
|
||||
</style>
|
75
src/client/pages/my-lists/index.vue
Normal file
75
src/client/pages/my-lists/index.vue
Normal file
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<div class="qkcjvfiv">
|
||||
<portal to="icon"><fa :icon="faListUl"/></portal>
|
||||
<portal to="title">{{ $t('manageLists') }}</portal>
|
||||
|
||||
<mk-button @click="create" primary class="add"><fa :icon="faPlus"/> {{ $t('createList') }}</mk-button>
|
||||
|
||||
<mk-pagination :pagination="pagination" #default="{items}" class="lists" ref="list">
|
||||
<div class="list _panel" v-for="(list, i) in items" :key="list.id" :data-index="i">
|
||||
<router-link :to="`/lists/${ list.id }`">{{ list.name }}</router-link>
|
||||
</div>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faListUl, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkPagination from '../../components/ui/pagination.vue';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('manageLists') as string,
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkPagination,
|
||||
MkButton,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination: {
|
||||
endpoint: 'users/lists/list',
|
||||
limit: 10,
|
||||
},
|
||||
faListUl, faPlus
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
async create() {
|
||||
const { canceled, result: name } = await this.$root.dialog({
|
||||
title: this.$t('enterListName'),
|
||||
input: true
|
||||
});
|
||||
if (canceled) return;
|
||||
await this.$root.api('users/lists/create', { name: name });
|
||||
this.$refs.list.reload();
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.qkcjvfiv {
|
||||
> .add {
|
||||
margin: 0 auto 16px auto;
|
||||
}
|
||||
|
||||
> .lists {
|
||||
> .list {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
163
src/client/pages/my-lists/list.vue
Normal file
163
src/client/pages/my-lists/list.vue
Normal file
|
@ -0,0 +1,163 @@
|
|||
<template>
|
||||
<div class="mk-list-page">
|
||||
<transition name="zoom" mode="out-in">
|
||||
<div v-if="list" :key="list.id" class="_section list">
|
||||
<div class="_title">{{ list.name }}</div>
|
||||
<div class="_content">
|
||||
<div class="users">
|
||||
<div class="user" v-for="(user, i) in users" :key="user.id" :data-index="i">
|
||||
<mk-avatar :user="user" class="avatar"/>
|
||||
<div class="body">
|
||||
<mk-user-name :user="user" class="name"/>
|
||||
<mk-acct :user="user" class="acct"/>
|
||||
</div>
|
||||
<div class="action">
|
||||
<button class="_button" @click="removeUser(user)"><fa :icon="faTimes"/></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button inline @click="renameList()">{{ $t('renameList') }}</mk-button>
|
||||
<mk-button inline @click="deleteList()">{{ $t('deleteList') }}</mk-button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../i18n';
|
||||
import Progress from '../../scripts/loading';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.list ? `${this.list.name} | ${this.$t('manageLists')}` : this.$t('manageLists')
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
MkButton
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
list: null,
|
||||
users: [],
|
||||
faTimes
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route: 'fetch'
|
||||
},
|
||||
|
||||
created() {
|
||||
this.fetch();
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetch() {
|
||||
Progress.start();
|
||||
this.$root.api('users/lists/show', {
|
||||
listId: this.$route.params.list
|
||||
}).then(list => {
|
||||
this.list = list;
|
||||
this.$root.api('users/show', {
|
||||
userIds: this.list.userIds
|
||||
}).then(users => {
|
||||
this.users = users;
|
||||
Progress.done();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
removeUser(user) {
|
||||
this.$root.api('users/lists/pull', {
|
||||
listId: this.list.id,
|
||||
userId: user.id
|
||||
}).then(() => {
|
||||
this.users = this.users.filter(x => x.id !== user.id);
|
||||
});
|
||||
},
|
||||
|
||||
async renameList() {
|
||||
const { canceled, result: name } = await this.$root.dialog({
|
||||
title: this.$t('enterListName'),
|
||||
input: {
|
||||
default: this.list.name
|
||||
}
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
await this.$root.api('users/lists/update', {
|
||||
listId: this.list.id,
|
||||
name: name
|
||||
});
|
||||
|
||||
this.list.name = name;
|
||||
},
|
||||
|
||||
async deleteList() {
|
||||
const { canceled } = await this.$root.dialog({
|
||||
type: 'warning',
|
||||
text: this.$t('deleteListConfirm', { list: this.list.name }),
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
await this.$root.api('users/lists/delete', {
|
||||
listId: this.list.id
|
||||
});
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
this.$router.push('/my/lists');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-list-page {
|
||||
> .list {
|
||||
> ._content {
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
|
||||
> .users {
|
||||
> .user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> .avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> .acct {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
55
src/client/pages/note.vue
Normal file
55
src/client/pages/note.vue
Normal file
|
@ -0,0 +1,55 @@
|
|||
<template>
|
||||
<div class="mk-note-page">
|
||||
<transition name="zoom" mode="out-in">
|
||||
<x-note v-if="note" :note="note" :key="note.id" :detail="true"/>
|
||||
<div v-else-if="error">
|
||||
<mk-error @retry="fetch()"/>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../i18n';
|
||||
import Progress from '../scripts/loading';
|
||||
import XNote from '../components/note.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('note') as string
|
||||
};
|
||||
},
|
||||
components: {
|
||||
XNote
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
note: null,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
$route: 'fetch'
|
||||
},
|
||||
created() {
|
||||
this.fetch();
|
||||
},
|
||||
methods: {
|
||||
fetch() {
|
||||
Progress.start();
|
||||
this.$root.api('notes/show', {
|
||||
noteId: this.$route.params.note
|
||||
}).then(note => {
|
||||
this.note = note;
|
||||
}).catch(e => {
|
||||
this.error = e;
|
||||
}).finally(() => {
|
||||
Progress.done();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
83
src/client/pages/page-editor/els/page-editor.el.button.vue
Normal file
83
src/client/pages/page-editor/els/page-editor.el.button.vue
Normal file
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<x-container @remove="() => $emit('remove')" :draggable="true">
|
||||
<template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.button') }}</template>
|
||||
|
||||
<section class="xfhsjczc">
|
||||
<mk-input v-model="value.text"><span>{{ $t('_pages.blocks._button.text') }}</span></mk-input>
|
||||
<mk-switch v-model="value.primary"><span>{{ $t('_pages.blocks._button.colored') }}</span></mk-switch>
|
||||
<mk-select v-model="value.action">
|
||||
<template #label>{{ $t('_pages.blocks._button.action') }}</template>
|
||||
<option value="dialog">{{ $t('_pages.blocks._button._action.dialog') }}</option>
|
||||
<option value="resetRandom">{{ $t('_pages.blocks._button._action.resetRandom') }}</option>
|
||||
<option value="pushEvent">{{ $t('_pages.blocks._button._action.pushEvent') }}</option>
|
||||
</mk-select>
|
||||
<template v-if="value.action === 'dialog'">
|
||||
<mk-input v-model="value.content"><span>{{ $t('_pages.blocks._button._action._dialog.content') }}</span></mk-input>
|
||||
</template>
|
||||
<template v-else-if="value.action === 'pushEvent'">
|
||||
<mk-input v-model="value.event"><span>{{ $t('_pages.blocks._button._action._pushEvent.event') }}</span></mk-input>
|
||||
<mk-input v-model="value.message"><span>{{ $t('_pages.blocks._button._action._pushEvent.message') }}</span></mk-input>
|
||||
<mk-select v-model="value.var">
|
||||
<template #label>{{ $t('_pages.blocks._button._action._pushEvent.variable') }}</template>
|
||||
<option :value="null">{{ $t('_pages.blocks._button._action._pushEvent.no-variable') }}</option>
|
||||
<option v-for="v in aiScript.getVarsByType()" :value="v.name">{{ v.name }}</option>
|
||||
<optgroup :label="$t('_pages.script.pageVariables')">
|
||||
<option v-for="v in aiScript.getPageVarsByType()" :value="v">{{ v }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="$t('_pages.script.enviromentVariables')">
|
||||
<option v-for="v in aiScript.getEnvVarsByType()" :value="v">{{ v }}</option>
|
||||
</optgroup>
|
||||
</mk-select>
|
||||
</template>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faBolt } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../../i18n';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
import MkSelect from '../../../components/ui/select.vue';
|
||||
import MkInput from '../../../components/ui/input.vue';
|
||||
import MkSwitch from '../../../components/ui/switch.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XContainer, MkSelect, MkInput, MkSwitch
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
aiScript: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faBolt
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.value.text == null) Vue.set(this.value, 'text', '');
|
||||
if (this.value.action == null) Vue.set(this.value, 'action', 'dialog');
|
||||
if (this.value.content == null) Vue.set(this.value, 'content', null);
|
||||
if (this.value.event == null) Vue.set(this.value, 'event', null);
|
||||
if (this.value.message == null) Vue.set(this.value, 'message', null);
|
||||
if (this.value.primary == null) Vue.set(this.value, 'primary', false);
|
||||
if (this.value.var == null) Vue.set(this.value, 'var', null);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.xfhsjczc {
|
||||
padding: 0 16px 0 16px;
|
||||
}
|
||||
</style>
|
43
src/client/pages/page-editor/els/page-editor.el.counter.vue
Normal file
43
src/client/pages/page-editor/els/page-editor.el.counter.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<x-container @remove="() => $emit('remove')" :draggable="true">
|
||||
<template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.counter') }}</template>
|
||||
|
||||
<section style="padding: 0 16px 0 16px;">
|
||||
<mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._counter.name') }}</span></mk-input>
|
||||
<mk-input v-model="value.text"><span>{{ $t('_pages.blocks._counter.text') }}</span></mk-input>
|
||||
<mk-input v-model="value.inc" type="number"><span>{{ $t('_pages.blocks._counter.inc') }}</span></mk-input>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../../i18n';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
import MkInput from '../../../components/ui/input.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XContainer, MkInput
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faBolt, faMagic
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.value.name == null) Vue.set(this.value, 'name', '');
|
||||
},
|
||||
});
|
||||
</script>
|
91
src/client/pages/page-editor/els/page-editor.el.if.vue
Normal file
91
src/client/pages/page-editor/els/page-editor.el.if.vue
Normal file
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<x-container @remove="() => $emit('remove')" :draggable="true">
|
||||
<template #header><fa :icon="faQuestion"/> {{ $t('_pages.blocks.if') }}</template>
|
||||
<template #func>
|
||||
<button @click="add()">
|
||||
<fa :icon="faPlus"/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<section class="romcojzs">
|
||||
<mk-select v-model="value.var">
|
||||
<template #label>{{ $t('_pages.blocks._if.variable') }}</template>
|
||||
<option v-for="v in aiScript.getVarsByType('boolean')" :value="v.name">{{ v.name }}</option>
|
||||
<optgroup :label="$t('_pages.script.pageVariables')">
|
||||
<option v-for="v in aiScript.getPageVarsByType('boolean')" :value="v">{{ v }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="$t('_pages.script.enviromentVariables')">
|
||||
<option v-for="v in aiScript.getEnvVarsByType('boolean')" :value="v">{{ v }}</option>
|
||||
</optgroup>
|
||||
</mk-select>
|
||||
|
||||
<x-blocks class="children" v-model="value.children" :ai-script="aiScript"/>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { faPlus, faQuestion } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../../i18n';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
import MkSelect from '../../../components/ui/select.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XContainer, MkSelect
|
||||
},
|
||||
|
||||
inject: ['getPageBlockList'],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
aiScript: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faPlus, faQuestion
|
||||
};
|
||||
},
|
||||
|
||||
beforeCreate() {
|
||||
this.$options.components.XBlocks = require('../page-editor.blocks.vue').default
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.value.children == null) Vue.set(this.value, 'children', []);
|
||||
if (this.value.var === undefined) Vue.set(this.value, 'var', null);
|
||||
},
|
||||
|
||||
methods: {
|
||||
async add() {
|
||||
const { canceled, result: type } = await this.$root.dialog({
|
||||
type: null,
|
||||
title: this.$t('choose-block'),
|
||||
select: {
|
||||
groupedItems: this.getPageBlockList()
|
||||
},
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
const id = uuid();
|
||||
this.value.children.push({ id, type });
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.romcojzs {
|
||||
padding: 0 16px 16px 16px;
|
||||
}
|
||||
</style>
|
78
src/client/pages/page-editor/els/page-editor.el.image.vue
Normal file
78
src/client/pages/page-editor/els/page-editor.el.image.vue
Normal file
|
@ -0,0 +1,78 @@
|
|||
<template>
|
||||
<x-container @remove="() => $emit('remove')" :draggable="true">
|
||||
<template #header><fa :icon="faImage"/> {{ $t('_pages.blocks.image') }}</template>
|
||||
<template #func>
|
||||
<button @click="choose()">
|
||||
<fa :icon="faFolderOpen"/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<section class="oyyftmcf">
|
||||
<mk-file-thumbnail class="preview" v-if="file" :file="file" :detail="true" fit="contain" @click="choose()"/>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faImage, faFolderOpen } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../../../i18n';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
import MkFileThumbnail from '../../../components/drive-file-thumbnail.vue';
|
||||
import { selectDriveFile } from '../../../scripts/select-drive-file';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XContainer, MkFileThumbnail
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
file: null,
|
||||
faPencilAlt, faImage, faFolderOpen
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.value.fileId === undefined) Vue.set(this.value, 'fileId', null);
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.value.fileId == null) {
|
||||
this.choose();
|
||||
} else {
|
||||
this.$root.api('drive/files/show', {
|
||||
fileId: this.value.fileId
|
||||
}).then(file => {
|
||||
this.file = file;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async choose() {
|
||||
selectDriveFile(this.$root, false).then(file => {
|
||||
this.file = file;
|
||||
this.value.fileId = file.id;
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.oyyftmcf {
|
||||
> .preview {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<x-container @remove="() => $emit('remove')" :draggable="true">
|
||||
<template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.numberInput') }}</template>
|
||||
|
||||
<section style="padding: 0 16px 0 16px;">
|
||||
<mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._numberInput.name') }}</span></mk-input>
|
||||
<mk-input v-model="value.text"><span>{{ $t('_pages.blocks._numberInput.text') }}</span></mk-input>
|
||||
<mk-input v-model="value.default" type="number"><span>{{ $t('_pages.blocks._numberInput.default') }}</span></mk-input>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../../i18n';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
import MkInput from '../../../components/ui/input.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XContainer, MkInput
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faBolt, faMagic
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.value.name == null) Vue.set(this.value, 'name', '');
|
||||
},
|
||||
});
|
||||
</script>
|
41
src/client/pages/page-editor/els/page-editor.el.post.vue
Normal file
41
src/client/pages/page-editor/els/page-editor.el.post.vue
Normal file
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<x-container @remove="() => $emit('remove')" :draggable="true">
|
||||
<template #header><fa :icon="faPaperPlane"/> {{ $t('_pages.blocks.post') }}</template>
|
||||
|
||||
<section style="padding: 0 16px 16px 16px;">
|
||||
<mk-textarea v-model="value.text">{{ $t('_pages.blocks._post.text') }}</mk-textarea>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faPaperPlane } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../../../i18n';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
import MkTextarea from '../../../components/ui/textarea.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XContainer, MkTextarea
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faPaperPlane
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.value.text == null) Vue.set(this.value, 'text', '');
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<x-container @remove="() => $emit('remove')" :draggable="true">
|
||||
<template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.radioButton') }}</template>
|
||||
|
||||
<section style="padding: 0 16px 16px 16px;">
|
||||
<mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._radioButton.name') }}</span></mk-input>
|
||||
<mk-input v-model="value.title"><span>{{ $t('_pages.blocks._radioButton.title') }}</span></mk-input>
|
||||
<mk-textarea v-model="values"><span>{{ $t('_pages.blocks._radioButton.values') }}</span></mk-textarea>
|
||||
<mk-input v-model="value.default"><span>{{ $t('_pages.blocks._radioButton.default') }}</span></mk-input>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../../i18n';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
import MkTextarea from '../../../components/ui/textarea.vue';
|
||||
import MkInput from '../../../components/ui/input.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
components: {
|
||||
XContainer, MkTextarea, MkInput
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
values: '',
|
||||
faBolt, faMagic
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
values() {
|
||||
Vue.set(this.value, 'values', this.values.split('\n'));
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.value.name == null) Vue.set(this.value, 'name', '');
|
||||
if (this.value.title == null) Vue.set(this.value, 'title', '');
|
||||
if (this.value.values == null) Vue.set(this.value, 'values', []);
|
||||
this.values = this.value.values.join('\n');
|
||||
},
|
||||
});
|
||||
</script>
|
104
src/client/pages/page-editor/els/page-editor.el.section.vue
Normal file
104
src/client/pages/page-editor/els/page-editor.el.section.vue
Normal file
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<x-container @remove="() => $emit('remove')" :draggable="true">
|
||||
<template #header><fa :icon="faStickyNote"/> {{ value.title }}</template>
|
||||
<template #func>
|
||||
<button @click="rename()">
|
||||
<fa :icon="faPencilAlt"/>
|
||||
</button>
|
||||
<button @click="add()">
|
||||
<fa :icon="faPlus"/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<section class="ilrvjyvi">
|
||||
<x-blocks class="children" v-model="value.children" :ai-script="aiScript"/>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { faPlus, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../../../i18n';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XContainer
|
||||
},
|
||||
|
||||
inject: ['getPageBlockList'],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
aiScript: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faStickyNote, faPlus, faPencilAlt
|
||||
};
|
||||
},
|
||||
|
||||
beforeCreate() {
|
||||
this.$options.components.XBlocks = require('../page-editor.blocks.vue').default
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.value.title == null) Vue.set(this.value, 'title', null);
|
||||
if (this.value.children == null) Vue.set(this.value, 'children', []);
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.value.title == null) {
|
||||
this.rename();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async rename() {
|
||||
const { canceled, result: title } = await this.$root.dialog({
|
||||
title: 'Enter title',
|
||||
input: {
|
||||
type: 'text',
|
||||
default: this.value.title
|
||||
},
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
this.value.title = title;
|
||||
},
|
||||
|
||||
async add() {
|
||||
const { canceled, result: type } = await this.$root.dialog({
|
||||
type: null,
|
||||
title: this.$t('choose-block'),
|
||||
select: {
|
||||
groupedItems: this.getPageBlockList()
|
||||
},
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
const id = uuid();
|
||||
this.value.children.push({ id, type });
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ilrvjyvi {
|
||||
> .children {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
50
src/client/pages/page-editor/els/page-editor.el.switch.vue
Normal file
50
src/client/pages/page-editor/els/page-editor.el.switch.vue
Normal file
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<x-container @remove="() => $emit('remove')" :draggable="true">
|
||||
<template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.switch') }}</template>
|
||||
|
||||
<section class="kjuadyyj">
|
||||
<mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._switch.name') }}</span></mk-input>
|
||||
<mk-input v-model="value.text"><span>{{ $t('_pages.blocks._switch.text') }}</span></mk-input>
|
||||
<mk-switch v-model="value.default"><span>{{ $t('_pages.blocks._switch.default') }}</span></mk-switch>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../../i18n';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
import MkSwitch from '../../../components/ui/switch.vue';
|
||||
import MkInput from '../../../components/ui/input.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XContainer, MkSwitch, MkInput
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faBolt, faMagic
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.value.name == null) Vue.set(this.value, 'name', '');
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.kjuadyyj {
|
||||
padding: 0 16px 16px 16px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<x-container @remove="() => $emit('remove')" :draggable="true">
|
||||
<template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.textInput') }}</template>
|
||||
|
||||
<section style="padding: 0 16px 0 16px;">
|
||||
<mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._textInput.name') }}</span></mk-input>
|
||||
<mk-input v-model="value.text"><span>{{ $t('_pages.blocks._textInput.text') }}</span></mk-input>
|
||||
<mk-input v-model="value.default" type="text"><span>{{ $t('_pages.blocks._textInput.default') }}</span></mk-input>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../../i18n';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
import MkInput from '../../../components/ui/input.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XContainer, MkInput
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faBolt, faMagic
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.value.name == null) Vue.set(this.value, 'name', '');
|
||||
},
|
||||
});
|
||||
</script>
|
60
src/client/pages/page-editor/els/page-editor.el.text.vue
Normal file
60
src/client/pages/page-editor/els/page-editor.el.text.vue
Normal file
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<x-container @remove="() => $emit('remove')" :draggable="true">
|
||||
<template #header><fa :icon="faAlignLeft"/> {{ $t('_pages.blocks.text') }}</template>
|
||||
|
||||
<section class="ihymsbbe">
|
||||
<textarea v-model="value.text"></textarea>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faAlignLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../../i18n';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XContainer
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faAlignLeft,
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.value.text == null) Vue.set(this.value, 'text', '');
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ihymsbbe {
|
||||
> textarea {
|
||||
display: block;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 150px;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<x-container @remove="() => $emit('remove')" :draggable="true">
|
||||
<template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.textareaInput') }}</template>
|
||||
|
||||
<section style="padding: 0 16px 16px 16px;">
|
||||
<mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._textareaInput.name') }}</span></mk-input>
|
||||
<mk-input v-model="value.text"><span>{{ $t('_pages.blocks._textareaInput.text') }}</span></mk-input>
|
||||
<mk-textarea v-model="value.default"><span>{{ $t('_pages.blocks._textareaInput.default') }}</span></mk-textarea>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../../i18n';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
import MkTextarea from '../../../components/ui/textarea.vue';
|
||||
import MkInput from '../../../components/ui/input.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XContainer, MkTextarea, MkInput
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faBolt, faMagic
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.value.name == null) Vue.set(this.value, 'name', '');
|
||||
},
|
||||
});
|
||||
</script>
|
60
src/client/pages/page-editor/els/page-editor.el.textarea.vue
Normal file
60
src/client/pages/page-editor/els/page-editor.el.textarea.vue
Normal file
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<x-container @remove="() => $emit('remove')" :draggable="true">
|
||||
<template #header><fa :icon="faAlignLeft"/> {{ $t('_pages.blocks.textarea') }}</template>
|
||||
|
||||
<section class="ihymsbbe">
|
||||
<textarea v-model="value.text"></textarea>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faAlignLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../../i18n';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XContainer
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faAlignLeft,
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.value.text == null) Vue.set(this.value, 'text', '');
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ihymsbbe {
|
||||
> textarea {
|
||||
display: block;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 150px;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
66
src/client/pages/page-editor/page-editor.blocks.vue
Normal file
66
src/client/pages/page-editor/page-editor.blocks.vue
Normal file
|
@ -0,0 +1,66 @@
|
|||
<template>
|
||||
<x-draggable tag="div" :list="blocks" handle=".drag-handle" :group="{ name: 'blocks' }" animation="150" swap-threshold="0.5">
|
||||
<component v-for="block in blocks" :is="'x-' + block.type" :value="block" @input="updateItem" @remove="() => removeItem(block)" :key="block.id" :ai-script="aiScript"/>
|
||||
</x-draggable>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import * as XDraggable from 'vuedraggable';
|
||||
import XSection from './els/page-editor.el.section.vue';
|
||||
import XText from './els/page-editor.el.text.vue';
|
||||
import XTextarea from './els/page-editor.el.textarea.vue';
|
||||
import XImage from './els/page-editor.el.image.vue';
|
||||
import XButton from './els/page-editor.el.button.vue';
|
||||
import XTextInput from './els/page-editor.el.text-input.vue';
|
||||
import XTextareaInput from './els/page-editor.el.textarea-input.vue';
|
||||
import XNumberInput from './els/page-editor.el.number-input.vue';
|
||||
import XSwitch from './els/page-editor.el.switch.vue';
|
||||
import XIf from './els/page-editor.el.if.vue';
|
||||
import XPost from './els/page-editor.el.post.vue';
|
||||
import XCounter from './els/page-editor.el.counter.vue';
|
||||
import XRadioButton from './els/page-editor.el.radio-button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XDraggable, XSection, XText, XImage, XButton, XTextarea, XTextInput, XTextareaInput, XNumberInput, XSwitch, XIf, XPost, XCounter, XRadioButton
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
aiScript: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
blocks() {
|
||||
return this.value;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateItem(v) {
|
||||
const i = this.blocks.findIndex(x => x.id === v.id);
|
||||
const newValue = [
|
||||
...this.blocks.slice(0, i),
|
||||
v,
|
||||
...this.blocks.slice(i + 1)
|
||||
];
|
||||
this.$emit('input', newValue);
|
||||
},
|
||||
|
||||
removeItem(el) {
|
||||
const i = this.blocks.findIndex(x => x.id === el.id);
|
||||
const newValue = [
|
||||
...this.blocks.slice(0, i),
|
||||
...this.blocks.slice(i + 1)
|
||||
];
|
||||
this.$emit('input', newValue);
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
152
src/client/pages/page-editor/page-editor.container.vue
Normal file
152
src/client/pages/page-editor/page-editor.container.vue
Normal file
|
@ -0,0 +1,152 @@
|
|||
<template>
|
||||
<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }">
|
||||
<header>
|
||||
<div class="title"><slot name="header"></slot></div>
|
||||
<div class="buttons">
|
||||
<slot name="func"></slot>
|
||||
<button v-if="removable" @click="remove()" class="_button">
|
||||
<fa :icon="faTrashAlt"/>
|
||||
</button>
|
||||
<button v-if="draggable" class="drag-handle _button">
|
||||
<fa :icon="faBars"/>
|
||||
</button>
|
||||
<button @click="toggleContent(!showBody)" class="_button">
|
||||
<template v-if="showBody"><fa :icon="faAngleUp"/></template>
|
||||
<template v-else><fa :icon="faAngleDown"/></template>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<p v-show="showBody" class="error" v-if="error != null">{{ $t('_pages.script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p>
|
||||
<p v-show="showBody" class="warn" v-if="warn != null">{{ $t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p>
|
||||
<div v-show="showBody">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faBars, faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
props: {
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
removable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
draggable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
error: {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
warn: {
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showBody: this.expanded,
|
||||
faTrashAlt, faBars, faAngleUp, faAngleDown
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
toggleContent(show: boolean) {
|
||||
this.showBody = show;
|
||||
this.$emit('toggle', show);
|
||||
},
|
||||
remove() {
|
||||
this.$emit('remove');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cpjygsrt {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
border: solid 2px var(--jvhmlskx);
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
border: solid 2px var(--yakfpmhl);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
border: solid 2px #dec44c;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border: solid 2px #f00;
|
||||
}
|
||||
|
||||
& + .cpjygsrt {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
> header {
|
||||
> .title {
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
line-height: 42px;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 1px rgba(#000, 0.07);
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .buttons {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
> button {
|
||||
padding: 0;
|
||||
width: 42px;
|
||||
font-size: 0.9em;
|
||||
line-height: 42px;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .warn {
|
||||
color: #b19e49;
|
||||
margin: 0;
|
||||
padding: 16px 16px 0 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
> .error {
|
||||
color: #f00;
|
||||
margin: 0;
|
||||
padding: 16px 16px 0 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
278
src/client/pages/page-editor/page-editor.script-block.vue
Normal file
278
src/client/pages/page-editor/page-editor.script-block.vue
Normal file
|
@ -0,0 +1,278 @@
|
|||
<template>
|
||||
<x-container :removable="removable" @remove="() => $emit('remove')" :error="error" :warn="warn" :draggable="draggable">
|
||||
<template #header><fa v-if="icon" :icon="icon"/> <template v-if="title">{{ title }} <span class="turmquns" v-if="typeText">({{ typeText }})</span></template><template v-else-if="typeText">{{ typeText }}</template></template>
|
||||
<template #func>
|
||||
<button @click="changeType()">
|
||||
<fa :icon="faPencilAlt"/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<section v-if="value.type === null" class="pbglfege" @click="changeType()">
|
||||
{{ $t('_pages.script.emptySlot') }}
|
||||
</section>
|
||||
<section v-else-if="value.type === 'text'" class="tbwccoaw">
|
||||
<input v-model="value.value"/>
|
||||
</section>
|
||||
<section v-else-if="value.type === 'multiLineText'" class="tbwccoaw">
|
||||
<textarea v-model="value.value"></textarea>
|
||||
</section>
|
||||
<section v-else-if="value.type === 'textList'" class="tbwccoaw">
|
||||
<textarea v-model="value.value" :placeholder="$t('_pages.script.blocks._textList.info')"></textarea>
|
||||
</section>
|
||||
<section v-else-if="value.type === 'number'" class="tbwccoaw">
|
||||
<input v-model="value.value" type="number"/>
|
||||
</section>
|
||||
<section v-else-if="value.type === 'ref'" class="hpdwcrvs">
|
||||
<select v-model="value.value">
|
||||
<option v-for="v in aiScript.getVarsByType(getExpectedType ? getExpectedType() : null).filter(x => x.name !== name)" :value="v.name">{{ v.name }}</option>
|
||||
<optgroup :label="$t('_pages.script.argVariables')">
|
||||
<option v-for="v in fnSlots" :value="v.name">{{ v.name }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="$t('_pages.script.pageVariables')">
|
||||
<option v-for="v in aiScript.getPageVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="$t('_pages.script.enviromentVariables')">
|
||||
<option v-for="v in aiScript.getEnvVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</section>
|
||||
<section v-else-if="value.type === 'fn'" class="" style="padding:0 16px 16px 16px;">
|
||||
<mk-textarea v-model="slots">
|
||||
<span>{{ $t('_pages.script.blocks._fn.slots') }}</span>
|
||||
<template #desc>{{ $t('_pages.script.blocks._fn.slots-info') }}</template>
|
||||
</mk-textarea>
|
||||
<x-v v-if="value.value.expression" v-model="value.value.expression" :title="$t(`_pages.script.blocks._fn.arg1`)" :get-expected-type="() => null" :ai-script="aiScript" :fn-slots="value.value.slots" :name="name"/>
|
||||
</section>
|
||||
<section v-else-if="value.type.startsWith('fn:')" class="" style="padding:16px;">
|
||||
<x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="aiScript.getVarByName(value.type.split(':')[1]).value.slots[i].name" :get-expected-type="() => null" :ai-script="aiScript" :name="name" :key="i"/>
|
||||
</section>
|
||||
<section v-else class="" style="padding:16px;">
|
||||
<x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="$t(`_pages.script.blocks._${value.type}.arg${i + 1}`)" :get-expected-type="() => _getExpectedType(i)" :ai-script="aiScript" :name="name" :fn-slots="fnSlots" :key="i"/>
|
||||
</section>
|
||||
</x-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faPencilAlt, faPlug } from '@fortawesome/free-solid-svg-icons';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import i18n from '../../i18n';
|
||||
import XContainer from './page-editor.container.vue';
|
||||
import MkTextarea from '../../components/ui/textarea.vue';
|
||||
import { isLiteralBlock, funcDefs, blockDefs } from '../../scripts/aiscript/index';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XContainer, MkTextarea
|
||||
},
|
||||
|
||||
inject: ['getScriptBlockList'],
|
||||
|
||||
props: {
|
||||
getExpectedType: {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
value: {
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
required: false
|
||||
},
|
||||
removable: {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
aiScript: {
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
required: true,
|
||||
},
|
||||
fnSlots: {
|
||||
required: false,
|
||||
},
|
||||
draggable: {
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: null,
|
||||
warn: null,
|
||||
slots: '',
|
||||
faPencilAlt
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
icon(): any {
|
||||
if (this.value.type === null) return null;
|
||||
if (this.value.type.startsWith('fn:')) return faPlug;
|
||||
return blockDefs.find(x => x.type === this.value.type).icon;
|
||||
},
|
||||
typeText(): any {
|
||||
if (this.value.type === null) return null;
|
||||
if (this.value.type.startsWith('fn:')) return this.value.type.split(':')[1];
|
||||
return this.$t(`_pages.script.blocks.${this.value.type}`);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
slots() {
|
||||
this.value.value.slots = this.slots.split('\n').map(x => ({
|
||||
name: x,
|
||||
type: null
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
beforeCreate() {
|
||||
this.$options.components.XV = require('./page-editor.script-block.vue').default;
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.value.value == null) Vue.set(this.value, 'value', null);
|
||||
|
||||
if (this.value.value && this.value.value.slots) this.slots = this.value.value.slots.map(x => x.name).join('\n');
|
||||
|
||||
this.$watch('value.type', (t) => {
|
||||
this.warn = null;
|
||||
|
||||
if (this.value.type === 'fn') {
|
||||
const id = uuid();
|
||||
this.value.value = {};
|
||||
Vue.set(this.value.value, 'slots', []);
|
||||
Vue.set(this.value.value, 'expression', { id, type: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.value.type && this.value.type.startsWith('fn:')) {
|
||||
const fnName = this.value.type.split(':')[1];
|
||||
const fn = this.aiScript.getVarByName(fnName);
|
||||
|
||||
const empties = [];
|
||||
for (let i = 0; i < fn.value.slots.length; i++) {
|
||||
const id = uuid();
|
||||
empties.push({ id, type: null });
|
||||
}
|
||||
Vue.set(this.value, 'args', empties);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLiteralBlock(this.value)) return;
|
||||
|
||||
const empties = [];
|
||||
for (let i = 0; i < funcDefs[this.value.type].in.length; i++) {
|
||||
const id = uuid();
|
||||
empties.push({ id, type: null });
|
||||
}
|
||||
Vue.set(this.value, 'args', empties);
|
||||
|
||||
for (let i = 0; i < funcDefs[this.value.type].in.length; i++) {
|
||||
const inType = funcDefs[this.value.type].in[i];
|
||||
if (typeof inType !== 'number') {
|
||||
if (inType === 'number') this.value.args[i].type = 'number';
|
||||
if (inType === 'string') this.value.args[i].type = 'text';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.$watch('value.args', (args) => {
|
||||
if (args == null) {
|
||||
this.warn = null;
|
||||
return;
|
||||
}
|
||||
const emptySlotIndex = args.findIndex(x => x.type === null);
|
||||
if (emptySlotIndex !== -1 && emptySlotIndex < args.length) {
|
||||
this.warn = {
|
||||
slot: emptySlotIndex
|
||||
};
|
||||
} else {
|
||||
this.warn = null;
|
||||
}
|
||||
}, {
|
||||
deep: true
|
||||
});
|
||||
|
||||
this.$watch('aiScript.variables', () => {
|
||||
if (this.type != null && this.value) {
|
||||
this.error = this.aiScript.typeCheck(this.value);
|
||||
}
|
||||
}, {
|
||||
deep: true
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
async changeType() {
|
||||
const { canceled, result: type } = await this.$root.dialog({
|
||||
type: null,
|
||||
title: this.$t('select-type'),
|
||||
select: {
|
||||
groupedItems: this.getScriptBlockList(this.getExpectedType ? this.getExpectedType() : null)
|
||||
},
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
this.value.type = type;
|
||||
},
|
||||
|
||||
_getExpectedType(slot: number) {
|
||||
return this.aiScript.getExpectedType(this.value, slot);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.turmquns {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.pbglfege {
|
||||
opacity: 0.5;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.tbwccoaw {
|
||||
> input,
|
||||
> textarea {
|
||||
display: block;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
> textarea {
|
||||
min-height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.hpdwcrvs {
|
||||
padding: 16px;
|
||||
|
||||
> select {
|
||||
display: block;
|
||||
padding: 4px;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
516
src/client/pages/page-editor/page-editor.vue
Normal file
516
src/client/pages/page-editor/page-editor.vue
Normal file
|
@ -0,0 +1,516 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="gwbmwxkm _panel">
|
||||
<header>
|
||||
<div class="title"><fa :icon="faStickyNote"/> {{ readonly ? $t('read-page') : pageId ? $t('edit-page') : $t('new-page') }}</div>
|
||||
<div class="buttons">
|
||||
<button class="_button" @click="del()" v-if="!readonly"><fa :icon="faTrashAlt"/></button>
|
||||
<button class="_button" @click="() => showOptions = !showOptions"><fa :icon="faCog"/></button>
|
||||
<button class="_button" @click="save()" v-if="!readonly"><fa :icon="faSave"/></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<router-link class="view" v-if="pageId" :to="`/@${ author.username }/pages/${ currentName }`"><fa :icon="faExternalLinkSquareAlt"/> {{ $t('view-page') }}</router-link>
|
||||
|
||||
<mk-input v-model="title">
|
||||
<span>{{ $t('title') }}</span>
|
||||
</mk-input>
|
||||
|
||||
<template v-if="showOptions">
|
||||
<mk-input v-model="summary">
|
||||
<span>{{ $t('summary') }}</span>
|
||||
</mk-input>
|
||||
|
||||
<mk-input v-model="name">
|
||||
<template #prefix>{{ url }}/@{{ author.username }}/pages/</template>
|
||||
<span>{{ $t('url') }}</span>
|
||||
</mk-input>
|
||||
|
||||
<mk-switch v-model="alignCenter">{{ $t('align-center') }}</mk-switch>
|
||||
|
||||
<mk-select v-model="font">
|
||||
<template #label>{{ $t('font') }}</template>
|
||||
<option value="serif">{{ $t('fontSerif') }}</option>
|
||||
<option value="sans-serif">{{ $t('fontSansSerif') }}</option>
|
||||
</mk-select>
|
||||
|
||||
<mk-switch v-model="hideTitleWhenPinned">{{ $t('hide-title-when-pinned') }}</mk-switch>
|
||||
|
||||
<div class="eyeCatch">
|
||||
<mk-button v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage()"><fa :icon="faPlus"/> {{ $t('set-eye-catching-image') }}</mk-button>
|
||||
<div v-else-if="eyeCatchingImage">
|
||||
<img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name"/>
|
||||
<mk-button @click="removeEyeCatchingImage()" v-if="!readonly"><fa :icon="faTrashAlt"/> {{ $t('remove-eye-catching-image') }}</mk-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<x-blocks class="content" v-model="content" :ai-script="aiScript"/>
|
||||
|
||||
<mk-button @click="add()" v-if="!readonly"><fa :icon="faPlus"/></mk-button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<mk-container :body-togglable="true">
|
||||
<template #header><fa :icon="faMagic"/> {{ $t('variables') }}</template>
|
||||
<div class="qmuvgica">
|
||||
<x-draggable tag="div" class="variables" v-show="variables.length > 0" :list="variables" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5">
|
||||
<x-variable v-for="variable in variables"
|
||||
:value="variable"
|
||||
:removable="true"
|
||||
@input="v => updateVariable(v)"
|
||||
@remove="() => removeVariable(variable)"
|
||||
:key="variable.name"
|
||||
:ai-script="aiScript"
|
||||
:name="variable.name"
|
||||
:title="variable.name"
|
||||
:draggable="true"
|
||||
/>
|
||||
</x-draggable>
|
||||
|
||||
<mk-button @click="addVariable()" class="add" v-if="!readonly"><fa :icon="faPlus"/></mk-button>
|
||||
|
||||
<x-info><span v-html="$t('variables-info')"></span><a @click="() => moreDetails = true" style="display:block;">{{ $t('more-details') }}</a></x-info>
|
||||
|
||||
<template v-if="moreDetails">
|
||||
<x-info><span v-html="$t('variables-info2')"></span></x-info>
|
||||
<x-info><span v-html="$t('variables-info3')"></span></x-info>
|
||||
<x-info><span v-html="$t('variables-info4')"></span></x-info>
|
||||
</template>
|
||||
</div>
|
||||
</mk-container>
|
||||
|
||||
<mk-container :body-togglable="true" :expanded="false">
|
||||
<template #header><fa :icon="faCode"/> {{ $t('inspector') }}</template>
|
||||
<div style="padding:0 32px 32px 32px;">
|
||||
<mk-textarea :value="JSON.stringify(content, null, 2)" readonly tall>{{ $t('content') }}</mk-textarea>
|
||||
<mk-textarea :value="JSON.stringify(variables, null, 2)" readonly tall>{{ $t('variables') }}</mk-textarea>
|
||||
</div>
|
||||
</mk-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import * as XDraggable from 'vuedraggable';
|
||||
import { faICursor, faPlus, faMagic, faCog, faCode, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faSave, faStickyNote, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import i18n from '../../i18n';
|
||||
import XVariable from './page-editor.script-block.vue';
|
||||
import XBlocks from './page-editor.blocks.vue';
|
||||
import MkTextarea from '../../components/ui/textarea.vue';
|
||||
import MkContainer from '../../components/ui/container.vue';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkSelect from '../../components/ui/select.vue';
|
||||
import MkSwitch from '../../components/ui/switch.vue';
|
||||
import MkInput from '../../components/ui/input.vue';
|
||||
import { blockDefs } from '../../scripts/aiscript/index';
|
||||
import { ASTypeChecker } from '../../scripts/aiscript/type-checker';
|
||||
import { url } from '../../config';
|
||||
import { collectPageVars } from '../../scripts/collect-page-vars';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XDraggable, XVariable, XBlocks, MkTextarea, MkContainer, MkButton, MkSelect, MkSwitch, MkInput
|
||||
},
|
||||
|
||||
props: {
|
||||
initPageId: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
initPageName: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
initUser: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
author: this.$store.state.i,
|
||||
readonly: false,
|
||||
page: null,
|
||||
pageId: null,
|
||||
currentName: null,
|
||||
title: '',
|
||||
summary: null,
|
||||
name: Date.now().toString(),
|
||||
eyeCatchingImage: null,
|
||||
eyeCatchingImageId: null,
|
||||
font: 'sans-serif',
|
||||
content: [],
|
||||
alignCenter: false,
|
||||
hideTitleWhenPinned: false,
|
||||
variables: [],
|
||||
aiScript: null,
|
||||
showOptions: false,
|
||||
moreDetails: false,
|
||||
url,
|
||||
faPlus, faICursor, faSave, faStickyNote, faMagic, faCog, faTrashAlt, faExternalLinkSquareAlt, faCode
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
async eyeCatchingImageId() {
|
||||
if (this.eyeCatchingImageId == null) {
|
||||
this.eyeCatchingImage = null;
|
||||
} else {
|
||||
this.eyeCatchingImage = await this.$root.api('drive/files/show', {
|
||||
fileId: this.eyeCatchingImageId,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
async created() {
|
||||
this.aiScript = new ASTypeChecker();
|
||||
|
||||
this.$watch('variables', () => {
|
||||
this.aiScript.variables = this.variables;
|
||||
}, { deep: true });
|
||||
|
||||
this.$watch('content', () => {
|
||||
this.aiScript.pageVars = collectPageVars(this.content);
|
||||
}, { deep: true });
|
||||
|
||||
if (this.initPageId) {
|
||||
this.page = await this.$root.api('pages/show', {
|
||||
pageId: this.initPageId,
|
||||
});
|
||||
} else if (this.initPageName && this.initUser) {
|
||||
this.page = await this.$root.api('pages/show', {
|
||||
name: this.initPageName,
|
||||
username: this.initUser,
|
||||
});
|
||||
this.readonly = true;
|
||||
}
|
||||
|
||||
if (this.page) {
|
||||
this.author = this.page.user;
|
||||
this.pageId = this.page.id;
|
||||
this.title = this.page.title;
|
||||
this.name = this.page.name;
|
||||
this.currentName = this.page.name;
|
||||
this.summary = this.page.summary;
|
||||
this.font = this.page.font;
|
||||
this.hideTitleWhenPinned = this.page.hideTitleWhenPinned;
|
||||
this.alignCenter = this.page.alignCenter;
|
||||
this.content = this.page.content;
|
||||
this.variables = this.page.variables;
|
||||
this.eyeCatchingImageId = this.page.eyeCatchingImageId;
|
||||
} else {
|
||||
const id = uuid();
|
||||
this.content = [{
|
||||
id,
|
||||
type: 'text',
|
||||
text: 'Hello World!'
|
||||
}];
|
||||
}
|
||||
},
|
||||
|
||||
provide() {
|
||||
return {
|
||||
readonly: this.readonly,
|
||||
getScriptBlockList: this.getScriptBlockList,
|
||||
getPageBlockList: this.getPageBlockList
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
save() {
|
||||
const options = {
|
||||
title: this.title.trim(),
|
||||
name: this.name.trim(),
|
||||
summary: this.summary,
|
||||
font: this.font,
|
||||
hideTitleWhenPinned: this.hideTitleWhenPinned,
|
||||
alignCenter: this.alignCenter,
|
||||
content: this.content,
|
||||
variables: this.variables,
|
||||
eyeCatchingImageId: this.eyeCatchingImageId,
|
||||
};
|
||||
|
||||
const onError = err => {
|
||||
if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') {
|
||||
if (err.info.param == 'name') {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
title: this.$t('title-invalid-name'),
|
||||
text: this.$t('text-invalid-name')
|
||||
});
|
||||
}
|
||||
} else if (err.code == 'NAME_ALREADY_EXISTS') {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('name-already-exists')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (this.pageId) {
|
||||
options.pageId = this.pageId;
|
||||
this.$root.api('pages/update', options)
|
||||
.then(page => {
|
||||
this.currentName = this.name.trim();
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
text: this.$t('page-updated')
|
||||
});
|
||||
}).catch(onError);
|
||||
} else {
|
||||
this.$root.api('pages/create', options)
|
||||
.then(page => {
|
||||
this.pageId = page.id;
|
||||
this.currentName = this.name.trim();
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
text: this.$t('page-created')
|
||||
});
|
||||
this.$router.push(`/my/pages/edit/${this.pageId}`);
|
||||
}).catch(onError);
|
||||
}
|
||||
},
|
||||
|
||||
del() {
|
||||
this.$root.dialog({
|
||||
type: 'warning',
|
||||
text: this.$t('are-you-sure-delete'),
|
||||
showCancelButton: true
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
this.$root.api('pages/delete', {
|
||||
pageId: this.pageId,
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
text: this.$t('page-deleted')
|
||||
});
|
||||
this.$router.push(`/my/pages`);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async add() {
|
||||
const { canceled, result: type } = await this.$root.dialog({
|
||||
type: null,
|
||||
title: this.$t('choose-block'),
|
||||
select: {
|
||||
groupedItems: this.getPageBlockList()
|
||||
},
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
const id = uuid();
|
||||
this.content.push({ id, type });
|
||||
},
|
||||
|
||||
async addVariable() {
|
||||
let { canceled, result: name } = await this.$root.dialog({
|
||||
title: this.$t('enter-variable-name'),
|
||||
input: {
|
||||
type: 'text',
|
||||
},
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
name = name.trim();
|
||||
|
||||
if (this.aiScript.isUsedName(name)) {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('the-variable-name-is-already-used')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const id = uuid();
|
||||
this.variables.push({ id, name, type: null });
|
||||
},
|
||||
|
||||
removeVariable(v) {
|
||||
const i = this.variables.findIndex(x => x.name === v.name);
|
||||
const newValue = [
|
||||
...this.variables.slice(0, i),
|
||||
...this.variables.slice(i + 1)
|
||||
];
|
||||
this.variables = newValue;
|
||||
},
|
||||
|
||||
getPageBlockList() {
|
||||
return [{
|
||||
label: this.$t('content-blocks'),
|
||||
items: [
|
||||
{ value: 'section', text: this.$t('_pages.blocks.section') },
|
||||
{ value: 'text', text: this.$t('_pages.blocks.text') },
|
||||
{ value: 'image', text: this.$t('_pages.blocks.image') },
|
||||
{ value: 'textarea', text: this.$t('_pages.blocks.textarea') },
|
||||
]
|
||||
}, {
|
||||
label: this.$t('input-blocks'),
|
||||
items: [
|
||||
{ value: 'button', text: this.$t('_pages.blocks.button') },
|
||||
{ value: 'radioButton', text: this.$t('_pages.blocks.radioButton') },
|
||||
{ value: 'textInput', text: this.$t('_pages.blocks.textInput') },
|
||||
{ value: 'textareaInput', text: this.$t('_pages.blocks.textareaInput') },
|
||||
{ value: 'numberInput', text: this.$t('_pages.blocks.numberInput') },
|
||||
{ value: 'switch', text: this.$t('_pages.blocks.switch') },
|
||||
{ value: 'counter', text: this.$t('_pages.blocks.counter') }
|
||||
]
|
||||
}, {
|
||||
label: this.$t('special-blocks'),
|
||||
items: [
|
||||
{ value: 'if', text: this.$t('_pages.blocks.if') },
|
||||
{ value: 'post', text: this.$t('_pages.blocks.post') }
|
||||
]
|
||||
}];
|
||||
},
|
||||
|
||||
getScriptBlockList(type: string = null) {
|
||||
const list = [];
|
||||
|
||||
const blocks = blockDefs.filter(block => type === null || block.out === null || block.out === type || typeof block.out === 'number');
|
||||
|
||||
for (const block of blocks) {
|
||||
const category = list.find(x => x.category === block.category);
|
||||
if (category) {
|
||||
category.items.push({
|
||||
value: block.type,
|
||||
text: this.$t(`_pages.script.blocks.${block.type}`)
|
||||
});
|
||||
} else {
|
||||
list.push({
|
||||
category: block.category,
|
||||
label: this.$t(`script.categories.${block.category}`),
|
||||
items: [{
|
||||
value: block.type,
|
||||
text: this.$t(`_pages.script.blocks.${block.type}`)
|
||||
}]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const userFns = this.variables.filter(x => x.type === 'fn');
|
||||
if (userFns.length > 0) {
|
||||
list.unshift({
|
||||
label: this.$t(`script.categories.fn`),
|
||||
items: userFns.map(v => ({
|
||||
value: 'fn:' + v.name,
|
||||
text: v.name
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
},
|
||||
|
||||
setEyeCatchingImage() {
|
||||
this.$chooseDriveFile({
|
||||
multiple: false
|
||||
}).then(file => {
|
||||
this.eyeCatchingImageId = file.id;
|
||||
});
|
||||
},
|
||||
|
||||
removeEyeCatchingImage() {
|
||||
this.eyeCatchingImageId = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.gwbmwxkm {
|
||||
margin-bottom: var(--margin);
|
||||
|
||||
> header {
|
||||
background: var(--faceHeader);
|
||||
|
||||
> .title {
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
line-height: 42px;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
color: var(--faceHeaderText);
|
||||
box-shadow: 0 var(--lineWidth) rgba(#000, 0.07);
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .buttons {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
> button {
|
||||
padding: 0;
|
||||
width: 42px;
|
||||
font-size: 0.9em;
|
||||
line-height: 42px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> section {
|
||||
padding: 0 32px 32px 32px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 0 16px 16px 16px;
|
||||
}
|
||||
|
||||
> .view {
|
||||
display: inline-block;
|
||||
margin: 16px 0 0 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
> .content {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
> .eyeCatch {
|
||||
margin-bottom: 16px;
|
||||
|
||||
> div {
|
||||
> img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qmuvgica {
|
||||
padding: 32px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
> .variables {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
> .add {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
69
src/client/pages/page.vue
Normal file
69
src/client/pages/page.vue
Normal file
|
@ -0,0 +1,69 @@
|
|||
<template>
|
||||
<div class="xcukqgmh _panel">
|
||||
<portal to="avatar" v-if="page"><mk-avatar class="avatar" :user="page.user" :disable-preview="true"/></portal>
|
||||
<portal to="title" v-if="page">{{ page.title || page.name }}</portal>
|
||||
|
||||
<x-page v-if="page" :page="page" :key="page.id" :show-footer="true"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XPage from '../components/page/page.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XPage
|
||||
},
|
||||
|
||||
props: {
|
||||
pageName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
page: null,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
path(): string {
|
||||
return this.username + '/' + this.pageName;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
path() {
|
||||
this.fetch();
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.fetch();
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetch() {
|
||||
this.$root.api('pages/show', {
|
||||
name: this.pageName,
|
||||
username: this.username,
|
||||
}).then(page => {
|
||||
this.page = page;
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.xcukqgmh {
|
||||
|
||||
}
|
||||
</style>
|
78
src/client/pages/pages.vue
Normal file
78
src/client/pages/pages.vue
Normal file
|
@ -0,0 +1,78 @@
|
|||
<template>
|
||||
<div>
|
||||
<mk-container :body-togglable="true">
|
||||
<template #header><fa :icon="faEdit" fixed-width/>{{ $t('my-pages') }}</template>
|
||||
<div class="rknalgpo my">
|
||||
<mk-button class="new" @click="create()"><fa :icon="faPlus"/></mk-button>
|
||||
<mk-pagination :pagination="myPagesPagination" #default="{items}">
|
||||
<mk-page-preview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
</mk-container>
|
||||
|
||||
<mk-container :body-togglable="true">
|
||||
<template #header><fa :icon="faHeart" fixed-width/>{{ $t('liked-pages') }}</template>
|
||||
<div class="rknalgpo">
|
||||
<mk-pagination :pagination="likedPagesPagination" #default="{items}">
|
||||
<mk-page-preview v-for="like in items" class="ckltabjg" :page="like.page" :key="like.page.id"/>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
</mk-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faPlus, faEdit } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faStickyNote, faHeart } from '@fortawesome/free-regular-svg-icons';
|
||||
import i18n from '../i18n';
|
||||
import MkPagePreview from '../components/page-preview.vue';
|
||||
import MkPagination from '../components/ui/pagination.vue';
|
||||
import MkButton from '../components/ui/button.vue';
|
||||
import MkContainer from '../components/ui/container.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
components: {
|
||||
MkPagePreview, MkPagination, MkButton, MkContainer
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
myPagesPagination: {
|
||||
endpoint: 'i/pages',
|
||||
limit: 5,
|
||||
},
|
||||
likedPagesPagination: {
|
||||
endpoint: 'i/page-likes',
|
||||
limit: 5,
|
||||
},
|
||||
faStickyNote, faPlus, faEdit, faHeart
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
create() {
|
||||
this.$router.push(`/my/pages/new`);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rknalgpo {
|
||||
padding: 16px;
|
||||
|
||||
&.my .ckltabjg:first-child {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ckltabjg:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 500px) {
|
||||
.ckltabjg:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
55
src/client/pages/search.vue
Normal file
55
src/client/pages/search.vue
Normal file
|
@ -0,0 +1,55 @@
|
|||
<template>
|
||||
<div>
|
||||
<portal to="icon"><fa :icon="faSearch"/></portal>
|
||||
<portal to="title">{{ $t('searchWith', { q: $route.query.q }) }}</portal>
|
||||
<x-notes ref="notes" :pagination="pagination" @before="before" @after="after"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faSearch } from '@fortawesome/free-solid-svg-icons';
|
||||
import Progress from '../scripts/loading';
|
||||
import XNotes from '../components/notes.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('searchWith', { q: this.$route.query.q }) as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
XNotes
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination: {
|
||||
endpoint: 'notes/search',
|
||||
limit: 10,
|
||||
params: () => ({
|
||||
query: this.$route.query.q,
|
||||
})
|
||||
},
|
||||
faSearch
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route() {
|
||||
(this.$refs.notes as any).reload();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
before() {
|
||||
Progress.start();
|
||||
},
|
||||
|
||||
after() {
|
||||
Progress.done();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
264
src/client/pages/settings/2fa.vue
Normal file
264
src/client/pages/settings/2fa.vue
Normal file
|
@ -0,0 +1,264 @@
|
|||
<template>
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faLock"/> {{ $t('twoStepAuthentication') }}</div>
|
||||
<div class="_content">
|
||||
<p v-if="!data && !$store.state.i.twoFactorEnabled"><mk-button @click="register">{{ $t('_2fa.registerDevice') }}</mk-button></p>
|
||||
<template v-if="$store.state.i.twoFactorEnabled">
|
||||
<h2 class="heading">{{ $t('totp-header') }}</h2>
|
||||
<p>{{ $t('already-registered') }}</p>
|
||||
<mk-button @click="unregister">{{ $t('unregister') }}</mk-button>
|
||||
|
||||
<template v-if="supportsCredentials">
|
||||
<hr class="totp-method-sep">
|
||||
|
||||
<h2 class="heading">{{ $t('security-key-header') }}</h2>
|
||||
<p>{{ $t('security-key') }}</p>
|
||||
<div class="key-list">
|
||||
<div class="key" v-for="key in $store.state.i.securityKeysList">
|
||||
<h3>
|
||||
{{ key.name }}
|
||||
</h3>
|
||||
<div class="last-used">
|
||||
{{ $t('last-used') }}
|
||||
<mk-time :time="key.lastUsed"/>
|
||||
</div>
|
||||
<mk-button @click="unregisterKey(key)">
|
||||
{{ $t('unregister') }}
|
||||
</mk-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mk-switch v-model="usePasswordLessLogin" @change="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0">
|
||||
{{ $t('use-password-less-login') }}
|
||||
</mk-switch>
|
||||
|
||||
<mk-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</mk-info>
|
||||
<mk-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</mk-button>
|
||||
|
||||
<ol v-if="registration && !registration.error">
|
||||
<li v-if="registration.stage >= 0">
|
||||
{{ $t('activate-key') }}
|
||||
<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" />
|
||||
</li>
|
||||
<li v-if="registration.stage >= 1">
|
||||
<mk-form :disabled="registration.stage != 1 || registration.saving">
|
||||
<mk-input v-model="keyName" :max="30">
|
||||
<span>{{ $t('security-key-name') }}</span>
|
||||
</mk-input>
|
||||
<mk-button @click="registerKey" :disabled="this.keyName.length == 0">
|
||||
{{ $t('register-security-key') }}
|
||||
</mk-button>
|
||||
<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" />
|
||||
</mk-form>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
</template>
|
||||
<div v-if="data && !$store.state.i.twoFactorEnabled">
|
||||
<ol style="margin: 0; padding: 0 0 0 1em;">
|
||||
<li>
|
||||
<i18n path="_2fa.step1" tag="span">
|
||||
<a href="https://authy.com/" rel="noopener" target="_blank" place="a" style="color: var(--link);">Authy</a>
|
||||
<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" place="b" style="color: var(--link);">Google Authenticator</a>
|
||||
</i18n>
|
||||
</li>
|
||||
<li>{{ $t('_2fa.step2') }}<br><img :src="data.qr"></li>
|
||||
<li>{{ $t('_2fa.step3') }}<br>
|
||||
<mk-input v-model="token" type="number" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false">{{ $t('token') }}</mk-input>
|
||||
<mk-button primary @click="submit">{{ $t('done') }}</mk-button>
|
||||
</li>
|
||||
</ol>
|
||||
<mk-info>{{ $t('_2fa.step4') }}</mk-info>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import i18n from '../../i18n';
|
||||
import { hostname } from '../../config';
|
||||
import { hexifyAB } from '../../scripts/2fa';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkInfo from '../../components/ui/info.vue';
|
||||
import MkInput from '../../components/ui/input.vue';
|
||||
|
||||
function stringifyAB(buffer) {
|
||||
return String.fromCharCode.apply(null, new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
components: {
|
||||
MkButton, MkInfo, MkInput
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
data: null,
|
||||
supportsCredentials: !!navigator.credentials,
|
||||
usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin,
|
||||
registration: null,
|
||||
keyName: '',
|
||||
token: null,
|
||||
faLock
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
register() {
|
||||
this.$root.dialog({
|
||||
title: this.$t('password'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
this.$root.api('i/2fa/register', {
|
||||
password: password
|
||||
}).then(data => {
|
||||
this.data = data;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
unregister() {
|
||||
this.$root.dialog({
|
||||
title: this.$t('password'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
this.$root.api('i/2fa/unregister', {
|
||||
password: password
|
||||
}).then(() => {
|
||||
this.usePasswordLessLogin = false;
|
||||
this.updatePasswordLessLogin();
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
this.$store.state.i.twoFactorEnabled = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.$root.api('i/2fa/done', {
|
||||
token: this.token
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
this.$store.state.i.twoFactorEnabled = true;
|
||||
}).catch(e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
registerKey() {
|
||||
this.registration.saving = true;
|
||||
this.$root.api('i/2fa/key-done', {
|
||||
password: this.registration.password,
|
||||
name: this.keyName,
|
||||
challengeId: this.registration.challengeId,
|
||||
// we convert each 16 bits to a string to serialise
|
||||
clientDataJSON: stringifyAB(this.registration.credential.response.clientDataJSON),
|
||||
attestationObject: hexifyAB(this.registration.credential.response.attestationObject)
|
||||
}).then(key => {
|
||||
this.registration = null;
|
||||
key.lastUsed = new Date();
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
})
|
||||
},
|
||||
|
||||
unregisterKey(key) {
|
||||
this.$root.dialog({
|
||||
title: this.$t('password'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
return this.$root.api('i/2fa/remove-key', {
|
||||
password,
|
||||
credentialId: key.id
|
||||
}).then(() => {
|
||||
this.usePasswordLessLogin = false;
|
||||
this.updatePasswordLessLogin();
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
addSecurityKey() {
|
||||
this.$root.dialog({
|
||||
title: this.$t('password'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
this.$root.api('i/2fa/register-key', {
|
||||
password
|
||||
}).then(registration => {
|
||||
this.registration = {
|
||||
password,
|
||||
challengeId: registration.challengeId,
|
||||
stage: 0,
|
||||
publicKeyOptions: {
|
||||
challenge: Buffer.from(
|
||||
registration.challenge
|
||||
.replace(/\-/g, "+")
|
||||
.replace(/_/g, "/"),
|
||||
'base64'
|
||||
),
|
||||
rp: {
|
||||
id: hostname,
|
||||
name: 'Misskey'
|
||||
},
|
||||
user: {
|
||||
id: Uint8Array.from(this.$store.state.i.id, c => c.charCodeAt(0)),
|
||||
name: this.$store.state.i.username,
|
||||
displayName: this.$store.state.i.name,
|
||||
},
|
||||
pubKeyCredParams: [{alg: -7, type: 'public-key'}],
|
||||
timeout: 60000,
|
||||
attestation: 'direct'
|
||||
},
|
||||
saving: true
|
||||
};
|
||||
return navigator.credentials.create({
|
||||
publicKey: this.registration.publicKeyOptions
|
||||
});
|
||||
}).then(credential => {
|
||||
this.registration.credential = credential;
|
||||
this.registration.saving = false;
|
||||
this.registration.stage = 1;
|
||||
}).catch(err => {
|
||||
console.warn('Error while registering?', err);
|
||||
this.registration.error = err.message;
|
||||
this.registration.stage = -1;
|
||||
});
|
||||
});
|
||||
},
|
||||
updatePasswordLessLogin() {
|
||||
this.$root.api('i/2fa/password-less', {
|
||||
value: !!this.usePasswordLessLogin
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
212
src/client/pages/settings/drive.vue
Normal file
212
src/client/pages/settings/drive.vue
Normal file
|
@ -0,0 +1,212 @@
|
|||
<template>
|
||||
<section class="mk-settings-page-drive _section">
|
||||
<div class="_title"><fa :icon="faCloud"/> {{ $t('drive') }}</div>
|
||||
<div class="_content">
|
||||
<mk-pagination :pagination="drivePagination" #default="{items}" class="drive" ref="drive">
|
||||
<div class="file" v-for="(file, i) in items" :key="file.id" :data-index="i" @click="selected = file" :class="{ selected: selected && (selected.id === file.id) }">
|
||||
<x-file-thumbnail class="thumbnail" :file="file" fit="cover"/>
|
||||
<div class="body">
|
||||
<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>
|
||||
<footer>
|
||||
<span class="type"><x-file-type-icon :type="file.type" class="icon"/>{{ file.type }}</span>
|
||||
<span class="separator"></span>
|
||||
<span class="data-size">{{ file.size | bytes }}</span>
|
||||
<span class="separator"></span>
|
||||
<span class="created-at"><fa :icon="faClock"/><mk-time :time="file.createdAt"/></span>
|
||||
<template v-if="file.isSensitive">
|
||||
<span class="separator"></span>
|
||||
<span class="nsfw"><fa :icon="faEyeSlash"/> {{ $t('nsfw') }}</span>
|
||||
</template>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button primary inline :disabled="selected == null" @click="download()"><fa :icon="faDownload"/> {{ $t('download') }}</mk-button>
|
||||
<mk-button inline :disabled="selected == null" @click="del()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faCloud, faDownload } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faClock, faEyeSlash, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import XFileTypeIcon from '../../components/file-type-icon.vue';
|
||||
import XFileThumbnail from '../../components/drive-file-thumbnail.vue';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkPagination from '../../components/ui/pagination.vue';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
XFileTypeIcon,
|
||||
XFileThumbnail,
|
||||
MkPagination,
|
||||
MkButton,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selected: null,
|
||||
connection: null,
|
||||
drivePagination: {
|
||||
endpoint: 'drive/files',
|
||||
limit: 10,
|
||||
},
|
||||
faCloud, faClock, faEyeSlash, faDownload, faTrashAlt
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
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);
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.connection.dispose();
|
||||
},
|
||||
|
||||
methods: {
|
||||
onStreamDriveFileCreated(file) {
|
||||
this.$refs.drive.prepend(file);
|
||||
},
|
||||
|
||||
onStreamDriveFileUpdated(file) {
|
||||
// TODO
|
||||
},
|
||||
|
||||
onStreamDriveFileDeleted(fileId) {
|
||||
this.$refs.drive.remove(x => x.id === fileId);
|
||||
},
|
||||
|
||||
download() {
|
||||
window.open(this.selected.url, '_blank');
|
||||
},
|
||||
|
||||
async del() {
|
||||
const { canceled } = await this.$root.dialog({
|
||||
type: 'warning',
|
||||
text: this.$t('driveFileDeleteConfirm', { name: this.selected.name }),
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
this.$root.api('drive/files/delete', {
|
||||
fileId: this.selected.id
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-settings-page-drive {
|
||||
> ._content {
|
||||
max-height: 350px;
|
||||
overflow: auto;
|
||||
|
||||
> .drive {
|
||||
> .file {
|
||||
display: grid;
|
||||
margin: 0 auto;
|
||||
grid-template-columns: 64px 1fr;
|
||||
grid-column-gap: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 8px var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
padding-top: 4px;
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
word-break: break-word;
|
||||
|
||||
> .ext {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
> .tags {
|
||||
display: block;
|
||||
margin: 4px 0 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.5em;
|
||||
|
||||
> .tag {
|
||||
display: inline-block;
|
||||
margin: 0 5px 0 0;
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
display: block;
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 0.7em;
|
||||
|
||||
> .separator {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
> .type {
|
||||
opacity: 0.7;
|
||||
|
||||
> .icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .data-size {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> .created-at {
|
||||
opacity: 0.7;
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
> .nsfw {
|
||||
color: #bf4633;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
108
src/client/pages/settings/general.vue
Normal file
108
src/client/pages/settings/general.vue
Normal file
|
@ -0,0 +1,108 @@
|
|||
<template>
|
||||
<section class="mk-settings-page-general _section">
|
||||
<div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div>
|
||||
<div class="_content">
|
||||
<mk-input type="file" @change="onWallpaperChange" style="margin-top: 0;">
|
||||
<span>{{ $t('wallpaper') }}</span>
|
||||
<template #icon><fa :icon="faImage"/></template>
|
||||
<template #desc v-if="wallpaperUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
|
||||
</mk-input>
|
||||
<mk-button primary :disabled="$store.state.settings.wallpaper == null" @click="delWallpaper()">{{ $t('removeWallpaper') }}</mk-button>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch">
|
||||
{{ $t('auto-watch') }}<template #desc>{{ $t('auto-watch-desc') }}</template>
|
||||
</mk-switch>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<mk-button @click="readAllNotifications">{{ $t('mark-as-read-all-notifications') }}</mk-button>
|
||||
<mk-button @click="readAllUnreadNotes">{{ $t('mark-as-read-all-unread-notes') }}</mk-button>
|
||||
<mk-button @click="readAllMessagingMessages">{{ $t('mark-as-read-all-talk-messages') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faImage, faCog } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkInput from '../../components/ui/input.vue';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkSwitch from '../../components/ui/switch.vue';
|
||||
import i18n from '../../i18n';
|
||||
import { apiUrl } from '../../config';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkInput,
|
||||
MkButton,
|
||||
MkSwitch,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
wallpaperUploading: false,
|
||||
faImage, faCog
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
wallpaper: {
|
||||
get() { return this.$store.state.settings.wallpaper; },
|
||||
set(value) { this.$store.dispatch('settings/set', { key: 'wallpaper', value }); }
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onWallpaperChange([file]) {
|
||||
this.wallpaperUploading = true;
|
||||
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
data.append('i', this.$store.state.i.token);
|
||||
|
||||
fetch(apiUrl + '/drive/files/create', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(f => {
|
||||
this.wallpaper = f.url;
|
||||
this.wallpaperUploading = false;
|
||||
document.documentElement.style.backgroundImage = `url(${this.$store.state.settings.wallpaper})`;
|
||||
})
|
||||
.catch(e => {
|
||||
this.wallpaperUploading = false;
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
delWallpaper() {
|
||||
this.wallpaper = null;
|
||||
document.documentElement.style.backgroundImage = 'none';
|
||||
},
|
||||
|
||||
onChangeAutoWatch(v) {
|
||||
this.$root.api('i/update', {
|
||||
autoWatch: v
|
||||
});
|
||||
},
|
||||
|
||||
readAllUnreadNotes() {
|
||||
this.$root.api('i/read_all_unread_notes');
|
||||
},
|
||||
|
||||
readAllMessagingMessages() {
|
||||
this.$root.api('i/read_all_messaging_messages');
|
||||
},
|
||||
|
||||
readAllNotifications() {
|
||||
this.$root.api('notifications/mark_all_as_read');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
121
src/client/pages/settings/import-export.vue
Normal file
121
src/client/pages/settings/import-export.vue
Normal file
|
@ -0,0 +1,121 @@
|
|||
<template>
|
||||
<section class="mk-settings-page-import-export _section">
|
||||
<div class="_title"><fa :icon="faBoxes"/> {{ $t('importAndExport') }}</div>
|
||||
<div class="_content">
|
||||
<input ref="file" type="file" style="display: none;" @change="onChangeFile"/>
|
||||
<mk-select v-model="exportTarget" style="margin-top: 0;">
|
||||
<option value="notes">{{ $t('_exportOrImport.allNotes') }}</option>
|
||||
<option value="following">{{ $t('_exportOrImport.followingList') }}</option>
|
||||
<option value="user-lists">{{ $t('_exportOrImport.userLists') }}</option>
|
||||
<option value="mute">{{ $t('_exportOrImport.muteList') }}</option>
|
||||
<option value="blocking">{{ $t('_exportOrImport.blockingList') }}</option>
|
||||
</mk-select>
|
||||
<mk-button inline @click="doExport()"><fa :icon="faDownload"/> {{ $t('export') }}</mk-button>
|
||||
<mk-button inline @click="doImport()" :disabled="!['following', 'user-lists'].includes(exportTarget)"><fa :icon="faUpload"/> {{ $t('import') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faDownload, faUpload, faBoxes } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkSelect from '../../components/ui/select.vue';
|
||||
import i18n from '../../i18n';
|
||||
import { apiUrl } from '../../config';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
MkSelect,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
exportTarget: 'notes',
|
||||
faDownload, faUpload, faBoxes
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
doExport() {
|
||||
this.$root.api(
|
||||
this.exportTarget == 'notes' ? 'i/export-notes' :
|
||||
this.exportTarget == 'following' ? 'i/export-following' :
|
||||
this.exportTarget == 'blocking' ? 'i/export-blocking' :
|
||||
this.exportTarget == 'user-lists' ? 'i/export-user-lists' :
|
||||
null, {})
|
||||
.then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'info',
|
||||
text: this.$t('exportRequested')
|
||||
});
|
||||
}).catch((e: any) => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e.message
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
doImport() {
|
||||
(this.$refs.file as any).click();
|
||||
},
|
||||
|
||||
onChangeFile() {
|
||||
const [file] = Array.from((this.$refs.file as any).files);
|
||||
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
data.append('i', this.$store.state.i.token);
|
||||
|
||||
const dialog = this.$root.dialog({
|
||||
type: 'waiting',
|
||||
text: this.$t('uploading') + '...',
|
||||
showOkButton: false,
|
||||
showCancelButton: false,
|
||||
cancelableByBgClick: false
|
||||
});
|
||||
|
||||
fetch(apiUrl + '/drive/files/create', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(f => {
|
||||
this.reqImport(f);
|
||||
})
|
||||
.catch(e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
dialog.close();
|
||||
});
|
||||
},
|
||||
|
||||
reqImport(file) {
|
||||
this.$root.api(
|
||||
this.exportTarget == 'following' ? 'i/import-following' :
|
||||
this.exportTarget == 'user-lists' ? 'i/import-user-lists' :
|
||||
null, {
|
||||
fileId: file.id
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'info',
|
||||
text: this.$t('importRequested')
|
||||
});
|
||||
}).catch((e: any) => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e.message
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
94
src/client/pages/settings/index.vue
Normal file
94
src/client/pages/settings/index.vue
Normal file
|
@ -0,0 +1,94 @@
|
|||
<template>
|
||||
<div class="mk-settings-page">
|
||||
<portal to="icon"><fa :icon="faCog"/></portal>
|
||||
<portal to="title">{{ $t('settings') }}</portal>
|
||||
|
||||
<x-profile-setting/>
|
||||
<x-privacy-setting/>
|
||||
<x-reaction-setting/>
|
||||
<x-theme/>
|
||||
<x-import-export/>
|
||||
<x-drive/>
|
||||
<x-general/>
|
||||
<x-mute-block/>
|
||||
<x-security/>
|
||||
<x-2fa/>
|
||||
<x-integration/>
|
||||
|
||||
<mk-button @click="cacheClear()" primary class="cacheClear">{{ $t('cacheClear') }}</mk-button>
|
||||
<mk-button @click="$root.signout()" primary class="logout">{{ $t('logout') }}</mk-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faCog } from '@fortawesome/free-solid-svg-icons';
|
||||
import XProfileSetting from './profile.vue';
|
||||
import XPrivacySetting from './privacy.vue';
|
||||
import XImportExport from './import-export.vue';
|
||||
import XDrive from './drive.vue';
|
||||
import XGeneral from './general.vue';
|
||||
import XReactionSetting from './reaction.vue';
|
||||
import XMuteBlock from './mute-block.vue';
|
||||
import XSecurity from './security.vue';
|
||||
import XTheme from './theme.vue';
|
||||
import X2fa from './2fa.vue';
|
||||
import XIntegration from './integration.vue';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.$t('settings') as string
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
XProfileSetting,
|
||||
XPrivacySetting,
|
||||
XImportExport,
|
||||
XDrive,
|
||||
XGeneral,
|
||||
XReactionSetting,
|
||||
XMuteBlock,
|
||||
XSecurity,
|
||||
XTheme,
|
||||
X2fa,
|
||||
XIntegration,
|
||||
MkButton,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faCog
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
cacheClear() {
|
||||
// Clear cache (service worker)
|
||||
try {
|
||||
navigator.serviceWorker.controller.postMessage('clear');
|
||||
|
||||
navigator.serviceWorker.getRegistrations().then(registrations => {
|
||||
for (const registration of registrations) registration.unregister();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
// Force reload
|
||||
location.reload(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-settings-page {
|
||||
> .logout,
|
||||
> .cacheClear {
|
||||
margin: 8px auto;
|
||||
}
|
||||
}
|
||||
</style>
|
122
src/client/pages/settings/integration.vue
Normal file
122
src/client/pages/settings/integration.vue
Normal file
|
@ -0,0 +1,122 @@
|
|||
<template>
|
||||
<section class="_section" v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration">
|
||||
<div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div>
|
||||
<div class="_content" v-if="enableTwitterIntegration">
|
||||
<header><fa :icon="faTwitter"/> Twitter</header>
|
||||
<p v-if="$store.state.i.twitter">{{ $t('connectedTo') }}: <a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p>
|
||||
<mk-button v-if="$store.state.i.twitter" @click="disconnectTwitter">{{ $t('disconnectSerice') }}</mk-button>
|
||||
<mk-button v-else @click="connectTwitter">{{ $t('connectSerice') }}</mk-button>
|
||||
</div>
|
||||
|
||||
<div class="_content" v-if="enableDiscordIntegration">
|
||||
<header><fa :icon="faDiscord"/> Discord</header>
|
||||
<p v-if="$store.state.i.discord">{{ $t('connectedTo') }}: <a :href="`https://discordapp.com/users/${$store.state.i.discord.id}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.discord.username }}#{{ $store.state.i.discord.discriminator }}</a></p>
|
||||
<mk-button v-if="$store.state.i.discord" @click="disconnectDiscord">{{ $t('disconnectSerice') }}</mk-button>
|
||||
<mk-button v-else @click="connectDiscord">{{ $t('connectSerice') }}</mk-button>
|
||||
</div>
|
||||
|
||||
<div class="_content" v-if="enableGithubIntegration">
|
||||
<header><fa :icon="faGithub"/> GitHub</header>
|
||||
<p v-if="$store.state.i.github">{{ $t('connectedTo') }}: <a :href="`https://github.com/${$store.state.i.github.login}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.github.login }}</a></p>
|
||||
<mk-button v-if="$store.state.i.github" @click="disconnectGithub">{{ $t('disconnectSerice') }}</mk-button>
|
||||
<mk-button v-else @click="connectGithub">{{ $t('connectSerice') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faShareAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||
import i18n from '../../i18n';
|
||||
import { apiUrl } from '../../config';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkButton
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
apiUrl,
|
||||
twitterForm: null,
|
||||
discordForm: null,
|
||||
githubForm: null,
|
||||
enableTwitterIntegration: false,
|
||||
enableDiscordIntegration: false,
|
||||
enableGithubIntegration: false,
|
||||
faShareAlt, faTwitter, faDiscord, faGithub
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$root.getMeta().then(meta => {
|
||||
this.enableTwitterIntegration = meta.enableTwitterIntegration;
|
||||
this.enableDiscordIntegration = meta.enableDiscordIntegration;
|
||||
this.enableGithubIntegration = meta.enableGithubIntegration;
|
||||
});
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (!document.cookie.match(/i=(\w+)/)) {
|
||||
document.cookie = `i=${this.$store.state.i.token}; path=/;` +
|
||||
` domain=${document.location.hostname}; max-age=31536000;` +
|
||||
(document.location.protocol.startsWith('https') ? ' secure' : '');
|
||||
}
|
||||
this.$watch('$store.state.i', () => {
|
||||
if (this.$store.state.i.twitter) {
|
||||
if (this.twitterForm) this.twitterForm.close();
|
||||
}
|
||||
if (this.$store.state.i.discord) {
|
||||
if (this.discordForm) this.discordForm.close();
|
||||
}
|
||||
if (this.$store.state.i.github) {
|
||||
if (this.githubForm) this.githubForm.close();
|
||||
}
|
||||
}, {
|
||||
deep: true
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
connectTwitter() {
|
||||
this.twitterForm = window.open(apiUrl + '/connect/twitter',
|
||||
'twitter_connect_window',
|
||||
'height=570, width=520');
|
||||
},
|
||||
|
||||
disconnectTwitter() {
|
||||
window.open(apiUrl + '/disconnect/twitter',
|
||||
'twitter_disconnect_window',
|
||||
'height=570, width=520');
|
||||
},
|
||||
|
||||
connectDiscord() {
|
||||
this.discordForm = window.open(apiUrl + '/connect/discord',
|
||||
'discord_connect_window',
|
||||
'height=570, width=520');
|
||||
},
|
||||
|
||||
disconnectDiscord() {
|
||||
window.open(apiUrl + '/disconnect/discord',
|
||||
'discord_disconnect_window',
|
||||
'height=570, width=520');
|
||||
},
|
||||
|
||||
connectGithub() {
|
||||
this.githubForm = window.open(apiUrl + '/connect/github',
|
||||
'github_connect_window',
|
||||
'height=570, width=520');
|
||||
},
|
||||
|
||||
disconnectGithub() {
|
||||
window.open(apiUrl + '/disconnect/github',
|
||||
'github_disconnect_window',
|
||||
'height=570, width=520');
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
76
src/client/pages/settings/mute-block.vue
Normal file
76
src/client/pages/settings/mute-block.vue
Normal file
|
@ -0,0 +1,76 @@
|
|||
<template>
|
||||
<section class="mk-settings-page-mute-block _section">
|
||||
<div class="_title"><fa :icon="faBan"/> {{ $t('muteAndBlock') }}</div>
|
||||
<div class="_content">
|
||||
<span>{{ $t('mutedUsers') }}</span>
|
||||
<mk-pagination :pagination="mutingPagination" class="muting">
|
||||
<template #empty><span>{{ $t('noUsers') }}</span></template>
|
||||
<template #default="{items}">
|
||||
<div class="user" v-for="(mute, i) in items" :key="mute.id" :data-index="i">
|
||||
<router-link class="name" :to="mute.mutee | userPage">
|
||||
<mk-acct :user="mute.mutee"/>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<span>{{ $t('blockedUsers') }}</span>
|
||||
<mk-pagination :pagination="blockingPagination" class="blocking">
|
||||
<template #empty><span>{{ $t('noUsers') }}</span></template>
|
||||
<template #default="{items}">
|
||||
<div class="user" v-for="(block, i) in items" :key="block.id" :data-index="i">
|
||||
<router-link class="name" :to="block.blockee | userPage">
|
||||
<mk-acct :user="block.blockee"/>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</mk-pagination>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faBan } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkPagination from '../../components/ui/pagination.vue';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkPagination,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
mutingPagination: {
|
||||
endpoint: 'mute/list',
|
||||
limit: 10,
|
||||
},
|
||||
blockingPagination: {
|
||||
endpoint: 'blocking/list',
|
||||
limit: 10,
|
||||
},
|
||||
faBan
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-settings-page-mute-block {
|
||||
> ._content {
|
||||
max-height: 350px;
|
||||
overflow: auto;
|
||||
|
||||
> .muting,
|
||||
> .blocking {
|
||||
> .empty {
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
69
src/client/pages/settings/privacy.vue
Normal file
69
src/client/pages/settings/privacy.vue
Normal file
|
@ -0,0 +1,69 @@
|
|||
<template>
|
||||
<section class="mk-settings-page-privacy _section">
|
||||
<div class="_title"><fa :icon="faLock"/> {{ $t('privacy') }}</div>
|
||||
<div class="_content">
|
||||
<mk-switch v-model="isLocked" @change="save()">{{ $t('makeFollowManuallyApprove') }}</mk-switch>
|
||||
<mk-switch v-model="autoAcceptFollowed" :disabled="!isLocked" @change="save()">{{ $t('autoAcceptFollowed') }}</mk-switch>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<mk-select v-model="defaultNoteVisibility" style="margin-top: 8px;">
|
||||
<template #label>{{ $t('defaultNoteVisibility') }}</template>
|
||||
<option value="public">{{ $t('_visibility.public') }}</option>
|
||||
<option value="followers">{{ $t('_visibility.followers') }}</option>
|
||||
<option value="specified">{{ $t('_visibility.specified') }}</option>
|
||||
</mk-select>
|
||||
<mk-switch v-model="rememberNoteVisibility" @change="save()">{{ $t('rememberNoteVisibility') }}</mk-switch>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkSelect from '../../components/ui/select.vue';
|
||||
import MkSwitch from '../../components/ui/switch.vue';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkSelect,
|
||||
MkSwitch,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isLocked: false,
|
||||
autoAcceptFollowed: false,
|
||||
faLock
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
defaultNoteVisibility: {
|
||||
get() { return this.$store.state.settings.defaultNoteVisibility; },
|
||||
set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); }
|
||||
},
|
||||
|
||||
rememberNoteVisibility: {
|
||||
get() { return this.$store.state.settings.rememberNoteVisibility; },
|
||||
set(value) { this.$store.dispatch('settings/set', { key: 'rememberNoteVisibility', value }); }
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.isLocked = this.$store.state.i.isLocked;
|
||||
this.autoAcceptFollowed = this.$store.state.i.autoAcceptFollowed;
|
||||
},
|
||||
|
||||
methods: {
|
||||
save() {
|
||||
this.$root.api('i/update', {
|
||||
isLocked: !!this.isLocked,
|
||||
autoAcceptFollowed: !!this.autoAcceptFollowed,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
246
src/client/pages/settings/profile.vue
Normal file
246
src/client/pages/settings/profile.vue
Normal file
|
@ -0,0 +1,246 @@
|
|||
<template>
|
||||
<section class="mk-settings-page-profile _section">
|
||||
<div class="_title"><fa :icon="faUser"/> {{ $t('profile') }}<small style="display: block; font-weight: normal; opacity: 0.6;">@{{ $store.state.i.username }}@{{ host }}</small></div>
|
||||
<div class="_content">
|
||||
<mk-input v-model="name" :max="30">
|
||||
<span>{{ $t('_profile.name') }}</span>
|
||||
</mk-input>
|
||||
|
||||
<mk-textarea v-model="description" :max="500">
|
||||
<span>{{ $t('_profile.description') }}</span>
|
||||
<template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template>
|
||||
</mk-textarea>
|
||||
|
||||
<mk-input v-model="location">
|
||||
<span>{{ $t('location') }}</span>
|
||||
<template #prefix><fa :icon="faMapMarkerAlt"/></template>
|
||||
</mk-input>
|
||||
|
||||
<mk-input v-model="birthday" type="date">
|
||||
<template #title>{{ $t('birthday') }}</template>
|
||||
<template #prefix><fa :icon="faBirthdayCake"/></template>
|
||||
</mk-input>
|
||||
|
||||
<mk-input type="file" @change="onAvatarChange">
|
||||
<span>{{ $t('avatar') }}</span>
|
||||
<template #icon><fa :icon="faImage"/></template>
|
||||
<template #desc v-if="avatarUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
|
||||
</mk-input>
|
||||
|
||||
<mk-input type="file" @change="onBannerChange">
|
||||
<span>{{ $t('banner') }}</span>
|
||||
<template #icon><fa :icon="faImage"/></template>
|
||||
<template #desc v-if="bannerUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
|
||||
</mk-input>
|
||||
|
||||
<details class="fields">
|
||||
<summary>{{ $t('_profile.metadata') }}</summary>
|
||||
<div class="row">
|
||||
<mk-input v-model="fieldName0">{{ $t('_profile.metadataLabel') }}</mk-input>
|
||||
<mk-input v-model="fieldValue0">{{ $t('_profile.metadataContent') }}</mk-input>
|
||||
</div>
|
||||
<div class="row">
|
||||
<mk-input v-model="fieldName1">{{ $t('_profile.metadataLabel') }}</mk-input>
|
||||
<mk-input v-model="fieldValue1">{{ $t('_profile.metadataContent') }}</mk-input>
|
||||
</div>
|
||||
<div class="row">
|
||||
<mk-input v-model="fieldName2">{{ $t('_profile.metadataLabel') }}</mk-input>
|
||||
<mk-input v-model="fieldValue2">{{ $t('_profile.metadataContent') }}</mk-input>
|
||||
</div>
|
||||
<div class="row">
|
||||
<mk-input v-model="fieldName3">{{ $t('_profile.metadataLabel') }}</mk-input>
|
||||
<mk-input v-model="fieldValue3">{{ $t('_profile.metadataContent') }}</mk-input>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<mk-switch v-model="isBot">{{ $t('flagAsBot') }}</mk-switch>
|
||||
<mk-switch v-model="isCat">{{ $t('flagAsCat') }}</mk-switch>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button @click="save(true)" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faUnlockAlt, faCogs, faImage, faUser, faMapMarkerAlt, faBirthdayCake } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faSave } from '@fortawesome/free-regular-svg-icons';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkInput from '../../components/ui/input.vue';
|
||||
import MkTextarea from '../../components/ui/textarea.vue';
|
||||
import MkSwitch from '../../components/ui/switch.vue';
|
||||
import i18n from '../../i18n';
|
||||
import { apiUrl, host } from '../../config';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
MkInput,
|
||||
MkTextarea,
|
||||
MkSwitch,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
host,
|
||||
name: null,
|
||||
description: null,
|
||||
birthday: null,
|
||||
location: null,
|
||||
fieldName0: null,
|
||||
fieldValue0: null,
|
||||
fieldName1: null,
|
||||
fieldValue1: null,
|
||||
fieldName2: null,
|
||||
fieldValue2: null,
|
||||
fieldName3: null,
|
||||
fieldValue3: null,
|
||||
avatarId: null,
|
||||
bannerId: null,
|
||||
isBot: false,
|
||||
isCat: false,
|
||||
saving: false,
|
||||
avatarUploading: false,
|
||||
bannerUploading: false,
|
||||
faSave, faUnlockAlt, faCogs, faImage, faUser, faMapMarkerAlt, faBirthdayCake
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.name = this.$store.state.i.name;
|
||||
this.description = this.$store.state.i.description;
|
||||
this.location = this.$store.state.i.location;
|
||||
this.birthday = this.$store.state.i.birthday;
|
||||
this.avatarId = this.$store.state.i.avatarId;
|
||||
this.bannerId = this.$store.state.i.bannerId;
|
||||
this.isBot = this.$store.state.i.isBot;
|
||||
this.isCat = this.$store.state.i.isCat;
|
||||
|
||||
this.fieldName0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].name : null;
|
||||
this.fieldValue0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].value : null;
|
||||
this.fieldName1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].name : null;
|
||||
this.fieldValue1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].value : null;
|
||||
this.fieldName2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].name : null;
|
||||
this.fieldValue2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].value : null;
|
||||
this.fieldName3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].name : null;
|
||||
this.fieldValue3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].value : null;
|
||||
},
|
||||
|
||||
methods: {
|
||||
onAvatarChange([file]) {
|
||||
this.avatarUploading = true;
|
||||
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
data.append('i', this.$store.state.i.token);
|
||||
|
||||
fetch(apiUrl + '/drive/files/create', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(f => {
|
||||
this.avatarId = f.id;
|
||||
this.avatarUploading = false;
|
||||
})
|
||||
.catch(e => {
|
||||
this.avatarUploading = false;
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
onBannerChange([file]) {
|
||||
this.bannerUploading = true;
|
||||
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
data.append('i', this.$store.state.i.token);
|
||||
|
||||
fetch(apiUrl + '/drive/files/create', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(f => {
|
||||
this.bannerId = f.id;
|
||||
this.bannerUploading = false;
|
||||
})
|
||||
.catch(e => {
|
||||
this.bannerUploading = false;
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
save(notify) {
|
||||
const fields = [
|
||||
{ name: this.fieldName0, value: this.fieldValue0 },
|
||||
{ name: this.fieldName1, value: this.fieldValue1 },
|
||||
{ name: this.fieldName2, value: this.fieldValue2 },
|
||||
{ name: this.fieldName3, value: this.fieldValue3 },
|
||||
];
|
||||
|
||||
this.saving = true;
|
||||
|
||||
this.$root.api('i/update', {
|
||||
name: this.name || null,
|
||||
description: this.description || null,
|
||||
location: this.location || null,
|
||||
birthday: this.birthday || null,
|
||||
avatarId: this.avatarId || undefined,
|
||||
bannerId: this.bannerId || undefined,
|
||||
fields,
|
||||
isBot: !!this.isBot,
|
||||
isCat: !!this.isCat,
|
||||
}).then(i => {
|
||||
this.saving = false;
|
||||
this.$store.state.i.avatarId = i.avatarId;
|
||||
this.$store.state.i.avatarUrl = i.avatarUrl;
|
||||
this.$store.state.i.bannerId = i.bannerId;
|
||||
this.$store.state.i.bannerUrl = i.bannerUrl;
|
||||
|
||||
if (notify) {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
this.saving = false;
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: err.id
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-settings-page-profile {
|
||||
> ._content {
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> .fields {
|
||||
> .row {
|
||||
> * {
|
||||
display: inline-block;
|
||||
width: 50%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
62
src/client/pages/settings/reaction.vue
Normal file
62
src/client/pages/settings/reaction.vue
Normal file
|
@ -0,0 +1,62 @@
|
|||
<template>
|
||||
<section class="mk-settings-page-reaction _section">
|
||||
<div class="_title"><fa :icon="faLaugh"/> {{ $t('reaction') }}</div>
|
||||
<div class="_content">
|
||||
<mk-textarea v-model="reactions" style="margin-top: 16px;">{{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }}</template></mk-textarea>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<mk-button @click="save()" primary inline :disabled="!changed"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
|
||||
<mk-button inline @click="preview"><fa :icon="faEye"/> {{ $t('preview') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons';
|
||||
import MkTextarea from '../../components/ui/textarea.vue';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkReactionPicker from '../../components/reaction-picker.vue';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkTextarea,
|
||||
MkButton,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
reactions: this.$store.state.settings.reactions.join('\n'),
|
||||
changed: false,
|
||||
faLaugh, faSave, faEye
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
reactions() {
|
||||
this.changed = true;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
save() {
|
||||
this.$store.dispatch('settings/set', { key: 'reactions', value: this.reactions.trim().split('\n') });
|
||||
this.changed = false;
|
||||
},
|
||||
|
||||
preview(ev) {
|
||||
const picker = this.$root.new(MkReactionPicker, {
|
||||
source: ev.currentTarget || ev.target,
|
||||
reactions: this.reactions.trim().split('\n'),
|
||||
showFocus: false,
|
||||
});
|
||||
picker.$once('chosen', reaction => {
|
||||
picker.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
87
src/client/pages/settings/security.vue
Normal file
87
src/client/pages/settings/security.vue
Normal file
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<section class="_section">
|
||||
<div class="_title"><fa :icon="faLock"/> {{ $t('password') }}</div>
|
||||
<div class="_content">
|
||||
<mk-button primary @click="change()">{{ $t('changePassword') }}</mk-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkButton,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faLock
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async change() {
|
||||
const { canceled: canceled1, result: currentPassword } = await this.$root.dialog({
|
||||
title: this.$t('currentPassword'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
});
|
||||
if (canceled1) return;
|
||||
|
||||
const { canceled: canceled2, result: newPassword } = await this.$root.dialog({
|
||||
title: this.$t('newPassword'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
});
|
||||
if (canceled2) return;
|
||||
|
||||
const { canceled: canceled3, result: newPassword2 } = await this.$root.dialog({
|
||||
title: this.$t('newPasswordRetype'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
});
|
||||
if (canceled3) return;
|
||||
|
||||
if (newPassword !== newPassword2) {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('retypedNotMatch')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = this.$root.dialog({
|
||||
type: 'waiting',
|
||||
iconOnly: true
|
||||
});
|
||||
|
||||
this.$root.api('i/change-password', {
|
||||
currentPassword,
|
||||
newPassword
|
||||
}).then(() => {
|
||||
this.$root.dialog({
|
||||
type: 'success',
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
}).catch(e => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
}).finally(() => {
|
||||
dialog.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
76
src/client/pages/settings/theme.vue
Normal file
76
src/client/pages/settings/theme.vue
Normal file
|
@ -0,0 +1,76 @@
|
|||
<template>
|
||||
<section class="mk-settings-page-theme _section">
|
||||
<div class="_title"><fa :icon="faPalette"/> {{ $t('theme') }}</div>
|
||||
<div class="_content">
|
||||
<mk-select v-model="theme" :placeholder="$t('theme')">
|
||||
<template #label>{{ $t('theme') }}</template>
|
||||
<optgroup :label="$t('lightThemes')">
|
||||
<option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="$t('darkThemes')">
|
||||
<option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
|
||||
</optgroup>
|
||||
</mk-select>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faPalette } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkInput from '../../components/ui/input.vue';
|
||||
import MkButton from '../../components/ui/button.vue';
|
||||
import MkSelect from '../../components/ui/select.vue';
|
||||
import i18n from '../../i18n';
|
||||
import { Theme, builtinThemes, applyTheme } from '../../theme';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkInput,
|
||||
MkButton,
|
||||
MkSelect,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
wallpaperUploading: false,
|
||||
faPalette
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
themes(): Theme[] {
|
||||
return builtinThemes.concat(this.$store.state.device.themes);
|
||||
},
|
||||
|
||||
installedThemes(): Theme[] {
|
||||
return this.$store.state.device.themes;
|
||||
},
|
||||
|
||||
darkThemes(): Theme[] {
|
||||
return this.themes.filter(t => t.base == 'dark' || t.kind == 'dark');
|
||||
},
|
||||
|
||||
lightThemes(): Theme[] {
|
||||
return this.themes.filter(t => t.base == 'light' || t.kind == 'light');
|
||||
},
|
||||
|
||||
theme: {
|
||||
get() { return this.$store.state.device.theme; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'theme', value }); }
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
theme() {
|
||||
applyTheme(this.themes.find(x => x.id === this.theme));
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
}
|
||||
});
|
||||
</script>
|
49
src/client/pages/tag.vue
Normal file
49
src/client/pages/tag.vue
Normal file
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<x-notes ref="notes" :pagination="pagination" @before="before" @after="after"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Progress from '../scripts/loading';
|
||||
import XNotes from '../components/notes.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
metaInfo() {
|
||||
return {
|
||||
title: '#' + this.$route.params.tag
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
XNotes
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination: {
|
||||
endpoint: 'notes/search-by-tag',
|
||||
limit: 10,
|
||||
params: () => ({
|
||||
tag: this.$route.params.tag,
|
||||
})
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route() {
|
||||
(this.$refs.notes as any).reload();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
before() {
|
||||
Progress.start();
|
||||
},
|
||||
|
||||
after() {
|
||||
Progress.done();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
140
src/client/pages/user/follow-list.vue
Normal file
140
src/client/pages/user/follow-list.vue
Normal file
|
@ -0,0 +1,140 @@
|
|||
<template>
|
||||
<mk-pagination :pagination="pagination" #default="{items}" class="mk-following-or-followers" ref="list">
|
||||
<div class="user _panel" v-for="(user, i) in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" :data-index="i">
|
||||
<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>
|
||||
<p class="acct">@{{ user | acct }}</p>
|
||||
</div>
|
||||
<div class="description" v-if="user.description" :title="user.description">
|
||||
<mfm :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :plain="true" :nowrap="true"/>
|
||||
</div>
|
||||
<x-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/>
|
||||
</div>
|
||||
</div>
|
||||
</mk-pagination>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import parseAcct from '../../../misc/acct/parse';
|
||||
import i18n from '../../i18n';
|
||||
import XFollowButton from '../../components/follow-button.vue';
|
||||
import MkPagination from '../../components/ui/pagination.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkPagination,
|
||||
XFollowButton,
|
||||
},
|
||||
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination: {
|
||||
endpoint: () => this.type === 'following' ? 'users/following' : 'users/followers',
|
||||
limit: 20,
|
||||
params: {
|
||||
...parseAcct(this.$route.params.user),
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
type() {
|
||||
this.$refs.list.reload();
|
||||
},
|
||||
|
||||
'$route'() {
|
||||
this.$refs.list.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-following-or-followers {
|
||||
> .user {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
|
||||
> .avatar {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
margin: 0 12px 0 0;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
display: flex;
|
||||
width: calc(100% - 54px);
|
||||
position: relative;
|
||||
|
||||
> .name {
|
||||
width: 45%;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> .name,
|
||||
> .acct {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> .name {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
> .acct {
|
||||
font-size: 15px;
|
||||
line-height: 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
> .description {
|
||||
width: 55%;
|
||||
line-height: 42px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.7;
|
||||
font-size: 14px;
|
||||
padding-right: 40px;
|
||||
padding-left: 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .koudoku-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: auto 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
114
src/client/pages/user/index.activity.vue
Normal file
114
src/client/pages/user/index.activity.vue
Normal file
|
@ -0,0 +1,114 @@
|
|||
<template>
|
||||
<div>
|
||||
<div ref="chart"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import ApexCharts from 'apexcharts';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 40
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fetching: true,
|
||||
data: [],
|
||||
peak: null
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$root.api('charts/user/notes', {
|
||||
userId: this.user.id,
|
||||
span: 'day',
|
||||
limit: this.limit
|
||||
}).then(stats => {
|
||||
const normal = [];
|
||||
const reply = [];
|
||||
const renote = [];
|
||||
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth();
|
||||
const d = now.getDate();
|
||||
|
||||
for (let i = 0; i < this.limit; i++) {
|
||||
const x = new Date(y, m, d - i);
|
||||
normal.push([
|
||||
x,
|
||||
stats.diffs.normal[i]
|
||||
]);
|
||||
reply.push([
|
||||
x,
|
||||
stats.diffs.reply[i]
|
||||
]);
|
||||
renote.push([
|
||||
x,
|
||||
stats.diffs.renote[i]
|
||||
]);
|
||||
}
|
||||
|
||||
const chart = new ApexCharts(this.$refs.chart, {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
stacked: true,
|
||||
height: 100,
|
||||
sparkline: {
|
||||
enabled: true
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
columnWidth: '40%'
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
grid: {
|
||||
clipMarkers: false,
|
||||
padding: {
|
||||
top: 0,
|
||||
right: 8,
|
||||
bottom: 0,
|
||||
left: 8
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
shared: true,
|
||||
intersect: false
|
||||
},
|
||||
series: [{
|
||||
name: 'Normal',
|
||||
data: normal
|
||||
}, {
|
||||
name: 'Reply',
|
||||
data: reply
|
||||
}, {
|
||||
name: 'Renote',
|
||||
data: renote
|
||||
}],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
crosshairs: {
|
||||
width: 1,
|
||||
opacity: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chart.render();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
98
src/client/pages/user/index.photos.vue
Normal file
98
src/client/pages/user/index.photos.vue
Normal file
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<div class="ujigsodd">
|
||||
<mk-loading v-if="fetching"/>
|
||||
<div class="stream" v-if="!fetching && images.length > 0">
|
||||
<a v-for="(image, i) in images" :key="i"
|
||||
class="img"
|
||||
:style="`background-image: url(${thumbnail(image.file)})`"
|
||||
:href="image.note | notePage"
|
||||
></a>
|
||||
</div>
|
||||
<p class="empty" v-if="!fetching && images.length == 0">{{ $t('no-photos') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../../i18n';
|
||||
import { getStaticImageUrl } from '../../scripts/get-static-image-url';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n,
|
||||
props: ['user'],
|
||||
data() {
|
||||
return {
|
||||
fetching: true,
|
||||
images: []
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const image = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/apng',
|
||||
'image/vnd.mozilla.apng',
|
||||
];
|
||||
this.$root.api('users/notes', {
|
||||
userId: this.user.id,
|
||||
fileType: image,
|
||||
excludeNsfw: !this.$store.state.device.alwaysShowNsfw,
|
||||
limit: 9,
|
||||
}).then(notes => {
|
||||
for (const note of notes) {
|
||||
for (const file of note.files) {
|
||||
if (this.images.length < 9) {
|
||||
this.images.push({
|
||||
note,
|
||||
file
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.fetching = false;
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
thumbnail(image: any): string {
|
||||
return this.$store.state.device.disableShowingAnimatedImages
|
||||
? getStaticImageUrl(image.thumbnailUrl)
|
||||
: image.thumbnailUrl;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ujigsodd {
|
||||
|
||||
> .stream {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px;
|
||||
|
||||
> .img {
|
||||
flex: 1 1 33%;
|
||||
width: 33%;
|
||||
height: 90px;
|
||||
box-sizing: border-box;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
background-clip: content-box;
|
||||
border: solid 2px transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .empty {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
79
src/client/pages/user/index.timeline.vue
Normal file
79
src/client/pages/user/index.timeline.vue
Normal file
|
@ -0,0 +1,79 @@
|
|||
<template>
|
||||
<div class="kjeftjfm">
|
||||
<div class="with">
|
||||
<button class="_button" @click="with_ = null" :class="{ active: with_ === null }">{{ $t('notes') }}</button>
|
||||
<button class="_button" @click="with_ = 'replies'" :class="{ active: with_ === 'replies' }">{{ $t('notesAndReplies') }}</button>
|
||||
<button class="_button" @click="with_ = 'files'" :class="{ active: with_ === 'files' }">{{ $t('withFiles') }}</button>
|
||||
</div>
|
||||
<x-notes ref="timeline" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import XNotes from '../../components/notes.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XNotes
|
||||
},
|
||||
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
user() {
|
||||
this.$refs.timeline.reload();
|
||||
},
|
||||
|
||||
with_() {
|
||||
this.$refs.timeline.reload();
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
date: null,
|
||||
with_: null,
|
||||
pagination: {
|
||||
endpoint: 'users/notes',
|
||||
limit: 10,
|
||||
params: init => ({
|
||||
userId: this.user.id,
|
||||
includeReplies: this.with_ === 'replies',
|
||||
withFiles: this.with_ === 'files',
|
||||
untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
|
||||
})
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.kjeftjfm {
|
||||
> .with {
|
||||
display: flex;
|
||||
margin-bottom: var(--margin);
|
||||
|
||||
@media (max-width: 500px) {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
> button {
|
||||
flex: 1;
|
||||
padding: 11px 8px 8px 8px;
|
||||
border-bottom: solid 3px transparent;
|
||||
|
||||
&.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
476
src/client/pages/user/index.vue
Normal file
476
src/client/pages/user/index.vue
Normal file
|
@ -0,0 +1,476 @@
|
|||
<template>
|
||||
<div class="mk-user-page" v-if="user">
|
||||
<portal to="title" v-if="user"><mk-user-name :user="user" :nowrap="false" class="name"/></portal>
|
||||
<portal to="avatar" v-if="user"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal>
|
||||
|
||||
<div class="remote-caution _panel" v-if="user.host != null"><fa :icon="faExclamationTriangle" style="margin-right: 8px;"/>{{ $t('remoteUserCaution') }}<a :href="user.url" rel="nofollow noopener" target="_blank">{{ $t('showOnRemote') }}</a></div>
|
||||
<transition name="zoom" mode="out-in" appear>
|
||||
<div class="profile _panel" :key="user.id">
|
||||
<div class="banner-container" :style="style">
|
||||
<div class="banner" ref="banner" :style="style"></div>
|
||||
<div class="fade"></div>
|
||||
<div class="title">
|
||||
<mk-user-name class="name" :user="user" :nowrap="true"/>
|
||||
<div class="bottom">
|
||||
<span class="username"><mk-acct :user="user" :detail="true" /></span>
|
||||
<span v-if="user.isAdmin" :title="$t('isAdmin')"><fa :icon="faBookmark"/></span>
|
||||
<span v-if="user.isLocked" :title="$t('isLocked')"><fa :icon="faLock"/></span>
|
||||
<span v-if="user.isBot" :title="$t('isBot')"><fa :icon="faRobot"/></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="followed" v-if="$store.getters.isSignedIn && $store.state.i.id != user.id && user.isFollowed">{{ $t('followsYou') }}</span>
|
||||
<div class="actions" v-if="$store.getters.isSignedIn">
|
||||
<button @click="menu" class="menu _button" ref="menu"><fa :icon="faEllipsisH"/></button>
|
||||
<x-follow-button v-if="$store.state.i.id != user.id" :user="user" :inline="true" :transparent="false" class="koudoku"/>
|
||||
</div>
|
||||
</div>
|
||||
<mk-avatar class="avatar" :user="user" :disable-preview="true"/>
|
||||
<div class="title">
|
||||
<mk-user-name :user="user" :nowrap="false" class="name"/>
|
||||
<div class="bottom">
|
||||
<span class="username"><mk-acct :user="user" :detail="true" /></span>
|
||||
<span v-if="user.isAdmin" :title="$t('isAdmin')"><fa :icon="faBookmark"/></span>
|
||||
<span v-if="user.isLocked" :title="$t('isLocked')"><fa :icon="faLock"/></span>
|
||||
<span v-if="user.isBot" :title="$t('isBot')"><fa :icon="faRobot"/></span>
|
||||
</div>
|
||||
</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"/>
|
||||
<p v-else class="empty">{{ $t('noAccountDescription') }}</p>
|
||||
</div>
|
||||
<div class="fields system">
|
||||
<dl class="field" v-if="user.location">
|
||||
<dt class="name"><fa :icon="faMapMarker" fixed-width/> {{ $t('location') }}</dt>
|
||||
<dd class="value">{{ user.location }}</dd>
|
||||
</dl>
|
||||
<dl class="field" v-if="user.birthday">
|
||||
<dt class="name"><fa :icon="faBirthdayCake" fixed-width/> {{ $t('birthday') }}</dt>
|
||||
<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
|
||||
</dl>
|
||||
<dl class="field">
|
||||
<dt class="name"><fa :icon="faCalendarAlt" fixed-width/> {{ $t('registeredDate') }}</dt>
|
||||
<dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<mk-time :time="user.createdAt"/>)</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="fields" v-if="user.fields.length > 0">
|
||||
<dl class="field" v-for="(field, i) in user.fields" :key="i">
|
||||
<dt class="name">
|
||||
<mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
|
||||
</dt>
|
||||
<dd class="value">
|
||||
<mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :colored="false"/>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="status" v-if="user.host === null">
|
||||
<router-link :to="user | userPage()" :class="{ active: $route.name === 'user' }">
|
||||
<b>{{ user.notesCount | number }}</b>
|
||||
<span>{{ $t('notes') }}</span>
|
||||
</router-link>
|
||||
<router-link :to="user | userPage('following')" :class="{ active: $route.name === 'userFollowing' }">
|
||||
<b>{{ user.followingCount | number }}</b>
|
||||
<span>{{ $t('following') }}</span>
|
||||
</router-link>
|
||||
<router-link :to="user | userPage('followers')" :class="{ active: $route.name === 'userFollowers' }">
|
||||
<b>{{ user.followersCount | number }}</b>
|
||||
<span>{{ $t('followers') }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<router-view :user="user"></router-view>
|
||||
<template v-if="$route.name == 'user'">
|
||||
<sequential-entrance class="pins">
|
||||
<x-note v-for="(note, i) in user.pinnedNotes" class="note" :note="note" :key="note.id" :data-index="i" :detail="true" :pinned="true"/>
|
||||
</sequential-entrance>
|
||||
<mk-container :body-togglable="true" class="content">
|
||||
<template #header><fa :icon="faImage"/>{{ $t('images') }}</template>
|
||||
<div>
|
||||
<x-photos :user="user" :key="user.id"/>
|
||||
</div>
|
||||
</mk-container>
|
||||
<mk-container :body-togglable="true" class="content">
|
||||
<template #header><fa :icon="faChartBar"/>{{ $t('activity') }}</template>
|
||||
<div style="padding:8px;">
|
||||
<x-activity :user="user" :key="user.id"/>
|
||||
</div>
|
||||
</mk-container>
|
||||
<x-user-timeline :user="user"/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="error">
|
||||
<mk-error @retry="fetch()"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { faEllipsisH, faRobot, faLock, faBookmark, faExclamationTriangle, faChartBar, faImage, faBirthdayCake, faMapMarker } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import * as age from 's-age';
|
||||
import XUserTimeline from './index.timeline.vue';
|
||||
import XUserMenu from '../../components/user-menu.vue';
|
||||
import XNote from '../../components/note.vue';
|
||||
import XFollowButton from '../../components/follow-button.vue';
|
||||
import MkContainer from '../../components/ui/container.vue';
|
||||
import Progress from '../../scripts/loading';
|
||||
import parseAcct from '../../../misc/acct/parse';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
XUserTimeline,
|
||||
XNote,
|
||||
XFollowButton,
|
||||
MkContainer,
|
||||
XPhotos: () => import('./index.photos.vue').then(m => m.default),
|
||||
XActivity: () => import('./index.activity.vue').then(m => m.default),
|
||||
},
|
||||
|
||||
metaInfo() {
|
||||
return {
|
||||
title: (this.user ? '@' + Vue.filter('acct')(this.user).replace('@', ' | ') : null) as string
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
user: null,
|
||||
error: null,
|
||||
parallaxAnimationId: null,
|
||||
faEllipsisH, faRobot, faLock, faBookmark, faExclamationTriangle, faChartBar, faImage, faBirthdayCake, faMapMarker, faCalendarAlt
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
style(): any {
|
||||
if (this.user.bannerUrl == null) return {};
|
||||
return {
|
||||
backgroundImage: `url(${ this.user.bannerUrl })`
|
||||
};
|
||||
},
|
||||
|
||||
age(): number {
|
||||
return age(this.user.birthday);
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route: 'fetch'
|
||||
},
|
||||
|
||||
created() {
|
||||
this.fetch();
|
||||
},
|
||||
|
||||
mounted() {
|
||||
window.requestAnimationFrame(this.parallaxLoop);
|
||||
window.addEventListener('scroll', this.parallax, { passive: true });
|
||||
document.addEventListener('touchmove', this.parallax, { passive: true });
|
||||
this.$once('hook:beforeDestroy', () => {
|
||||
window.cancelAnimationFrame(this.parallaxAnimationId);
|
||||
window.removeEventListener('scroll', this.parallax);
|
||||
document.removeEventListener('touchmove', this.parallax);
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetch() {
|
||||
Progress.start();
|
||||
this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => {
|
||||
this.user = user;
|
||||
}).catch(e => {
|
||||
this.error = e;
|
||||
}).finally(() => {
|
||||
Progress.done();
|
||||
});
|
||||
},
|
||||
|
||||
menu() {
|
||||
this.$root.new(XUserMenu, {
|
||||
source: this.$refs.menu,
|
||||
user: this.user
|
||||
});
|
||||
},
|
||||
|
||||
parallaxLoop() {
|
||||
this.parallaxAnimationId = window.requestAnimationFrame(this.parallaxLoop);
|
||||
this.parallax();
|
||||
},
|
||||
|
||||
parallax() {
|
||||
const banner = this.$refs.banner as any;
|
||||
if (banner == null) return;
|
||||
|
||||
const top = window.scrollY;
|
||||
|
||||
if (top < 0) return;
|
||||
|
||||
const z = 1.75; // 奥行き(小さいほど奥)
|
||||
const pos = -(top / z);
|
||||
banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-user-page {
|
||||
> .remote-caution {
|
||||
font-size: 0.8em;
|
||||
padding: 16px;
|
||||
margin-bottom: var(--margin);
|
||||
|
||||
> a {
|
||||
margin-left: 4px;
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
> .profile {
|
||||
position: relative;
|
||||
margin-bottom: var(--margin);
|
||||
overflow: hidden;
|
||||
|
||||
> .banner-container {
|
||||
position: relative;
|
||||
height: 250px;
|
||||
overflow: hidden;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
> .banner {
|
||||
height: 100%;
|
||||
background-color: #4c5e6d;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset;
|
||||
}
|
||||
|
||||
> .fade {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 78px;
|
||||
background: linear-gradient(transparent, rgba(#000, 0.7));
|
||||
|
||||
@media (max-width: 500px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .followed {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
padding: 4px 6px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
> .actions {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 8px;
|
||||
border-radius: 24px;
|
||||
|
||||
> .menu {
|
||||
vertical-align: bottom;
|
||||
height: 31px;
|
||||
width: 31px;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 8px #000;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
> .koudoku {
|
||||
margin-left: 4px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
> .title {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 0 0 8px 154px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
margin: 0;
|
||||
line-height: 32px;
|
||||
font-weight: bold;
|
||||
font-size: 1.8em;
|
||||
text-shadow: 0 0 8px #000;
|
||||
}
|
||||
|
||||
> .bottom {
|
||||
> * {
|
||||
display: inline-block;
|
||||
margin-right: 16px;
|
||||
line-height: 20px;
|
||||
opacity: 0.8;
|
||||
|
||||
&.username {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .title {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 50px 8px 16px 8px;
|
||||
font-weight: bold;
|
||||
border-bottom: solid 1px var(--divider);
|
||||
|
||||
@media (max-width: 500px) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
> .bottom {
|
||||
> * {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 170px;
|
||||
left: 16px;
|
||||
z-index: 2;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
box-shadow: 1px 1px 3px rgba(#000, 0.2);
|
||||
|
||||
@media (max-width: 500px) {
|
||||
top: 90px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
> .description {
|
||||
padding: 24px 24px 24px 154px;
|
||||
font-size: 15px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
> .empty {
|
||||
margin: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
> .fields {
|
||||
padding: 24px;
|
||||
font-size: 14px;
|
||||
border-top: solid 1px var(--divider);
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
> .field {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
> .name {
|
||||
width: 30%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
> .value {
|
||||
width: 70%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
&.system > .field > .name {
|
||||
}
|
||||
}
|
||||
|
||||
> .status {
|
||||
display: flex;
|
||||
padding: 24px;
|
||||
border-top: solid 1px var(--divider);
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
> a {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
&.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
> b {
|
||||
display: block;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
> span {
|
||||
font-size: 70%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .pins {
|
||||
> .note {
|
||||
margin-bottom: var(--margin);
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
margin-bottom: var(--margin);
|
||||
}
|
||||
}
|
||||
</style>
|
Loading…
Add table
Add a link
Reference in a new issue