summaryrefslogtreecommitdiff
path: root/src/server/web/app/desktop/views/widgets
diff options
context:
space:
mode:
authorAkihiko Odaki <nekomanma@pixiv.co.jp>2018-03-29 01:20:40 +0900
committerAkihiko Odaki <nekomanma@pixiv.co.jp>2018-03-29 01:54:41 +0900
commit90f8fe7e538bb7e52d2558152a0390e693f39b11 (patch)
tree0f830887053c8f352b1cd0c13ca715fd14c1f030 /src/server/web/app/desktop/views/widgets
parentImplement remote account resolution (diff)
downloadmisskey-90f8fe7e538bb7e52d2558152a0390e693f39b11.tar.gz
misskey-90f8fe7e538bb7e52d2558152a0390e693f39b11.tar.bz2
misskey-90f8fe7e538bb7e52d2558152a0390e693f39b11.zip
Introduce processor
Diffstat (limited to 'src/server/web/app/desktop/views/widgets')
-rw-r--r--src/server/web/app/desktop/views/widgets/activity.vue31
-rw-r--r--src/server/web/app/desktop/views/widgets/channel.channel.form.vue67
-rw-r--r--src/server/web/app/desktop/views/widgets/channel.channel.post.vue71
-rw-r--r--src/server/web/app/desktop/views/widgets/channel.channel.vue106
-rw-r--r--src/server/web/app/desktop/views/widgets/channel.vue107
-rw-r--r--src/server/web/app/desktop/views/widgets/index.ts23
-rw-r--r--src/server/web/app/desktop/views/widgets/messaging.vue59
-rw-r--r--src/server/web/app/desktop/views/widgets/notifications.vue70
-rw-r--r--src/server/web/app/desktop/views/widgets/polls.vue129
-rw-r--r--src/server/web/app/desktop/views/widgets/post-form.vue111
-rw-r--r--src/server/web/app/desktop/views/widgets/profile.vue125
-rw-r--r--src/server/web/app/desktop/views/widgets/timemachine.vue28
-rw-r--r--src/server/web/app/desktop/views/widgets/trends.vue135
-rw-r--r--src/server/web/app/desktop/views/widgets/users.vue172
14 files changed, 1234 insertions, 0 deletions
diff --git a/src/server/web/app/desktop/views/widgets/activity.vue b/src/server/web/app/desktop/views/widgets/activity.vue
new file mode 100644
index 0000000000..0bdf4622af
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/activity.vue
@@ -0,0 +1,31 @@
+<template>
+<mk-activity
+ :design="props.design"
+ :init-view="props.view"
+ :user="os.i"
+ @view-changed="viewChanged"/>
+</template>
+
+<script lang="ts">
+import define from '../../../common/define-widget';
+export default define({
+ name: 'activity',
+ props: () => ({
+ design: 0,
+ view: 0
+ })
+}).extend({
+ methods: {
+ func() {
+ if (this.props.design == 2) {
+ this.props.design = 0;
+ } else {
+ this.props.design++;
+ }
+ },
+ viewChanged(view) {
+ this.props.view = view;
+ }
+ }
+});
+</script>
diff --git a/src/server/web/app/desktop/views/widgets/channel.channel.form.vue b/src/server/web/app/desktop/views/widgets/channel.channel.form.vue
new file mode 100644
index 0000000000..392ba5924b
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/channel.channel.form.vue
@@ -0,0 +1,67 @@
+<template>
+<div class="form">
+ <input v-model="text" :disabled="wait" @keydown="onKeydown" placeholder="書いて">
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ data() {
+ return {
+ text: '',
+ wait: false
+ };
+ },
+ methods: {
+ onKeydown(e) {
+ if (e.which == 10 || e.which == 13) this.post();
+ },
+ post() {
+ this.wait = true;
+
+ let reply = null;
+
+ if (/^>>([0-9]+) /.test(this.text)) {
+ const index = this.text.match(/^>>([0-9]+) /)[1];
+ reply = (this.$parent as any).posts.find(p => p.index.toString() == index);
+ this.text = this.text.replace(/^>>([0-9]+) /, '');
+ }
+
+ (this as any).api('posts/create', {
+ text: this.text,
+ reply_id: reply ? reply.id : undefined,
+ channel_id: (this.$parent as any).channel.id
+ }).then(data => {
+ this.text = '';
+ }).catch(err => {
+ alert('失敗した');
+ }).then(() => {
+ this.wait = false;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.form
+ width 100%
+ height 38px
+ padding 4px
+ border-top solid 1px #ddd
+
+ > input
+ padding 0 8px
+ width 100%
+ height 100%
+ font-size 14px
+ color #55595c
+ border solid 1px #dadada
+ border-radius 4px
+
+ &:hover
+ &:focus
+ border-color #aeaeae
+
+</style>
diff --git a/src/server/web/app/desktop/views/widgets/channel.channel.post.vue b/src/server/web/app/desktop/views/widgets/channel.channel.post.vue
new file mode 100644
index 0000000000..433f9a00aa
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/channel.channel.post.vue
@@ -0,0 +1,71 @@
+<template>
+<div class="post">
+ <header>
+ <a class="index" @click="reply">{{ post.index }}:</a>
+ <router-link class="name" :to="`/@${acct}`" v-user-preview="post.user.id"><b>{{ post.user.name }}</b></router-link>
+ <span>ID:<i>{{ acct }}</i></span>
+ </header>
+ <div>
+ <a v-if="post.reply">&gt;&gt;{{ post.reply.index }}</a>
+ {{ post.text }}
+ <div class="media" v-if="post.media">
+ <a v-for="file in post.media" :href="file.url" target="_blank">
+ <img :src="`${file.url}?thumbnail&size=512`" :alt="file.name" :title="file.name"/>
+ </a>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import getAcct from '../../../../../common/user/get-acct';
+
+export default Vue.extend({
+ props: ['post'],
+ computed: {
+ acct() {
+ return getAcct(this.post.user);
+ }
+ },
+ methods: {
+ reply() {
+ this.$emit('reply', this.post);
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.post
+ margin 0
+ padding 0
+ color #444
+
+ > header
+ position -webkit-sticky
+ position sticky
+ z-index 1
+ top 0
+ padding 8px 4px 4px 16px
+ background rgba(255, 255, 255, 0.9)
+
+ > .index
+ margin-right 0.25em
+
+ > .name
+ margin-right 0.5em
+ color #008000
+
+ > div
+ padding 0 16px 16px 16px
+
+ > .media
+ > a
+ display inline-block
+
+ > img
+ max-width 100%
+ vertical-align bottom
+
+</style>
diff --git a/src/server/web/app/desktop/views/widgets/channel.channel.vue b/src/server/web/app/desktop/views/widgets/channel.channel.vue
new file mode 100644
index 0000000000..de5885bfc1
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/channel.channel.vue
@@ -0,0 +1,106 @@
+<template>
+<div class="channel">
+ <p v-if="fetching">読み込み中<mk-ellipsis/></p>
+ <div v-if="!fetching" ref="posts" class="posts">
+ <p v-if="posts.length == 0">まだ投稿がありません</p>
+ <x-post class="post" v-for="post in posts.slice().reverse()" :post="post" :key="post.id" @reply="reply"/>
+ </div>
+ <x-form class="form" ref="form"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import ChannelStream from '../../../common/scripts/streaming/channel';
+import XForm from './channel.channel.form.vue';
+import XPost from './channel.channel.post.vue';
+
+export default Vue.extend({
+ components: {
+ XForm,
+ XPost
+ },
+ props: ['channel'],
+ data() {
+ return {
+ fetching: true,
+ posts: [],
+ connection: null
+ };
+ },
+ watch: {
+ channel() {
+ this.zap();
+ }
+ },
+ mounted() {
+ this.zap();
+ },
+ beforeDestroy() {
+ this.disconnect();
+ },
+ methods: {
+ zap() {
+ this.fetching = true;
+
+ (this as any).api('channels/posts', {
+ channel_id: this.channel.id
+ }).then(posts => {
+ this.posts = posts;
+ this.fetching = false;
+
+ this.$nextTick(() => {
+ this.scrollToBottom();
+ });
+
+ this.disconnect();
+ this.connection = new ChannelStream((this as any).os, this.channel.id);
+ this.connection.on('post', this.onPost);
+ });
+ },
+ disconnect() {
+ if (this.connection) {
+ this.connection.off('post', this.onPost);
+ this.connection.close();
+ }
+ },
+ onPost(post) {
+ this.posts.unshift(post);
+ this.scrollToBottom();
+ },
+ scrollToBottom() {
+ (this.$refs.posts as any).scrollTop = (this.$refs.posts as any).scrollHeight;
+ },
+ reply(post) {
+ (this.$refs.form as any).text = `>>${ post.index } `;
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.channel
+
+ > p
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .posts
+ height calc(100% - 38px)
+ overflow auto
+ font-size 0.9em
+
+ > .post
+ border-bottom solid 1px #eee
+
+ &:last-child
+ border-bottom none
+
+ > .form
+ position absolute
+ left 0
+ bottom 0
+
+</style>
diff --git a/src/server/web/app/desktop/views/widgets/channel.vue b/src/server/web/app/desktop/views/widgets/channel.vue
new file mode 100644
index 0000000000..fc143bb1df
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/channel.vue
@@ -0,0 +1,107 @@
+<template>
+<div class="mkw-channel">
+ <template v-if="!props.compact">
+ <p class="title">%fa:tv%{{ channel ? channel.title : '%i18n:desktop.tags.mk-channel-home-widget.title%' }}</p>
+ <button @click="settings" title="%i18n:desktop.tags.mk-channel-home-widget.settings%">%fa:cog%</button>
+ </template>
+ <p class="get-started" v-if="props.channel == null">%i18n:desktop.tags.mk-channel-home-widget.get-started%</p>
+ <x-channel class="channel" :channel="channel" v-if="channel != null"/>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../common/define-widget';
+import XChannel from './channel.channel.vue';
+
+export default define({
+ name: 'server',
+ props: () => ({
+ channel: null,
+ compact: false
+ })
+}).extend({
+ components: {
+ XChannel
+ },
+ data() {
+ return {
+ fetching: true,
+ channel: null
+ };
+ },
+ mounted() {
+ if (this.props.channel) {
+ this.zap();
+ }
+ },
+ methods: {
+ func() {
+ this.props.compact = !this.props.compact;
+ },
+ settings() {
+ const id = window.prompt('チャンネルID');
+ if (!id) return;
+ this.props.channel = id;
+ this.zap();
+ },
+ zap() {
+ this.fetching = true;
+
+ (this as any).api('channels/show', {
+ channel_id: this.props.channel
+ }).then(channel => {
+ this.channel = channel;
+ this.fetching = false;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mkw-channel
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.075)
+ border-radius 6px
+ overflow hidden
+
+ > .title
+ z-index 2
+ margin 0
+ padding 0 16px
+ line-height 42px
+ font-size 0.9em
+ font-weight bold
+ color #888
+ box-shadow 0 1px rgba(0, 0, 0, 0.07)
+
+ > [data-fa]
+ margin-right 4px
+
+ > button
+ position absolute
+ z-index 2
+ top 0
+ right 0
+ padding 0
+ width 42px
+ font-size 0.9em
+ line-height 42px
+ color #ccc
+
+ &:hover
+ color #aaa
+
+ &:active
+ color #999
+
+ > .get-started
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .channel
+ height 200px
+
+</style>
diff --git a/src/server/web/app/desktop/views/widgets/index.ts b/src/server/web/app/desktop/views/widgets/index.ts
new file mode 100644
index 0000000000..77d771d6b3
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/index.ts
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+
+import wNotifications from './notifications.vue';
+import wTimemachine from './timemachine.vue';
+import wActivity from './activity.vue';
+import wTrends from './trends.vue';
+import wUsers from './users.vue';
+import wPolls from './polls.vue';
+import wPostForm from './post-form.vue';
+import wMessaging from './messaging.vue';
+import wChannel from './channel.vue';
+import wProfile from './profile.vue';
+
+Vue.component('mkw-notifications', wNotifications);
+Vue.component('mkw-timemachine', wTimemachine);
+Vue.component('mkw-activity', wActivity);
+Vue.component('mkw-trends', wTrends);
+Vue.component('mkw-users', wUsers);
+Vue.component('mkw-polls', wPolls);
+Vue.component('mkw-post-form', wPostForm);
+Vue.component('mkw-messaging', wMessaging);
+Vue.component('mkw-channel', wChannel);
+Vue.component('mkw-profile', wProfile);
diff --git a/src/server/web/app/desktop/views/widgets/messaging.vue b/src/server/web/app/desktop/views/widgets/messaging.vue
new file mode 100644
index 0000000000..2c9f473bd1
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/messaging.vue
@@ -0,0 +1,59 @@
+<template>
+<div class="mkw-messaging">
+ <p class="title" v-if="props.design == 0">%fa:comments%%i18n:desktop.tags.mk-messaging-home-widget.title%</p>
+ <mk-messaging ref="index" compact @navigate="navigate"/>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../common/define-widget';
+import MkMessagingRoomWindow from '../components/messaging-room-window.vue';
+
+export default define({
+ name: 'messaging',
+ props: () => ({
+ design: 0
+ })
+}).extend({
+ methods: {
+ navigate(user) {
+ (this as any).os.new(MkMessagingRoomWindow, {
+ user: user
+ });
+ },
+ func() {
+ if (this.props.design == 1) {
+ this.props.design = 0;
+ } else {
+ this.props.design++;
+ }
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mkw-messaging
+ overflow hidden
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.075)
+ border-radius 6px
+
+ > .title
+ z-index 2
+ margin 0
+ padding 0 16px
+ line-height 42px
+ font-size 0.9em
+ font-weight bold
+ color #888
+ box-shadow 0 1px rgba(0, 0, 0, 0.07)
+
+ > [data-fa]
+ margin-right 4px
+
+ > .mk-messaging
+ max-height 250px
+ overflow auto
+
+</style>
diff --git a/src/server/web/app/desktop/views/widgets/notifications.vue b/src/server/web/app/desktop/views/widgets/notifications.vue
new file mode 100644
index 0000000000..1a2b3d3f89
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/notifications.vue
@@ -0,0 +1,70 @@
+<template>
+<div class="mkw-notifications">
+ <template v-if="!props.compact">
+ <p class="title">%fa:R bell%%i18n:desktop.tags.mk-notifications-home-widget.title%</p>
+ <button @click="settings" title="%i18n:desktop.tags.mk-notifications-home-widget.settings%">%fa:cog%</button>
+ </template>
+ <mk-notifications/>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../common/define-widget';
+export default define({
+ name: 'notifications',
+ props: () => ({
+ compact: false
+ })
+}).extend({
+ methods: {
+ settings() {
+ alert('not implemented yet');
+ },
+ func() {
+ this.props.compact = !this.props.compact;
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mkw-notifications
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.075)
+ border-radius 6px
+
+ > .title
+ z-index 1
+ margin 0
+ padding 0 16px
+ line-height 42px
+ font-size 0.9em
+ font-weight bold
+ color #888
+ box-shadow 0 1px rgba(0, 0, 0, 0.07)
+
+ > [data-fa]
+ margin-right 4px
+
+ > button
+ position absolute
+ z-index 2
+ top 0
+ right 0
+ padding 0
+ width 42px
+ font-size 0.9em
+ line-height 42px
+ color #ccc
+
+ &:hover
+ color #aaa
+
+ &:active
+ color #999
+
+ > .mk-notifications
+ max-height 300px
+ overflow auto
+
+</style>
diff --git a/src/server/web/app/desktop/views/widgets/polls.vue b/src/server/web/app/desktop/views/widgets/polls.vue
new file mode 100644
index 0000000000..e5db34fc7a
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/polls.vue
@@ -0,0 +1,129 @@
+<template>
+<div class="mkw-polls">
+ <template v-if="!props.compact">
+ <p class="title">%fa:chart-pie%%i18n:desktop.tags.mk-recommended-polls-home-widget.title%</p>
+ <button @click="fetch" title="%i18n:desktop.tags.mk-recommended-polls-home-widget.refresh%">%fa:sync%</button>
+ </template>
+ <div class="poll" v-if="!fetching && poll != null">
+ <p v-if="poll.text"><router-link to="`/@${ acct }/${ poll.id }`">{{ poll.text }}</router-link></p>
+ <p v-if="!poll.text"><router-link to="`/@${ acct }/${ poll.id }`">%fa:link%</router-link></p>
+ <mk-poll :post="poll"/>
+ </div>
+ <p class="empty" v-if="!fetching && poll == null">%i18n:desktop.tags.mk-recommended-polls-home-widget.nothing%</p>
+ <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../common/define-widget';
+import getAcct from '../../../../../common/user/get-acct';
+
+export default define({
+ name: 'polls',
+ props: () => ({
+ compact: false
+ })
+}).extend({
+ computed: {
+ acct() {
+ return getAcct(this.poll.user);
+ },
+ },
+ data() {
+ return {
+ poll: null,
+ fetching: true,
+ offset: 0
+ };
+ },
+ mounted() {
+ this.fetch();
+ },
+ methods: {
+ func() {
+ this.props.compact = !this.props.compact;
+ },
+ fetch() {
+ this.fetching = true;
+ this.poll = null;
+
+ (this as any).api('posts/polls/recommendation', {
+ limit: 1,
+ offset: this.offset
+ }).then(posts => {
+ const poll = posts ? posts[0] : null;
+ if (poll == null) {
+ this.offset = 0;
+ } else {
+ this.offset++;
+ }
+ this.poll = poll;
+ this.fetching = false;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mkw-polls
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.075)
+ border-radius 6px
+
+ > .title
+ margin 0
+ padding 0 16px
+ line-height 42px
+ font-size 0.9em
+ font-weight bold
+ color #888
+ border-bottom solid 1px #eee
+
+ > [data-fa]
+ margin-right 4px
+
+ > button
+ position absolute
+ z-index 2
+ top 0
+ right 0
+ padding 0
+ width 42px
+ font-size 0.9em
+ line-height 42px
+ color #ccc
+
+ &:hover
+ color #aaa
+
+ &:active
+ color #999
+
+ > .poll
+ padding 16px
+ font-size 12px
+ color #555
+
+ > p
+ margin 0 0 8px 0
+
+ > a
+ color inherit
+
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .fetching
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > [data-fa]
+ margin-right 4px
+
+</style>
diff --git a/src/server/web/app/desktop/views/widgets/post-form.vue b/src/server/web/app/desktop/views/widgets/post-form.vue
new file mode 100644
index 0000000000..cf7fd1f2b2
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/post-form.vue
@@ -0,0 +1,111 @@
+<template>
+<div class="mkw-post-form">
+ <template v-if="props.design == 0">
+ <p class="title">%fa:pencil-alt%%i18n:desktop.tags.mk-post-form-home-widget.title%</p>
+ </template>
+ <textarea :disabled="posting" v-model="text" @keydown="onKeydown" placeholder="%i18n:desktop.tags.mk-post-form-home-widget.placeholder%"></textarea>
+ <button @click="post" :disabled="posting">%i18n:desktop.tags.mk-post-form-home-widget.post%</button>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../common/define-widget';
+export default define({
+ name: 'post-form',
+ props: () => ({
+ design: 0
+ })
+}).extend({
+ data() {
+ return {
+ posting: false,
+ text: ''
+ };
+ },
+ methods: {
+ func() {
+ if (this.props.design == 1) {
+ this.props.design = 0;
+ } else {
+ this.props.design++;
+ }
+ },
+ onKeydown(e) {
+ if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post();
+ },
+ post() {
+ this.posting = true;
+
+ (this as any).api('posts/create', {
+ text: this.text
+ }).then(data => {
+ this.clear();
+ }).catch(err => {
+ alert('失敗した');
+ }).then(() => {
+ this.posting = false;
+ });
+ },
+ clear() {
+ this.text = '';
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+.mkw-post-form
+ background #fff
+ overflow hidden
+ border solid 1px rgba(0, 0, 0, 0.075)
+ border-radius 6px
+
+ > .title
+ z-index 1
+ margin 0
+ padding 0 16px
+ line-height 42px
+ font-size 0.9em
+ font-weight bold
+ color #888
+ box-shadow 0 1px rgba(0, 0, 0, 0.07)
+
+ > [data-fa]
+ margin-right 4px
+
+ > textarea
+ display block
+ width 100%
+ max-width 100%
+ min-width 100%
+ padding 16px
+ margin-bottom 28px + 16px
+ border none
+ border-bottom solid 1px #eee
+
+ > button
+ display block
+ position absolute
+ bottom 8px
+ right 8px
+ margin 0
+ padding 0 10px
+ height 28px
+ color $theme-color-foreground
+ background $theme-color !important
+ outline none
+ border none
+ border-radius 4px
+ transition background 0.1s ease
+ cursor pointer
+
+ &:hover
+ background lighten($theme-color, 10%) !important
+
+ &:active
+ background darken($theme-color, 10%) !important
+ transition background 0s ease
+
+</style>
diff --git a/src/server/web/app/desktop/views/widgets/profile.vue b/src/server/web/app/desktop/views/widgets/profile.vue
new file mode 100644
index 0000000000..3940106197
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/profile.vue
@@ -0,0 +1,125 @@
+<template>
+<div class="mkw-profile"
+ :data-compact="props.design == 1 || props.design == 2"
+ :data-melt="props.design == 2"
+>
+ <div class="banner"
+ :style="os.i.banner_url ? `background-image: url(${os.i.banner_url}?thumbnail&size=256)` : ''"
+ title="クリックでバナー編集"
+ @click="os.apis.updateBanner"
+ ></div>
+ <img class="avatar"
+ :src="`${os.i.avatar_url}?thumbnail&size=96`"
+ @click="os.apis.updateAvatar"
+ alt="avatar"
+ title="クリックでアバター編集"
+ v-user-preview="os.i.id"
+ />
+ <router-link class="name" :to="`/@${os.i.username}`">{{ os.i.name }}</router-link>
+ <p class="username">@{{ os.i.username }}</p>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../common/define-widget';
+export default define({
+ name: 'profile',
+ props: () => ({
+ design: 0
+ })
+}).extend({
+ methods: {
+ func() {
+ if (this.props.design == 2) {
+ this.props.design = 0;
+ } else {
+ this.props.design++;
+ }
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mkw-profile
+ overflow hidden
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.075)
+ border-radius 6px
+
+ &[data-compact]
+ > .banner:before
+ content ""
+ display block
+ width 100%
+ height 100%
+ background rgba(0, 0, 0, 0.5)
+
+ > .avatar
+ top ((100px - 58px) / 2)
+ left ((100px - 58px) / 2)
+ border none
+ border-radius 100%
+ box-shadow 0 0 16px rgba(0, 0, 0, 0.5)
+
+ > .name
+ position absolute
+ top 0
+ left 92px
+ margin 0
+ line-height 100px
+ color #fff
+ text-shadow 0 0 8px rgba(0, 0, 0, 0.5)
+
+ > .username
+ display none
+
+ &[data-melt]
+ background transparent !important
+ border none !important
+
+ > .banner
+ visibility hidden
+
+ > .avatar
+ box-shadow none
+
+ > .name
+ color #666
+ text-shadow none
+
+ > .banner
+ height 100px
+ background-color #f5f5f5
+ background-size cover
+ background-position center
+ cursor pointer
+
+ > .avatar
+ display block
+ position absolute
+ top 76px
+ left 16px
+ width 58px
+ height 58px
+ margin 0
+ border solid 3px #fff
+ border-radius 8px
+ vertical-align bottom
+ cursor pointer
+
+ > .name
+ display block
+ margin 10px 0 0 84px
+ line-height 16px
+ font-weight bold
+ color #555
+
+ > .username
+ display block
+ margin 4px 0 8px 84px
+ line-height 16px
+ font-size 0.9em
+ color #999
+
+</style>
diff --git a/src/server/web/app/desktop/views/widgets/timemachine.vue b/src/server/web/app/desktop/views/widgets/timemachine.vue
new file mode 100644
index 0000000000..6db3b14c62
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/timemachine.vue
@@ -0,0 +1,28 @@
+<template>
+<div class="mkw-timemachine">
+ <mk-calendar :design="props.design" @chosen="chosen"/>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../common/define-widget';
+export default define({
+ name: 'timemachine',
+ props: () => ({
+ design: 0
+ })
+}).extend({
+ methods: {
+ chosen(date) {
+ this.$emit('chosen', date);
+ },
+ func() {
+ if (this.props.design == 5) {
+ this.props.design = 0;
+ } else {
+ this.props.design++;
+ }
+ }
+ }
+});
+</script>
diff --git a/src/server/web/app/desktop/views/widgets/trends.vue b/src/server/web/app/desktop/views/widgets/trends.vue
new file mode 100644
index 0000000000..77779787ee
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/trends.vue
@@ -0,0 +1,135 @@
+<template>
+<div class="mkw-trends">
+ <template v-if="!props.compact">
+ <p class="title">%fa:fire%%i18n:desktop.tags.mk-trends-home-widget.title%</p>
+ <button @click="fetch" title="%i18n:desktop.tags.mk-trends-home-widget.refresh%">%fa:sync%</button>
+ </template>
+ <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+ <div class="post" v-else-if="post != null">
+ <p class="text"><router-link :to="`/@${ acct }/${ post.id }`">{{ post.text }}</router-link></p>
+ <p class="author">―<router-link :to="`/@${ acct }`">@{{ acct }}</router-link></p>
+ </div>
+ <p class="empty" v-else>%i18n:desktop.tags.mk-trends-home-widget.nothing%</p>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../common/define-widget';
+import getAcct from '../../../../../common/user/get-acct';
+
+export default define({
+ name: 'trends',
+ props: () => ({
+ compact: false
+ })
+}).extend({
+ computed: {
+ acct() {
+ return getAcct(this.post.user);
+ },
+ },
+ data() {
+ return {
+ post: null,
+ fetching: true,
+ offset: 0
+ };
+ },
+ mounted() {
+ this.fetch();
+ },
+ methods: {
+ func() {
+ this.props.compact = !this.props.compact;
+ },
+ fetch() {
+ this.fetching = true;
+ this.post = null;
+
+ (this as any).api('posts/trend', {
+ limit: 1,
+ offset: this.offset,
+ repost: false,
+ reply: false,
+ media: false,
+ poll: false
+ }).then(posts => {
+ const post = posts ? posts[0] : null;
+ if (post == null) {
+ this.offset = 0;
+ } else {
+ this.offset++;
+ }
+ this.post = post;
+ this.fetching = false;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mkw-trends
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.075)
+ border-radius 6px
+
+ > .title
+ margin 0
+ padding 0 16px
+ line-height 42px
+ font-size 0.9em
+ font-weight bold
+ color #888
+ border-bottom solid 1px #eee
+
+ > [data-fa]
+ margin-right 4px
+
+ > button
+ position absolute
+ z-index 2
+ top 0
+ right 0
+ padding 0
+ width 42px
+ font-size 0.9em
+ line-height 42px
+ color #ccc
+
+ &:hover
+ color #aaa
+
+ &:active
+ color #999
+
+ > .post
+ padding 16px
+ font-size 12px
+ font-style oblique
+ color #555
+
+ > p
+ margin 0
+
+ > .text,
+ > .author
+ > a
+ color inherit
+
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .fetching
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > [data-fa]
+ margin-right 4px
+
+</style>
diff --git a/src/server/web/app/desktop/views/widgets/users.vue b/src/server/web/app/desktop/views/widgets/users.vue
new file mode 100644
index 0000000000..10e3c529ee
--- /dev/null
+++ b/src/server/web/app/desktop/views/widgets/users.vue
@@ -0,0 +1,172 @@
+<template>
+<div class="mkw-users">
+ <template v-if="!props.compact">
+ <p class="title">%fa:users%%i18n:desktop.tags.mk-user-recommendation-home-widget.title%</p>
+ <button @click="refresh" title="%i18n:desktop.tags.mk-user-recommendation-home-widget.refresh%">%fa:sync%</button>
+ </template>
+ <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+ <template v-else-if="users.length != 0">
+ <div class="user" v-for="_user in users">
+ <router-link class="avatar-anchor" :to="`/@${getAcct(_user)}`">
+ <img class="avatar" :src="`${_user.avatar_url}?thumbnail&size=42`" alt="" v-user-preview="_user.id"/>
+ </router-link>
+ <div class="body">
+ <router-link class="name" :to="`/@${getAcct(_user)}`" v-user-preview="_user.id">{{ _user.name }}</router-link>
+ <p class="username">@{{ getAcct(_user) }}</p>
+ </div>
+ <mk-follow-button :user="_user"/>
+ </div>
+ </template>
+ <p class="empty" v-else>%i18n:desktop.tags.mk-user-recommendation-home-widget.no-one%</p>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../common/define-widget';
+import getAcct from '../../../../../common/user/get-acct';
+
+const limit = 3;
+
+export default define({
+ name: 'users',
+ props: () => ({
+ compact: false
+ })
+}).extend({
+ data() {
+ return {
+ users: [],
+ fetching: true,
+ page: 0
+ };
+ },
+ mounted() {
+ this.fetch();
+ },
+ methods: {
+ getAcct,
+ func() {
+ this.props.compact = !this.props.compact;
+ },
+ fetch() {
+ this.fetching = true;
+ this.users = [];
+
+ (this as any).api('users/recommendation', {
+ limit: limit,
+ offset: limit * this.page
+ }).then(users => {
+ this.users = users;
+ this.fetching = false;
+ });
+ },
+ refresh() {
+ if (this.users.length < limit) {
+ this.page = 0;
+ } else {
+ this.page++;
+ }
+ this.fetch();
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mkw-users
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.075)
+ border-radius 6px
+
+ > .title
+ margin 0
+ padding 0 16px
+ line-height 42px
+ font-size 0.9em
+ font-weight bold
+ color #888
+ border-bottom solid 1px #eee
+
+ > [data-fa]
+ margin-right 4px
+
+ > button
+ position absolute
+ z-index 2
+ top 0
+ right 0
+ padding 0
+ width 42px
+ font-size 0.9em
+ line-height 42px
+ color #ccc
+
+ &:hover
+ color #aaa
+
+ &:active
+ color #999
+
+ > .user
+ padding 16px
+ border-bottom solid 1px #eee
+
+ &:last-child
+ border-bottom none
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 12px 0 0
+
+ > .avatar
+ display block
+ width 42px
+ height 42px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
+
+ > .body
+ float left
+ width calc(100% - 54px)
+
+ > .name
+ margin 0
+ font-size 16px
+ line-height 24px
+ color #555
+
+ > .username
+ display block
+ margin 0
+ font-size 15px
+ line-height 16px
+ color #ccc
+
+ > .mk-follow-button
+ position absolute
+ top 16px
+ right 16px
+
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .fetching
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > [data-fa]
+ margin-right 4px
+
+</style>