summaryrefslogtreecommitdiff
path: root/src/server/web/app/mobile
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/mobile
parentImplement remote account resolution (diff)
downloadsharkey-90f8fe7e538bb7e52d2558152a0390e693f39b11.tar.gz
sharkey-90f8fe7e538bb7e52d2558152a0390e693f39b11.tar.bz2
sharkey-90f8fe7e538bb7e52d2558152a0390e693f39b11.zip
Introduce processor
Diffstat (limited to 'src/server/web/app/mobile')
-rw-r--r--src/server/web/app/mobile/api/choose-drive-file.ts18
-rw-r--r--src/server/web/app/mobile/api/choose-drive-folder.ts17
-rw-r--r--src/server/web/app/mobile/api/dialog.ts5
-rw-r--r--src/server/web/app/mobile/api/input.ts8
-rw-r--r--src/server/web/app/mobile/api/notify.ts3
-rw-r--r--src/server/web/app/mobile/api/post.ts43
-rw-r--r--src/server/web/app/mobile/script.ts84
-rw-r--r--src/server/web/app/mobile/style.styl15
-rw-r--r--src/server/web/app/mobile/views/components/activity.vue62
-rw-r--r--src/server/web/app/mobile/views/components/drive-file-chooser.vue98
-rw-r--r--src/server/web/app/mobile/views/components/drive-folder-chooser.vue78
-rw-r--r--src/server/web/app/mobile/views/components/drive.file-detail.vue295
-rw-r--r--src/server/web/app/mobile/views/components/drive.file.vue171
-rw-r--r--src/server/web/app/mobile/views/components/drive.folder.vue58
-rw-r--r--src/server/web/app/mobile/views/components/drive.vue581
-rw-r--r--src/server/web/app/mobile/views/components/follow-button.vue123
-rw-r--r--src/server/web/app/mobile/views/components/friends-maker.vue127
-rw-r--r--src/server/web/app/mobile/views/components/index.ts47
-rw-r--r--src/server/web/app/mobile/views/components/media-image.vue31
-rw-r--r--src/server/web/app/mobile/views/components/media-video.vue36
-rw-r--r--src/server/web/app/mobile/views/components/notification-preview.vue128
-rw-r--r--src/server/web/app/mobile/views/components/notification.vue164
-rw-r--r--src/server/web/app/mobile/views/components/notifications.vue168
-rw-r--r--src/server/web/app/mobile/views/components/notify.vue49
-rw-r--r--src/server/web/app/mobile/views/components/post-card.vue89
-rw-r--r--src/server/web/app/mobile/views/components/post-detail.sub.vue109
-rw-r--r--src/server/web/app/mobile/views/components/post-detail.vue447
-rw-r--r--src/server/web/app/mobile/views/components/post-form.vue276
-rw-r--r--src/server/web/app/mobile/views/components/post-preview.vue106
-rw-r--r--src/server/web/app/mobile/views/components/post.sub.vue115
-rw-r--r--src/server/web/app/mobile/views/components/post.vue523
-rw-r--r--src/server/web/app/mobile/views/components/posts.vue111
-rw-r--r--src/server/web/app/mobile/views/components/sub-post-content.vue43
-rw-r--r--src/server/web/app/mobile/views/components/timeline.vue109
-rw-r--r--src/server/web/app/mobile/views/components/ui.header.vue242
-rw-r--r--src/server/web/app/mobile/views/components/ui.nav.vue244
-rw-r--r--src/server/web/app/mobile/views/components/ui.vue75
-rw-r--r--src/server/web/app/mobile/views/components/user-card.vue69
-rw-r--r--src/server/web/app/mobile/views/components/user-preview.vue110
-rw-r--r--src/server/web/app/mobile/views/components/user-timeline.vue76
-rw-r--r--src/server/web/app/mobile/views/components/users-list.vue133
-rw-r--r--src/server/web/app/mobile/views/components/widget-container.vue68
-rw-r--r--src/server/web/app/mobile/views/directives/index.ts6
-rw-r--r--src/server/web/app/mobile/views/directives/user-preview.ts2
-rw-r--r--src/server/web/app/mobile/views/pages/drive.vue107
-rw-r--r--src/server/web/app/mobile/views/pages/followers.vue65
-rw-r--r--src/server/web/app/mobile/views/pages/following.vue65
-rw-r--r--src/server/web/app/mobile/views/pages/home.vue259
-rw-r--r--src/server/web/app/mobile/views/pages/index.vue16
-rw-r--r--src/server/web/app/mobile/views/pages/messaging-room.vue42
-rw-r--r--src/server/web/app/mobile/views/pages/messaging.vue23
-rw-r--r--src/server/web/app/mobile/views/pages/notifications.vue32
-rw-r--r--src/server/web/app/mobile/views/pages/othello.vue50
-rw-r--r--src/server/web/app/mobile/views/pages/post.vue85
-rw-r--r--src/server/web/app/mobile/views/pages/profile-setting.vue226
-rw-r--r--src/server/web/app/mobile/views/pages/search.vue93
-rw-r--r--src/server/web/app/mobile/views/pages/selectdrive.vue96
-rw-r--r--src/server/web/app/mobile/views/pages/settings.vue102
-rw-r--r--src/server/web/app/mobile/views/pages/signup.vue57
-rw-r--r--src/server/web/app/mobile/views/pages/user.vue247
-rw-r--r--src/server/web/app/mobile/views/pages/user/home.followers-you-know.vue67
-rw-r--r--src/server/web/app/mobile/views/pages/user/home.friends.vue54
-rw-r--r--src/server/web/app/mobile/views/pages/user/home.photos.vue83
-rw-r--r--src/server/web/app/mobile/views/pages/user/home.posts.vue57
-rw-r--r--src/server/web/app/mobile/views/pages/user/home.vue94
-rw-r--r--src/server/web/app/mobile/views/pages/welcome.vue206
-rw-r--r--src/server/web/app/mobile/views/widgets/activity.vue32
-rw-r--r--src/server/web/app/mobile/views/widgets/index.ts7
-rw-r--r--src/server/web/app/mobile/views/widgets/profile.vue62
69 files changed, 7589 insertions, 0 deletions
diff --git a/src/server/web/app/mobile/api/choose-drive-file.ts b/src/server/web/app/mobile/api/choose-drive-file.ts
new file mode 100644
index 0000000000..b1a78f2364
--- /dev/null
+++ b/src/server/web/app/mobile/api/choose-drive-file.ts
@@ -0,0 +1,18 @@
+import Chooser from '../views/components/drive-file-chooser.vue';
+
+export default function(opts) {
+ return new Promise((res, rej) => {
+ const o = opts || {};
+ const w = new Chooser({
+ propsData: {
+ title: o.title,
+ multiple: o.multiple,
+ initFolder: o.currentFolder
+ }
+ }).$mount();
+ w.$once('selected', file => {
+ res(file);
+ });
+ document.body.appendChild(w.$el);
+ });
+}
diff --git a/src/server/web/app/mobile/api/choose-drive-folder.ts b/src/server/web/app/mobile/api/choose-drive-folder.ts
new file mode 100644
index 0000000000..d1f97d1487
--- /dev/null
+++ b/src/server/web/app/mobile/api/choose-drive-folder.ts
@@ -0,0 +1,17 @@
+import Chooser from '../views/components/drive-folder-chooser.vue';
+
+export default function(opts) {
+ return new Promise((res, rej) => {
+ const o = opts || {};
+ const w = new Chooser({
+ propsData: {
+ title: o.title,
+ initFolder: o.currentFolder
+ }
+ }).$mount();
+ w.$once('selected', folder => {
+ res(folder);
+ });
+ document.body.appendChild(w.$el);
+ });
+}
diff --git a/src/server/web/app/mobile/api/dialog.ts b/src/server/web/app/mobile/api/dialog.ts
new file mode 100644
index 0000000000..a2378767be
--- /dev/null
+++ b/src/server/web/app/mobile/api/dialog.ts
@@ -0,0 +1,5 @@
+export default function(opts) {
+ return new Promise<string>((res, rej) => {
+ alert('dialog not implemented yet');
+ });
+}
diff --git a/src/server/web/app/mobile/api/input.ts b/src/server/web/app/mobile/api/input.ts
new file mode 100644
index 0000000000..38d0fb61eb
--- /dev/null
+++ b/src/server/web/app/mobile/api/input.ts
@@ -0,0 +1,8 @@
+export default function(opts) {
+ return new Promise<string>((res, rej) => {
+ const x = window.prompt(opts.title);
+ if (x) {
+ res(x);
+ }
+ });
+}
diff --git a/src/server/web/app/mobile/api/notify.ts b/src/server/web/app/mobile/api/notify.ts
new file mode 100644
index 0000000000..82780d196f
--- /dev/null
+++ b/src/server/web/app/mobile/api/notify.ts
@@ -0,0 +1,3 @@
+export default function(message) {
+ alert(message);
+}
diff --git a/src/server/web/app/mobile/api/post.ts b/src/server/web/app/mobile/api/post.ts
new file mode 100644
index 0000000000..9b78ce10c2
--- /dev/null
+++ b/src/server/web/app/mobile/api/post.ts
@@ -0,0 +1,43 @@
+import PostForm from '../views/components/post-form.vue';
+//import RepostForm from '../views/components/repost-form.vue';
+import getPostSummary from '../../../../common/get-post-summary';
+
+export default (os) => (opts) => {
+ const o = opts || {};
+
+ if (o.repost) {
+ /*const vm = new RepostForm({
+ propsData: {
+ repost: o.repost
+ }
+ }).$mount();
+ vm.$once('cancel', recover);
+ vm.$once('post', recover);
+ document.body.appendChild(vm.$el);*/
+
+ const text = window.prompt(`「${getPostSummary(o.repost)}」をRepost`);
+ if (text == null) return;
+ os.api('posts/create', {
+ repost_id: o.repost.id,
+ text: text == '' ? undefined : text
+ });
+ } else {
+ const app = document.getElementById('app');
+ app.style.display = 'none';
+
+ function recover() {
+ app.style.display = 'block';
+ }
+
+ const vm = new PostForm({
+ parent: os.app,
+ propsData: {
+ reply: o.reply
+ }
+ }).$mount();
+ vm.$once('cancel', recover);
+ vm.$once('post', recover);
+ document.body.appendChild(vm.$el);
+ (vm as any).focus();
+ }
+};
diff --git a/src/server/web/app/mobile/script.ts b/src/server/web/app/mobile/script.ts
new file mode 100644
index 0000000000..4776fccddb
--- /dev/null
+++ b/src/server/web/app/mobile/script.ts
@@ -0,0 +1,84 @@
+/**
+ * Mobile Client
+ */
+
+import VueRouter from 'vue-router';
+
+// Style
+import './style.styl';
+import '../../element.scss';
+
+import init from '../init';
+
+import chooseDriveFolder from './api/choose-drive-folder';
+import chooseDriveFile from './api/choose-drive-file';
+import dialog from './api/dialog';
+import input from './api/input';
+import post from './api/post';
+import notify from './api/notify';
+
+import MkIndex from './views/pages/index.vue';
+import MkSignup from './views/pages/signup.vue';
+import MkUser from './views/pages/user.vue';
+import MkSelectDrive from './views/pages/selectdrive.vue';
+import MkDrive from './views/pages/drive.vue';
+import MkNotifications from './views/pages/notifications.vue';
+import MkMessaging from './views/pages/messaging.vue';
+import MkMessagingRoom from './views/pages/messaging-room.vue';
+import MkPost from './views/pages/post.vue';
+import MkSearch from './views/pages/search.vue';
+import MkFollowers from './views/pages/followers.vue';
+import MkFollowing from './views/pages/following.vue';
+import MkSettings from './views/pages/settings.vue';
+import MkProfileSetting from './views/pages/profile-setting.vue';
+import MkOthello from './views/pages/othello.vue';
+
+/**
+ * init
+ */
+init((launch) => {
+ // Register directives
+ require('./views/directives');
+
+ // Register components
+ require('./views/components');
+ require('./views/widgets');
+
+ // http://qiita.com/junya/items/3ff380878f26ca447f85
+ document.body.setAttribute('ontouchstart', '');
+
+ // Init router
+ const router = new VueRouter({
+ mode: 'history',
+ routes: [
+ { path: '/', name: 'index', component: MkIndex },
+ { path: '/signup', name: 'signup', component: MkSignup },
+ { path: '/i/settings', component: MkSettings },
+ { path: '/i/settings/profile', component: MkProfileSetting },
+ { path: '/i/notifications', component: MkNotifications },
+ { path: '/i/messaging', component: MkMessaging },
+ { path: '/i/messaging/:user', component: MkMessagingRoom },
+ { path: '/i/drive', component: MkDrive },
+ { path: '/i/drive/folder/:folder', component: MkDrive },
+ { path: '/i/drive/file/:file', component: MkDrive },
+ { path: '/selectdrive', component: MkSelectDrive },
+ { path: '/search', component: MkSearch },
+ { path: '/othello', component: MkOthello },
+ { path: '/othello/:game', component: MkOthello },
+ { path: '/@:user', component: MkUser },
+ { path: '/@:user/followers', component: MkFollowers },
+ { path: '/@:user/following', component: MkFollowing },
+ { path: '/@:user/:post', component: MkPost }
+ ]
+ });
+
+ // Launch the app
+ launch(router, os => ({
+ chooseDriveFolder,
+ chooseDriveFile,
+ dialog,
+ input,
+ post: post(os),
+ notify
+ }));
+}, true);
diff --git a/src/server/web/app/mobile/style.styl b/src/server/web/app/mobile/style.styl
new file mode 100644
index 0000000000..81912a2483
--- /dev/null
+++ b/src/server/web/app/mobile/style.styl
@@ -0,0 +1,15 @@
+@import "../app"
+@import "../reset"
+
+#wait
+ top auto
+ bottom 15px
+ left 15px
+
+html
+ height 100%
+
+body
+ display flex
+ flex-direction column
+ min-height 100%
diff --git a/src/server/web/app/mobile/views/components/activity.vue b/src/server/web/app/mobile/views/components/activity.vue
new file mode 100644
index 0000000000..b50044b3de
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/activity.vue
@@ -0,0 +1,62 @@
+<template>
+<div class="mk-activity">
+ <svg v-if="data" ref="canvas" viewBox="0 0 30 1" preserveAspectRatio="none">
+ <g v-for="(d, i) in data">
+ <rect width="0.8" :height="d.postsH"
+ :x="i + 0.1" :y="1 - d.postsH - d.repliesH - d.repostsH"
+ fill="#41ddde"/>
+ <rect width="0.8" :height="d.repliesH"
+ :x="i + 0.1" :y="1 - d.repliesH - d.repostsH"
+ fill="#f7796c"/>
+ <rect width="0.8" :height="d.repostsH"
+ :x="i + 0.1" :y="1 - d.repostsH"
+ fill="#a1de41"/>
+ </g>
+ </svg>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ props: ['user'],
+ data() {
+ return {
+ fetching: true,
+ data: [],
+ peak: null
+ };
+ },
+ mounted() {
+ (this as any).api('aggregation/users/activity', {
+ user_id: this.user.id,
+ limit: 30
+ }).then(data => {
+ data.forEach(d => d.total = d.posts + d.replies + d.reposts);
+ this.peak = Math.max.apply(null, data.map(d => d.total));
+ data.forEach(d => {
+ d.postsH = d.posts / this.peak;
+ d.repliesH = d.replies / this.peak;
+ d.repostsH = d.reposts / this.peak;
+ });
+ data.reverse();
+ this.data = data;
+ });
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-activity
+ max-width 600px
+ margin 0 auto
+
+ > svg
+ display block
+ width 100%
+ height 80px
+
+ > rect
+ transform-origin center
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/drive-file-chooser.vue b/src/server/web/app/mobile/views/components/drive-file-chooser.vue
new file mode 100644
index 0000000000..6806af0f1e
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/drive-file-chooser.vue
@@ -0,0 +1,98 @@
+<template>
+<div class="mk-drive-file-chooser">
+ <div class="body">
+ <header>
+ <h1>%i18n:mobile.tags.mk-drive-selector.select-file%<span class="count" v-if="files.length > 0">({{ files.length }})</span></h1>
+ <button class="close" @click="cancel">%fa:times%</button>
+ <button v-if="multiple" class="ok" @click="ok">%fa:check%</button>
+ </header>
+ <mk-drive ref="browser"
+ :select-file="true"
+ :multiple="multiple"
+ @change-selection="onChangeSelection"
+ @selected="onSelected"
+ />
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ props: ['multiple'],
+ data() {
+ return {
+ files: []
+ };
+ },
+ methods: {
+ onChangeSelection(files) {
+ this.files = files;
+ },
+ onSelected(file) {
+ this.$emit('selected', file);
+ this.$destroy();
+ },
+ cancel() {
+ this.$emit('canceled');
+ this.$destroy();
+ },
+ ok() {
+ this.$emit('selected', this.files);
+ this.$destroy();
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-drive-file-chooser
+ position fixed
+ z-index 2048
+ top 0
+ left 0
+ width 100%
+ height 100%
+ padding 8px
+ background rgba(0, 0, 0, 0.2)
+
+ > .body
+ width 100%
+ height 100%
+ background #fff
+
+ > header
+ border-bottom solid 1px #eee
+
+ > h1
+ margin 0
+ padding 0
+ text-align center
+ line-height 42px
+ font-size 1em
+ font-weight normal
+
+ > .count
+ margin-left 4px
+ opacity 0.5
+
+ > .close
+ position absolute
+ top 0
+ left 0
+ line-height 42px
+ width 42px
+
+ > .ok
+ position absolute
+ top 0
+ right 0
+ line-height 42px
+ width 42px
+
+ > .mk-drive
+ height calc(100% - 42px)
+ overflow scroll
+ -webkit-overflow-scrolling touch
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/drive-folder-chooser.vue b/src/server/web/app/mobile/views/components/drive-folder-chooser.vue
new file mode 100644
index 0000000000..853078664f
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/drive-folder-chooser.vue
@@ -0,0 +1,78 @@
+<template>
+<div class="mk-drive-folder-chooser">
+ <div class="body">
+ <header>
+ <h1>%i18n:mobile.tags.mk-drive-folder-selector.select-folder%</h1>
+ <button class="close" @click="cancel">%fa:times%</button>
+ <button class="ok" @click="ok">%fa:check%</button>
+ </header>
+ <mk-drive ref="browser"
+ select-folder
+ />
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ methods: {
+ cancel() {
+ this.$emit('canceled');
+ this.$destroy();
+ },
+ ok() {
+ this.$emit('selected', (this.$refs.browser as any).folder);
+ this.$destroy();
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-drive-folder-chooser
+ position fixed
+ z-index 2048
+ top 0
+ left 0
+ width 100%
+ height 100%
+ padding 8px
+ background rgba(0, 0, 0, 0.2)
+
+ > .body
+ width 100%
+ height 100%
+ background #fff
+
+ > header
+ border-bottom solid 1px #eee
+
+ > h1
+ margin 0
+ padding 0
+ text-align center
+ line-height 42px
+ font-size 1em
+ font-weight normal
+
+ > .close
+ position absolute
+ top 0
+ left 0
+ line-height 42px
+ width 42px
+
+ > .ok
+ position absolute
+ top 0
+ right 0
+ line-height 42px
+ width 42px
+
+ > .mk-drive
+ height calc(100% - 42px)
+ overflow scroll
+ -webkit-overflow-scrolling touch
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/drive.file-detail.vue b/src/server/web/app/mobile/views/components/drive.file-detail.vue
new file mode 100644
index 0000000000..e41ebbb451
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/drive.file-detail.vue
@@ -0,0 +1,295 @@
+<template>
+<div class="file-detail">
+ <div class="preview">
+ <img v-if="kind == 'image'" ref="img"
+ :src="file.url"
+ :alt="file.name"
+ :title="file.name"
+ @load="onImageLoaded"
+ :style="style">
+ <template v-if="kind != 'image'">%fa:file%</template>
+ <footer v-if="kind == 'image' && file.properties && file.properties.width && file.properties.height">
+ <span class="size">
+ <span class="width">{{ file.properties.width }}</span>
+ <span class="time">×</span>
+ <span class="height">{{ file.properties.height }}</span>
+ <span class="px">px</span>
+ </span>
+ <span class="separator"></span>
+ <span class="aspect-ratio">
+ <span class="width">{{ file.properties.width / gcd(file.properties.width, file.properties.height) }}</span>
+ <span class="colon">:</span>
+ <span class="height">{{ file.properties.height / gcd(file.properties.width, file.properties.height) }}</span>
+ </span>
+ </footer>
+ </div>
+ <div class="info">
+ <div>
+ <span class="type"><mk-file-type-icon :type="file.type"/>{{ file.type }}</span>
+ <span class="separator"></span>
+ <span class="data-size">{{ file.datasize | bytes }}</span>
+ <span class="separator"></span>
+ <span class="created-at" @click="showCreatedAt">%fa:R clock%<mk-time :time="file.created_at"/></span>
+ </div>
+ </div>
+ <div class="menu">
+ <div>
+ <a :href="`${file.url}?download`" :download="file.name">
+ %fa:download%%i18n:mobile.tags.mk-drive-file-viewer.download%
+ </a>
+ <button @click="rename">
+ %fa:pencil-alt%%i18n:mobile.tags.mk-drive-file-viewer.rename%
+ </button>
+ <button @click="move">
+ %fa:R folder-open%%i18n:mobile.tags.mk-drive-file-viewer.move%
+ </button>
+ </div>
+ </div>
+ <div class="exif" v-show="exif">
+ <div>
+ <p>
+ %fa:camera%%i18n:mobile.tags.mk-drive-file-viewer.exif%
+ </p>
+ <pre ref="exif" class="json">{{ exif ? JSON.stringify(exif, null, 2) : '' }}</pre>
+ </div>
+ </div>
+ <div class="hash">
+ <div>
+ <p>
+ %fa:hashtag%%i18n:mobile.tags.mk-drive-file-viewer.hash%
+ </p>
+ <code>{{ file.md5 }}</code>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as EXIF from 'exif-js';
+import * as hljs from 'highlight.js';
+import gcd from '../../../common/scripts/gcd';
+
+export default Vue.extend({
+ props: ['file'],
+ data() {
+ return {
+ gcd,
+ exif: null
+ };
+ },
+ computed: {
+ browser(): any {
+ return this.$parent;
+ },
+ kind(): string {
+ return this.file.type.split('/')[0];
+ },
+ style(): any {
+ return this.file.properties.average_color ? {
+ 'background-color': `rgb(${ this.file.properties.average_color.join(',') })`
+ } : {};
+ }
+ },
+ methods: {
+ rename() {
+ const name = window.prompt('名前を変更', this.file.name);
+ if (name == null || name == '' || name == this.file.name) return;
+ (this as any).api('drive/files/update', {
+ file_id: this.file.id,
+ name: name
+ }).then(() => {
+ this.browser.cf(this.file, true);
+ });
+ },
+ move() {
+ (this as any).apis.chooseDriveFolder().then(folder => {
+ (this as any).api('drive/files/update', {
+ file_id: this.file.id,
+ folder_id: folder == null ? null : folder.id
+ }).then(() => {
+ this.browser.cf(this.file, true);
+ });
+ });
+ },
+ showCreatedAt() {
+ alert(new Date(this.file.created_at).toLocaleString());
+ },
+ onImageLoaded() {
+ const self = this;
+ EXIF.getData(this.$refs.img, function(this: any) {
+ const allMetaData = EXIF.getAllTags(this);
+ self.exif = allMetaData;
+ hljs.highlightBlock(self.$refs.exif);
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.file-detail
+
+ > .preview
+ padding 8px
+ background #f0f0f0
+
+ > img
+ display block
+ max-width 100%
+ max-height 300px
+ margin 0 auto
+ box-shadow 1px 1px 4px rgba(0, 0, 0, 0.2)
+
+ > footer
+ padding 8px 8px 0 8px
+ font-size 0.8em
+ color #888
+ text-align center
+
+ > .separator
+ display inline
+ padding 0 4px
+
+ > .size
+ display inline
+
+ .time
+ margin 0 2px
+
+ .px
+ margin-left 4px
+
+ > .aspect-ratio
+ display inline
+ opacity 0.7
+
+ &:before
+ content "("
+
+ &:after
+ content ")"
+
+ > .info
+ padding 14px
+ font-size 0.8em
+ border-top solid 1px #dfdfdf
+
+ > div
+ max-width 500px
+ margin 0 auto
+
+ > .separator
+ padding 0 4px
+ color #cdcdcd
+
+ > .type
+ > .data-size
+ color #9d9d9d
+
+ > mk-file-type-icon
+ margin-right 4px
+
+ > .created-at
+ color #bdbdbd
+
+ > [data-fa]
+ margin-right 2px
+
+ > .menu
+ padding 14px
+ border-top solid 1px #dfdfdf
+
+ > div
+ max-width 500px
+ margin 0 auto
+
+ > *
+ display block
+ width 100%
+ padding 10px 16px
+ margin 0 0 12px 0
+ color #333
+ font-size 0.9em
+ text-align center
+ text-decoration none
+ text-shadow 0 1px 0 rgba(255, 255, 255, 0.9)
+ background-image linear-gradient(#fafafa, #eaeaea)
+ border 1px solid #ddd
+ border-bottom-color #cecece
+ border-radius 3px
+
+ &:last-child
+ margin-bottom 0
+
+ &:active
+ background-color #767676
+ background-image none
+ border-color #444
+ box-shadow 0 1px 3px rgba(0, 0, 0, 0.075), inset 0 0 5px rgba(0, 0, 0, 0.2)
+
+ > [data-fa]
+ margin-right 4px
+
+ > .hash
+ padding 14px
+ border-top solid 1px #dfdfdf
+
+ > div
+ max-width 500px
+ margin 0 auto
+
+ > p
+ display block
+ margin 0
+ padding 0
+ color #555
+ font-size 0.9em
+
+ > [data-fa]
+ margin-right 4px
+
+ > code
+ display block
+ width 100%
+ margin 6px 0 0 0
+ padding 8px
+ white-space nowrap
+ overflow auto
+ font-size 0.8em
+ color #222
+ border solid 1px #dfdfdf
+ border-radius 2px
+ background #f5f5f5
+
+ > .exif
+ padding 14px
+ border-top solid 1px #dfdfdf
+
+ > div
+ max-width 500px
+ margin 0 auto
+
+ > p
+ display block
+ margin 0
+ padding 0
+ color #555
+ font-size 0.9em
+
+ > [data-fa]
+ margin-right 4px
+
+ > pre
+ display block
+ width 100%
+ margin 6px 0 0 0
+ padding 8px
+ height 128px
+ overflow auto
+ font-size 0.9em
+ border solid 1px #dfdfdf
+ border-radius 2px
+ background #f5f5f5
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/drive.file.vue b/src/server/web/app/mobile/views/components/drive.file.vue
new file mode 100644
index 0000000000..db73816282
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/drive.file.vue
@@ -0,0 +1,171 @@
+<template>
+<a class="file" @click.prevent="onClick" :href="`/i/drive/file/${ file.id }`" :data-is-selected="isSelected">
+ <div class="container">
+ <div class="thumbnail" :style="thumbnail"></div>
+ <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>
+ <!--
+ if file.tags.length > 0
+ ul.tags
+ each tag in file.tags
+ li.tag(style={background: tag.color, color: contrast(tag.color)})= tag.name
+ -->
+ <footer>
+ <p class="type"><mk-file-type-icon :type="file.type"/>{{ file.type }}</p>
+ <p class="separator"></p>
+ <p class="data-size">{{ file.datasize | bytes }}</p>
+ <p class="separator"></p>
+ <p class="created-at">
+ %fa:R clock%<mk-time :time="file.created_at"/>
+ </p>
+ </footer>
+ </div>
+ </div>
+</a>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ props: ['file'],
+ data() {
+ return {
+ isSelected: false
+ };
+ },
+ computed: {
+ browser(): any {
+ return this.$parent;
+ },
+ thumbnail(): any {
+ return {
+ 'background-color': this.file.properties.average_color ? `rgb(${this.file.properties.average_color.join(',')})` : 'transparent',
+ 'background-image': `url(${this.file.url}?thumbnail&size=128)`
+ };
+ }
+ },
+ created() {
+ this.isSelected = this.browser.selectedFiles.some(f => f.id == this.file.id)
+
+ this.browser.$on('change-selection', this.onBrowserChangeSelection);
+ },
+ beforeDestroy() {
+ this.browser.$off('change-selection', this.onBrowserChangeSelection);
+ },
+ methods: {
+ onBrowserChangeSelection(selections) {
+ this.isSelected = selections.some(f => f.id == this.file.id);
+ },
+ onClick() {
+ this.browser.chooseFile(this.file);
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+.file
+ display block
+ text-decoration none !important
+
+ *
+ user-select none
+ pointer-events none
+
+ > .container
+ max-width 500px
+ margin 0 auto
+ padding 16px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .thumbnail
+ display block
+ float left
+ width 64px
+ height 64px
+ background-size cover
+ background-position center center
+
+ > .body
+ display block
+ float left
+ width calc(100% - 74px)
+ margin-left 10px
+
+ > .name
+ display block
+ margin 0
+ padding 0
+ font-size 0.9em
+ font-weight bold
+ color #555
+ text-overflow ellipsis
+ overflow-wrap 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
+ display inline
+ margin 0
+ padding 0 4px
+ color #CDCDCD
+
+ > .type
+ display inline
+ margin 0
+ padding 0
+ color #9D9D9D
+
+ > .mk-file-type-icon
+ margin-right 4px
+
+ > .data-size
+ display inline
+ margin 0
+ padding 0
+ color #9D9D9D
+
+ > .created-at
+ display inline
+ margin 0
+ padding 0
+ color #BDBDBD
+
+ > [data-fa]
+ margin-right 2px
+
+ &[data-is-selected]
+ background $theme-color
+
+ &, *
+ color #fff !important
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/drive.folder.vue b/src/server/web/app/mobile/views/components/drive.folder.vue
new file mode 100644
index 0000000000..22ff38fecb
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/drive.folder.vue
@@ -0,0 +1,58 @@
+<template>
+<a class="root folder" @click.prevent="onClick" :href="`/i/drive/folder/${ folder.id }`">
+ <div class="container">
+ <p class="name">%fa:folder%{{ folder.name }}</p>%fa:angle-right%
+ </div>
+</a>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ props: ['folder'],
+ computed: {
+ browser(): any {
+ return this.$parent;
+ }
+ },
+ methods: {
+ onClick() {
+ this.browser.cd(this.folder);
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.root.folder
+ display block
+ color #777
+ text-decoration none !important
+
+ *
+ user-select none
+ pointer-events none
+
+ > .container
+ max-width 500px
+ margin 0 auto
+ padding 16px
+
+ > .name
+ display block
+ margin 0
+ padding 0
+
+ > [data-fa]
+ margin-right 6px
+
+ > [data-fa]
+ position absolute
+ top 0
+ bottom 0
+ right 20px
+
+ > *
+ height 100%
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/drive.vue b/src/server/web/app/mobile/views/components/drive.vue
new file mode 100644
index 0000000000..696c63e2a4
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/drive.vue
@@ -0,0 +1,581 @@
+<template>
+<div class="mk-drive">
+ <nav ref="nav">
+ <a @click.prevent="goRoot()" href="/i/drive">%fa:cloud%%i18n:mobile.tags.mk-drive.drive%</a>
+ <template v-for="folder in hierarchyFolders">
+ <span :key="folder.id + '>'">%fa:angle-right%</span>
+ <a :key="folder.id" @click.prevent="cd(folder)" :href="`/i/drive/folder/${folder.id}`">{{ folder.name }}</a>
+ </template>
+ <template v-if="folder != null">
+ <span>%fa:angle-right%</span>
+ <p>{{ folder.name }}</p>
+ </template>
+ <template v-if="file != null">
+ <span>%fa:angle-right%</span>
+ <p>{{ file.name }}</p>
+ </template>
+ </nav>
+ <mk-uploader ref="uploader"/>
+ <div class="browser" :class="{ fetching }" v-if="file == null">
+ <div class="info" v-if="info">
+ <p v-if="folder == null">{{ (info.usage / info.capacity * 100).toFixed(1) }}% %i18n:mobile.tags.mk-drive.used%</p>
+ <p v-if="folder != null && (folder.folders_count > 0 || folder.files_count > 0)">
+ <template v-if="folder.folders_count > 0">{{ folder.folders_count }} %i18n:mobile.tags.mk-drive.folder-count%</template>
+ <template v-if="folder.folders_count > 0 && folder.files_count > 0">%i18n:mobile.tags.mk-drive.count-separator%</template>
+ <template v-if="folder.files_count > 0">{{ folder.files_count }} %i18n:mobile.tags.mk-drive.file-count%</template>
+ </p>
+ </div>
+ <div class="folders" v-if="folders.length > 0">
+ <x-folder v-for="folder in folders" :key="folder.id" :folder="folder"/>
+ <p v-if="moreFolders">%i18n:mobile.tags.mk-drive.load-more%</p>
+ </div>
+ <div class="files" v-if="files.length > 0">
+ <x-file v-for="file in files" :key="file.id" :file="file"/>
+ <button class="more" v-if="moreFiles" @click="fetchMoreFiles">
+ {{ fetchingMoreFiles ? '%i18n:common.loading%' : '%i18n:mobile.tags.mk-drive.load-more%' }}
+ </button>
+ </div>
+ <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching">
+ <p v-if="folder == null">%i18n:mobile.tags.mk-drive.nothing-in-drive%</p>
+ <p v-if="folder != null">%i18n:mobile.tags.mk-drive.folder-is-empty%</p>
+ </div>
+ </div>
+ <div class="fetching" v-if="fetching && file == null && files.length == 0 && folders.length == 0">
+ <div class="spinner">
+ <div class="dot1"></div>
+ <div class="dot2"></div>
+ </div>
+ </div>
+ <input ref="file" class="file" type="file" multiple="multiple" @change="onChangeLocalFile"/>
+ <x-file-detail v-if="file != null" :file="file"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XFolder from './drive.folder.vue';
+import XFile from './drive.file.vue';
+import XFileDetail from './drive.file-detail.vue';
+
+export default Vue.extend({
+ components: {
+ XFolder,
+ XFile,
+ XFileDetail
+ },
+ props: ['initFolder', 'initFile', 'selectFile', 'multiple', 'isNaked', 'top'],
+ data() {
+ return {
+ /**
+ * 現在の階層(フォルダ)
+ * * null でルートを表す
+ */
+ folder: null,
+
+ file: null,
+
+ files: [],
+ folders: [],
+ moreFiles: false,
+ moreFolders: false,
+ hierarchyFolders: [],
+ selectedFiles: [],
+ info: null,
+ connection: null,
+ connectionId: null,
+
+ fetching: true,
+ fetchingMoreFiles: false,
+ fetchingMoreFolders: false
+ };
+ },
+ computed: {
+ isFileSelectMode(): boolean {
+ return this.selectFile;
+ }
+ },
+ mounted() {
+ this.connection = (this as any).os.streams.driveStream.getConnection();
+ this.connectionId = (this as any).os.streams.driveStream.use();
+
+ this.connection.on('file_created', this.onStreamDriveFileCreated);
+ this.connection.on('file_updated', this.onStreamDriveFileUpdated);
+ this.connection.on('folder_created', this.onStreamDriveFolderCreated);
+ this.connection.on('folder_updated', this.onStreamDriveFolderUpdated);
+
+ if (this.initFolder) {
+ this.cd(this.initFolder, true);
+ } else if (this.initFile) {
+ this.cf(this.initFile, true);
+ } else {
+ this.fetch();
+ }
+
+ if (this.isNaked) {
+ (this.$refs.nav as any).style.top = `${this.top}px`;
+ }
+ },
+ beforeDestroy() {
+ this.connection.off('file_created', this.onStreamDriveFileCreated);
+ this.connection.off('file_updated', this.onStreamDriveFileUpdated);
+ this.connection.off('folder_created', this.onStreamDriveFolderCreated);
+ this.connection.off('folder_updated', this.onStreamDriveFolderUpdated);
+ (this as any).os.streams.driveStream.dispose(this.connectionId);
+ },
+ methods: {
+ onStreamDriveFileCreated(file) {
+ this.addFile(file, true);
+ },
+
+ onStreamDriveFileUpdated(file) {
+ const current = this.folder ? this.folder.id : null;
+ if (current != file.folder_id) {
+ this.removeFile(file);
+ } else {
+ this.addFile(file, true);
+ }
+ },
+
+ onStreamDriveFolderCreated(folder) {
+ this.addFolder(folder, true);
+ },
+
+ onStreamDriveFolderUpdated(folder) {
+ const current = this.folder ? this.folder.id : null;
+ if (current != folder.parent_id) {
+ this.removeFolder(folder);
+ } else {
+ this.addFolder(folder, true);
+ }
+ },
+
+ dive(folder) {
+ this.hierarchyFolders.unshift(folder);
+ if (folder.parent) this.dive(folder.parent);
+ },
+
+ cd(target, silent = false) {
+ this.file = null;
+
+ if (target == null) {
+ this.goRoot(silent);
+ return;
+ } else if (typeof target == 'object') {
+ target = target.id;
+ }
+
+ this.fetching = true;
+
+ (this as any).api('drive/folders/show', {
+ folder_id: target
+ }).then(folder => {
+ this.folder = folder;
+ this.hierarchyFolders = [];
+
+ if (folder.parent) this.dive(folder.parent);
+
+ this.$emit('open-folder', this.folder, silent);
+ this.fetch();
+ });
+ },
+
+ addFolder(folder, unshift = false) {
+ const current = this.folder ? this.folder.id : null;
+ // 追加しようとしているフォルダが、今居る階層とは違う階層のものだったら中断
+ if (current != folder.parent_id) return;
+
+ // 追加しようとしているフォルダを既に所有してたら中断
+ if (this.folders.some(f => f.id == folder.id)) return;
+
+ if (unshift) {
+ this.folders.unshift(folder);
+ } else {
+ this.folders.push(folder);
+ }
+ },
+
+ addFile(file, unshift = false) {
+ const current = this.folder ? this.folder.id : null;
+ // 追加しようとしているファイルが、今居る階層とは違う階層のものだったら中断
+ if (current != file.folder_id) return;
+
+ if (this.files.some(f => f.id == file.id)) {
+ const exist = this.files.map(f => f.id).indexOf(file.id);
+ Vue.set(this.files, exist, file);
+ return;
+ }
+
+ if (unshift) {
+ this.files.unshift(file);
+ } else {
+ this.files.push(file);
+ }
+ },
+
+ removeFolder(folder) {
+ if (typeof folder == 'object') folder = folder.id;
+ this.folders = this.folders.filter(f => f.id != folder);
+ },
+
+ removeFile(file) {
+ if (typeof file == 'object') file = file.id;
+ this.files = this.files.filter(f => f.id != file);
+ },
+
+ appendFile(file) {
+ this.addFile(file);
+ },
+ appendFolder(folder) {
+ this.addFolder(folder);
+ },
+ prependFile(file) {
+ this.addFile(file, true);
+ },
+ prependFolder(folder) {
+ this.addFolder(folder, true);
+ },
+
+ goRoot(silent = false) {
+ if (this.folder || this.file) {
+ this.file = null;
+ this.folder = null;
+ this.hierarchyFolders = [];
+ this.$emit('move-root', silent);
+ this.fetch();
+ }
+ },
+
+ fetch() {
+ this.folders = [];
+ this.files = [];
+ this.moreFolders = false;
+ this.moreFiles = false;
+ this.fetching = true;
+
+ this.$emit('begin-fetch');
+
+ let fetchedFolders = null;
+ let fetchedFiles = null;
+
+ const foldersMax = 20;
+ const filesMax = 20;
+
+ // フォルダ一覧取得
+ (this as any).api('drive/folders', {
+ folder_id: this.folder ? this.folder.id : null,
+ limit: foldersMax + 1
+ }).then(folders => {
+ if (folders.length == foldersMax + 1) {
+ this.moreFolders = true;
+ folders.pop();
+ }
+ fetchedFolders = folders;
+ complete();
+ });
+
+ // ファイル一覧取得
+ (this as any).api('drive/files', {
+ folder_id: this.folder ? this.folder.id : null,
+ limit: filesMax + 1
+ }).then(files => {
+ if (files.length == filesMax + 1) {
+ this.moreFiles = true;
+ files.pop();
+ }
+ fetchedFiles = files;
+ complete();
+ });
+
+ let flag = false;
+ const complete = () => {
+ if (flag) {
+ fetchedFolders.forEach(this.appendFolder);
+ fetchedFiles.forEach(this.appendFile);
+ this.fetching = false;
+
+ // 一連の読み込みが完了したイベントを発行
+ this.$emit('fetched');
+ } else {
+ flag = true;
+ // 一連の読み込みが半分完了したイベントを発行
+ this.$emit('fetch-mid');
+ }
+ };
+
+ if (this.folder == null) {
+ // Fetch addtional drive info
+ (this as any).api('drive').then(info => {
+ this.info = info;
+ });
+ }
+ },
+
+ fetchMoreFiles() {
+ this.fetching = true;
+ this.fetchingMoreFiles = true;
+
+ const max = 30;
+
+ // ファイル一覧取得
+ (this as any).api('drive/files', {
+ folder_id: this.folder ? this.folder.id : null,
+ limit: max + 1,
+ until_id: this.files[this.files.length - 1].id
+ }).then(files => {
+ if (files.length == max + 1) {
+ this.moreFiles = true;
+ files.pop();
+ } else {
+ this.moreFiles = false;
+ }
+ files.forEach(this.appendFile);
+ this.fetching = false;
+ this.fetchingMoreFiles = false;
+ });
+ },
+
+ chooseFile(file) {
+ if (this.isFileSelectMode) {
+ if (this.multiple) {
+ if (this.selectedFiles.some(f => f.id == file.id)) {
+ this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id);
+ } else {
+ this.selectedFiles.push(file);
+ }
+ this.$emit('change-selection', this.selectedFiles);
+ } else {
+ this.$emit('selected', file);
+ }
+ } else {
+ this.cf(file);
+ }
+ },
+
+ cf(file, silent = false) {
+ if (typeof file == 'object') file = file.id;
+
+ this.fetching = true;
+
+ (this as any).api('drive/files/show', {
+ file_id: file
+ }).then(file => {
+ this.file = file;
+ this.folder = null;
+ this.hierarchyFolders = [];
+
+ if (file.folder) this.dive(file.folder);
+
+ this.fetching = false;
+
+ this.$emit('open-file', this.file, silent);
+ });
+ },
+
+ openContextMenu() {
+ const fn = window.prompt('何をしますか?(数字を入力してください): <1 → ファイルをアップロード | 2 → ファイルをURLでアップロード | 3 → フォルダ作成 | 4 → このフォルダ名を変更 | 5 → このフォルダを移動 | 6 → このフォルダを削除>');
+ if (fn == null || fn == '') return;
+ switch (fn) {
+ case '1':
+ this.selectLocalFile();
+ break;
+ case '2':
+ this.urlUpload();
+ break;
+ case '3':
+ this.createFolder();
+ break;
+ case '4':
+ this.renameFolder();
+ break;
+ case '5':
+ this.moveFolder();
+ break;
+ case '6':
+ alert('ごめんなさい!フォルダの削除は未実装です...。');
+ break;
+ }
+ },
+
+ selectLocalFile() {
+ (this.$refs.file as any).click();
+ },
+
+ createFolder() {
+ const name = window.prompt('フォルダー名');
+ if (name == null || name == '') return;
+ (this as any).api('drive/folders/create', {
+ name: name,
+ parent_id: this.folder ? this.folder.id : undefined
+ }).then(folder => {
+ this.addFolder(folder, true);
+ });
+ },
+
+ renameFolder() {
+ if (this.folder == null) {
+ alert('現在いる場所はルートで、フォルダではないため名前の変更はできません。名前を変更したいフォルダに移動してからやってください。');
+ return;
+ }
+ const name = window.prompt('フォルダー名', this.folder.name);
+ if (name == null || name == '') return;
+ (this as any).api('drive/folders/update', {
+ name: name,
+ folder_id: this.folder.id
+ }).then(folder => {
+ this.cd(folder);
+ });
+ },
+
+ moveFolder() {
+ if (this.folder == null) {
+ alert('現在いる場所はルートで、フォルダではないため移動はできません。移動したいフォルダに移動してからやってください。');
+ return;
+ }
+ (this as any).apis.chooseDriveFolder().then(folder => {
+ (this as any).api('drive/folders/update', {
+ parent_id: folder ? folder.id : null,
+ folder_id: this.folder.id
+ }).then(folder => {
+ this.cd(folder);
+ });
+ });
+ },
+
+ urlUpload() {
+ const url = window.prompt('アップロードしたいファイルのURL');
+ if (url == null || url == '') return;
+ (this as any).api('drive/files/upload_from_url', {
+ url: url,
+ folder_id: this.folder ? this.folder.id : undefined
+ });
+ alert('アップロードをリクエストしました。アップロードが完了するまで時間がかかる場合があります。');
+ },
+
+ onChangeLocalFile() {
+ Array.from((this.$refs.file as any).files)
+ .forEach(f => (this.$refs.uploader as any).upload(f, this.folder));
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-drive
+ background #fff
+
+ > nav
+ display block
+ position sticky
+ position -webkit-sticky
+ top 0
+ z-index 1
+ width 100%
+ padding 10px 12px
+ overflow auto
+ white-space nowrap
+ font-size 0.9em
+ color rgba(0, 0, 0, 0.67)
+ -webkit-backdrop-filter blur(12px)
+ backdrop-filter blur(12px)
+ background-color rgba(#fff, 0.75)
+ border-bottom solid 1px rgba(0, 0, 0, 0.13)
+
+ > p
+ > a
+ display inline
+ margin 0
+ padding 0
+ text-decoration none !important
+ color inherit
+
+ &:last-child
+ font-weight bold
+
+ > [data-fa]
+ margin-right 4px
+
+ > span
+ margin 0 8px
+ opacity 0.5
+
+ > .browser
+ &.fetching
+ opacity 0.5
+
+ > .info
+ border-bottom solid 1px #eee
+
+ &:empty
+ display none
+
+ > p
+ display block
+ max-width 500px
+ margin 0 auto
+ padding 4px 16px
+ font-size 10px
+ color #777
+
+ > .folders
+ > .folder
+ border-bottom solid 1px #eee
+
+ > .files
+ > .file
+ border-bottom solid 1px #eee
+
+ > .more
+ display block
+ width 100%
+ padding 16px
+ font-size 16px
+ color #555
+
+ > .empty
+ padding 16px
+ text-align center
+ color #999
+ pointer-events none
+
+ > p
+ margin 0
+
+ > .fetching
+ .spinner
+ margin 100px auto
+ width 40px
+ height 40px
+ text-align center
+
+ animation sk-rotate 2.0s infinite linear
+
+ .dot1, .dot2
+ width 60%
+ height 60%
+ display inline-block
+ position absolute
+ top 0
+ background rgba(0, 0, 0, 0.2)
+ border-radius 100%
+
+ animation sk-bounce 2.0s infinite ease-in-out
+
+ .dot2
+ top auto
+ bottom 0
+ animation-delay -1.0s
+
+ @keyframes sk-rotate { 100% { transform: rotate(360deg); }}
+
+ @keyframes sk-bounce {
+ 0%, 100% {
+ transform: scale(0.0);
+ } 50% {
+ transform: scale(1.0);
+ }
+ }
+
+ > .file
+ display none
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/follow-button.vue b/src/server/web/app/mobile/views/components/follow-button.vue
new file mode 100644
index 0000000000..fb6eaa39c6
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/follow-button.vue
@@ -0,0 +1,123 @@
+<template>
+<button class="mk-follow-button"
+ :class="{ wait: wait, follow: !user.is_following, unfollow: user.is_following }"
+ @click="onClick"
+ :disabled="wait"
+>
+ <template v-if="!wait && user.is_following">%fa:minus%</template>
+ <template v-if="!wait && !user.is_following">%fa:plus%</template>
+ <template v-if="wait">%fa:spinner .pulse .fw%</template>
+ {{ user.is_following ? '%i18n:mobile.tags.mk-follow-button.unfollow%' : '%i18n:mobile.tags.mk-follow-button.follow%' }}
+</button>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ props: {
+ user: {
+ type: Object,
+ required: true
+ }
+ },
+ data() {
+ return {
+ wait: false,
+ connection: null,
+ connectionId: null
+ };
+ },
+ mounted() {
+ this.connection = (this as any).os.stream.getConnection();
+ this.connectionId = (this as any).os.stream.use();
+
+ this.connection.on('follow', this.onFollow);
+ this.connection.on('unfollow', this.onUnfollow);
+ },
+ beforeDestroy() {
+ this.connection.off('follow', this.onFollow);
+ this.connection.off('unfollow', this.onUnfollow);
+ (this as any).os.stream.dispose(this.connectionId);
+ },
+ methods: {
+
+ onFollow(user) {
+ if (user.id == this.user.id) {
+ this.user.is_following = user.is_following;
+ }
+ },
+
+ onUnfollow(user) {
+ if (user.id == this.user.id) {
+ this.user.is_following = user.is_following;
+ }
+ },
+
+ onClick() {
+ this.wait = true;
+ if (this.user.is_following) {
+ (this as any).api('following/delete', {
+ user_id: this.user.id
+ }).then(() => {
+ this.user.is_following = false;
+ }).catch(err => {
+ console.error(err);
+ }).then(() => {
+ this.wait = false;
+ });
+ } else {
+ (this as any).api('following/create', {
+ user_id: this.user.id
+ }).then(() => {
+ this.user.is_following = true;
+ }).catch(err => {
+ console.error(err);
+ }).then(() => {
+ this.wait = false;
+ });
+ }
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+.mk-follow-button
+ display block
+ user-select none
+ cursor pointer
+ padding 0 16px
+ margin 0
+ height inherit
+ font-size 16px
+ outline none
+ border solid 1px $theme-color
+ border-radius 4px
+
+ *
+ pointer-events none
+
+ &.follow
+ color $theme-color
+ background transparent
+
+ &:hover
+ background rgba($theme-color, 0.1)
+
+ &:active
+ background rgba($theme-color, 0.2)
+
+ &.unfollow
+ color $theme-color-foreground
+ background $theme-color
+
+ &.wait
+ cursor wait !important
+ opacity 0.7
+
+ > [data-fa]
+ margin-right 4px
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/friends-maker.vue b/src/server/web/app/mobile/views/components/friends-maker.vue
new file mode 100644
index 0000000000..961a5f568a
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/friends-maker.vue
@@ -0,0 +1,127 @@
+<template>
+<div class="mk-friends-maker">
+ <p class="title">気になるユーザーをフォロー:</p>
+ <div class="users" v-if="!fetching && users.length > 0">
+ <mk-user-card v-for="user in users" :key="user.id" :user="user"/>
+ </div>
+ <p class="empty" v-if="!fetching && users.length == 0">おすすめのユーザーは見つかりませんでした。</p>
+ <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%読み込んでいます<mk-ellipsis/></p>
+ <a class="refresh" @click="refresh">もっと見る</a>
+ <button class="close" @click="close" title="閉じる">%fa:times%</button>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ data() {
+ return {
+ users: [],
+ fetching: true,
+ limit: 6,
+ page: 0
+ };
+ },
+ mounted() {
+ this.fetch();
+ },
+ methods: {
+ fetch() {
+ this.fetching = true;
+ this.users = [];
+
+ (this as any).api('users/recommendation', {
+ limit: this.limit,
+ offset: this.limit * this.page
+ }).then(users => {
+ this.users = users;
+ this.fetching = false;
+ });
+ },
+ refresh() {
+ if (this.users.length < this.limit) {
+ this.page = 0;
+ } else {
+ this.page++;
+ }
+ this.fetch();
+ },
+ close() {
+ this.$destroy();
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-friends-maker
+ background #fff
+ border-radius 8px
+ box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+
+ > .title
+ margin 0
+ padding 8px 16px
+ font-size 1em
+ font-weight bold
+ color #888
+
+ > .users
+ overflow-x scroll
+ -webkit-overflow-scrolling touch
+ white-space nowrap
+ padding 16px
+ background #eee
+
+ > .mk-user-card
+ &:not(:last-child)
+ margin-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
+
+ > .refresh
+ display block
+ margin 0
+ padding 8px 16px
+ text-align right
+ font-size 0.9em
+ color #999
+
+ > .close
+ cursor pointer
+ display block
+ position absolute
+ top 0
+ right 0
+ z-index 1
+ margin 0
+ padding 0
+ font-size 1.2em
+ color #999
+ border none
+ outline none
+ background transparent
+
+ &:hover
+ color #555
+
+ &:active
+ color #222
+
+ > [data-fa]
+ padding 10px
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/index.ts b/src/server/web/app/mobile/views/components/index.ts
new file mode 100644
index 0000000000..fb8f65f47d
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/index.ts
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+
+import ui from './ui.vue';
+import timeline from './timeline.vue';
+import post from './post.vue';
+import posts from './posts.vue';
+import mediaImage from './media-image.vue';
+import mediaVideo from './media-video.vue';
+import drive from './drive.vue';
+import postPreview from './post-preview.vue';
+import subPostContent from './sub-post-content.vue';
+import postCard from './post-card.vue';
+import userCard from './user-card.vue';
+import postDetail from './post-detail.vue';
+import followButton from './follow-button.vue';
+import friendsMaker from './friends-maker.vue';
+import notification from './notification.vue';
+import notifications from './notifications.vue';
+import notificationPreview from './notification-preview.vue';
+import usersList from './users-list.vue';
+import userPreview from './user-preview.vue';
+import userTimeline from './user-timeline.vue';
+import activity from './activity.vue';
+import widgetContainer from './widget-container.vue';
+
+Vue.component('mk-ui', ui);
+Vue.component('mk-timeline', timeline);
+Vue.component('mk-post', post);
+Vue.component('mk-posts', posts);
+Vue.component('mk-media-image', mediaImage);
+Vue.component('mk-media-video', mediaVideo);
+Vue.component('mk-drive', drive);
+Vue.component('mk-post-preview', postPreview);
+Vue.component('mk-sub-post-content', subPostContent);
+Vue.component('mk-post-card', postCard);
+Vue.component('mk-user-card', userCard);
+Vue.component('mk-post-detail', postDetail);
+Vue.component('mk-follow-button', followButton);
+Vue.component('mk-friends-maker', friendsMaker);
+Vue.component('mk-notification', notification);
+Vue.component('mk-notifications', notifications);
+Vue.component('mk-notification-preview', notificationPreview);
+Vue.component('mk-users-list', usersList);
+Vue.component('mk-user-preview', userPreview);
+Vue.component('mk-user-timeline', userTimeline);
+Vue.component('mk-activity', activity);
+Vue.component('mk-widget-container', widgetContainer);
diff --git a/src/server/web/app/mobile/views/components/media-image.vue b/src/server/web/app/mobile/views/components/media-image.vue
new file mode 100644
index 0000000000..faf8bad48a
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/media-image.vue
@@ -0,0 +1,31 @@
+<template>
+<a class="mk-media-image" :href="image.url" target="_blank" :style="style" :title="image.name"></a>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+ props: ['image'],
+ computed: {
+ style(): any {
+ return {
+ 'background-color': this.image.properties.average_color ? `rgb(${this.image.properties.average_color.join(',')})` : 'transparent',
+ 'background-image': `url(${this.image.url}?thumbnail&size=512)`
+ };
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-media-image
+ display block
+ overflow hidden
+ width 100%
+ height 100%
+ background-position center
+ background-size cover
+ border-radius 4px
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/media-video.vue b/src/server/web/app/mobile/views/components/media-video.vue
new file mode 100644
index 0000000000..68cd48587a
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/media-video.vue
@@ -0,0 +1,36 @@
+<template>
+ <a class="mk-media-video"
+ :href="video.url"
+ target="_blank"
+ :style="imageStyle"
+ :title="video.name">
+ %fa:R play-circle%
+ </a>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+export default Vue.extend({
+ props: ['video'],
+ computed: {
+ imageStyle(): any {
+ return {
+ 'background-image': `url(${this.video.url}?thumbnail&size=512)`
+ };
+ }
+ },})
+</script>
+
+<style lang="stylus" scoped>
+.mk-media-video
+ display flex
+ justify-content center
+ align-items center
+
+ font-size 3.5em
+ overflow hidden
+ background-position center
+ background-size cover
+ width 100%
+ height 100%
+</style>
diff --git a/src/server/web/app/mobile/views/components/notification-preview.vue b/src/server/web/app/mobile/views/components/notification-preview.vue
new file mode 100644
index 0000000000..47df626fa8
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/notification-preview.vue
@@ -0,0 +1,128 @@
+<template>
+<div class="mk-notification-preview" :class="notification.type">
+ <template v-if="notification.type == 'reaction'">
+ <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ <div class="text">
+ <p><mk-reaction-icon :reaction="notification.reaction"/>{{ notification.user.name }}</p>
+ <p class="post-ref">%fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right%</p>
+ </div>
+ </template>
+
+ <template v-if="notification.type == 'repost'">
+ <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ <div class="text">
+ <p>%fa:retweet%{{ notification.post.user.name }}</p>
+ <p class="post-ref">%fa:quote-left%{{ getPostSummary(notification.post.repost) }}%fa:quote-right%</p>
+ </div>
+ </template>
+
+ <template v-if="notification.type == 'quote'">
+ <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ <div class="text">
+ <p>%fa:quote-left%{{ notification.post.user.name }}</p>
+ <p class="post-preview">{{ getPostSummary(notification.post) }}</p>
+ </div>
+ </template>
+
+ <template v-if="notification.type == 'follow'">
+ <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ <div class="text">
+ <p>%fa:user-plus%{{ notification.user.name }}</p>
+ </div>
+ </template>
+
+ <template v-if="notification.type == 'reply'">
+ <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ <div class="text">
+ <p>%fa:reply%{{ notification.post.user.name }}</p>
+ <p class="post-preview">{{ getPostSummary(notification.post) }}</p>
+ </div>
+ </template>
+
+ <template v-if="notification.type == 'mention'">
+ <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ <div class="text">
+ <p>%fa:at%{{ notification.post.user.name }}</p>
+ <p class="post-preview">{{ getPostSummary(notification.post) }}</p>
+ </div>
+ </template>
+
+ <template v-if="notification.type == 'poll_vote'">
+ <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ <div class="text">
+ <p>%fa:chart-pie%{{ notification.user.name }}</p>
+ <p class="post-ref">%fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right%</p>
+ </div>
+ </template>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import getPostSummary from '../../../../../common/get-post-summary';
+
+export default Vue.extend({
+ props: ['notification'],
+ data() {
+ return {
+ getPostSummary
+ };
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-notification-preview
+ margin 0
+ padding 8px
+ color #fff
+ overflow-wrap break-word
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ img
+ display block
+ float left
+ min-width 36px
+ min-height 36px
+ max-width 36px
+ max-height 36px
+ border-radius 6px
+
+ .text
+ float right
+ width calc(100% - 36px)
+ padding-left 8px
+
+ p
+ margin 0
+
+ i, mk-reaction-icon
+ margin-right 4px
+
+ .post-ref
+
+ [data-fa]
+ font-size 1em
+ font-weight normal
+ font-style normal
+ display inline-block
+ margin-right 3px
+
+ &.repost, &.quote
+ .text p i
+ color #77B255
+
+ &.follow
+ .text p i
+ color #53c7ce
+
+ &.reply, &.mention
+ .text p i
+ color #fff
+
+</style>
+
diff --git a/src/server/web/app/mobile/views/components/notification.vue b/src/server/web/app/mobile/views/components/notification.vue
new file mode 100644
index 0000000000..150ac0fd8b
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/notification.vue
@@ -0,0 +1,164 @@
+<template>
+<div class="mk-notification">
+ <div class="notification reaction" v-if="notification.type == 'reaction'">
+ <mk-time :time="notification.created_at"/>
+ <router-link class="avatar-anchor" :to="`/@${acct}`">
+ <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ </router-link>
+ <div class="text">
+ <p>
+ <mk-reaction-icon :reaction="notification.reaction"/>
+ <router-link :to="`/@${acct}`">{{ notification.user.name }}</router-link>
+ </p>
+ <router-link class="post-ref" :to="`/@${acct}/${notification.post.id}`">
+ %fa:quote-left%{{ getPostSummary(notification.post) }}
+ %fa:quote-right%
+ </router-link>
+ </div>
+ </div>
+
+ <div class="notification repost" v-if="notification.type == 'repost'">
+ <mk-time :time="notification.created_at"/>
+ <router-link class="avatar-anchor" :to="`/@${acct}`">
+ <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ </router-link>
+ <div class="text">
+ <p>
+ %fa:retweet%
+ <router-link :to="`/@${acct}`">{{ notification.post.user.name }}</router-link>
+ </p>
+ <router-link class="post-ref" :to="`/@${acct}/${notification.post.id}`">
+ %fa:quote-left%{{ getPostSummary(notification.post.repost) }}%fa:quote-right%
+ </router-link>
+ </div>
+ </div>
+
+ <template v-if="notification.type == 'quote'">
+ <mk-post :post="notification.post"/>
+ </template>
+
+ <div class="notification follow" v-if="notification.type == 'follow'">
+ <mk-time :time="notification.created_at"/>
+ <router-link class="avatar-anchor" :to="`/@${acct}`">
+ <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ </router-link>
+ <div class="text">
+ <p>
+ %fa:user-plus%
+ <router-link :to="`/@${acct}`">{{ notification.user.name }}</router-link>
+ </p>
+ </div>
+ </div>
+
+ <template v-if="notification.type == 'reply'">
+ <mk-post :post="notification.post"/>
+ </template>
+
+ <template v-if="notification.type == 'mention'">
+ <mk-post :post="notification.post"/>
+ </template>
+
+ <div class="notification poll_vote" v-if="notification.type == 'poll_vote'">
+ <mk-time :time="notification.created_at"/>
+ <router-link class="avatar-anchor" :to="`/@${acct}`">
+ <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ </router-link>
+ <div class="text">
+ <p>
+ %fa:chart-pie%
+ <router-link :to="`/@${acct}`">{{ notification.user.name }}</router-link>
+ </p>
+ <router-link class="post-ref" :to="`/@${acct}/${notification.post.id}`">
+ %fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right%
+ </router-link>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import getPostSummary from '../../../../../common/get-post-summary';
+import getAcct from '../../../../../common/user/get-acct';
+
+export default Vue.extend({
+ props: ['notification'],
+ computed: {
+ acct() {
+ return getAcct(this.notification.user);
+ }
+ },
+ data() {
+ return {
+ getPostSummary
+ };
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-notification
+
+ > .notification
+ padding 16px
+ overflow-wrap break-word
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .mk-time
+ display inline
+ position absolute
+ top 16px
+ right 12px
+ vertical-align top
+ color rgba(0, 0, 0, 0.6)
+ font-size 0.9em
+
+ > .avatar-anchor
+ display block
+ float left
+
+ img
+ min-width 36px
+ min-height 36px
+ max-width 36px
+ max-height 36px
+ border-radius 6px
+
+ > .text
+ float right
+ width calc(100% - 36px)
+ padding-left 8px
+
+ p
+ margin 0
+
+ i, .mk-reaction-icon
+ margin-right 4px
+
+ > .post-preview
+ color rgba(0, 0, 0, 0.7)
+
+ > .post-ref
+ color rgba(0, 0, 0, 0.7)
+
+ [data-fa]
+ font-size 1em
+ font-weight normal
+ font-style normal
+ display inline-block
+ margin-right 3px
+
+ &.repost
+ .text p i
+ color #77B255
+
+ &.follow
+ .text p i
+ color #53c7ce
+
+</style>
+
diff --git a/src/server/web/app/mobile/views/components/notifications.vue b/src/server/web/app/mobile/views/components/notifications.vue
new file mode 100644
index 0000000000..1cd6e2bc13
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/notifications.vue
@@ -0,0 +1,168 @@
+<template>
+<div class="mk-notifications">
+ <div class="notifications" v-if="notifications.length != 0">
+ <template v-for="(notification, i) in _notifications">
+ <mk-notification :notification="notification" :key="notification.id"/>
+ <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date">
+ <span>%fa:angle-up%{{ notification._datetext }}</span>
+ <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span>
+ </p>
+ </template>
+ </div>
+ <button class="more" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications">
+ <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>
+ {{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:mobile.tags.mk-notifications.more%' }}
+ </button>
+ <p class="empty" v-if="notifications.length == 0 && !fetching">%i18n:mobile.tags.mk-notifications.empty%</p>
+ <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ data() {
+ return {
+ fetching: true,
+ fetchingMoreNotifications: false,
+ notifications: [],
+ moreNotifications: false,
+ connection: null,
+ connectionId: null
+ };
+ },
+ computed: {
+ _notifications(): any[] {
+ return (this.notifications as any).map(notification => {
+ const date = new Date(notification.created_at).getDate();
+ const month = new Date(notification.created_at).getMonth() + 1;
+ notification._date = date;
+ notification._datetext = `${month}月 ${date}日`;
+ return notification;
+ });
+ }
+ },
+ mounted() {
+ this.connection = (this as any).os.stream.getConnection();
+ this.connectionId = (this as any).os.stream.use();
+
+ this.connection.on('notification', this.onNotification);
+
+ const max = 10;
+
+ (this as any).api('i/notifications', {
+ limit: max + 1
+ }).then(notifications => {
+ if (notifications.length == max + 1) {
+ this.moreNotifications = true;
+ notifications.pop();
+ }
+
+ this.notifications = notifications;
+ this.fetching = false;
+ this.$emit('fetched');
+ });
+ },
+ beforeDestroy() {
+ this.connection.off('notification', this.onNotification);
+ (this as any).os.stream.dispose(this.connectionId);
+ },
+ methods: {
+ fetchMoreNotifications() {
+ this.fetchingMoreNotifications = true;
+
+ const max = 30;
+
+ (this as any).api('i/notifications', {
+ limit: max + 1,
+ until_id: this.notifications[this.notifications.length - 1].id
+ }).then(notifications => {
+ if (notifications.length == max + 1) {
+ this.moreNotifications = true;
+ notifications.pop();
+ } else {
+ this.moreNotifications = false;
+ }
+ this.notifications = this.notifications.concat(notifications);
+ this.fetchingMoreNotifications = false;
+ });
+ },
+ onNotification(notification) {
+ // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
+ this.connection.send({
+ type: 'read_notification',
+ id: notification.id
+ });
+
+ this.notifications.unshift(notification);
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-notifications
+ margin 8px auto
+ padding 0
+ max-width 500px
+ width calc(100% - 16px)
+ background #fff
+ border-radius 8px
+ box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+
+ @media (min-width 500px)
+ margin 16px auto
+ width calc(100% - 32px)
+
+ > .notifications
+
+ > .mk-notification
+ margin 0 auto
+ max-width 500px
+ border-bottom solid 1px rgba(0, 0, 0, 0.05)
+
+ &:last-child
+ border-bottom none
+
+ > .date
+ display block
+ margin 0
+ line-height 32px
+ text-align center
+ font-size 0.8em
+ color #aaa
+ background #fdfdfd
+ border-bottom solid 1px rgba(0, 0, 0, 0.05)
+
+ span
+ margin 0 16px
+
+ i
+ margin-right 8px
+
+ > .more
+ display block
+ width 100%
+ padding 16px
+ color #555
+ border-top solid 1px rgba(0, 0, 0, 0.05)
+
+ > [data-fa]
+ margin-right 4px
+
+ > .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/mobile/views/components/notify.vue b/src/server/web/app/mobile/views/components/notify.vue
new file mode 100644
index 0000000000..6d4a481dbe
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/notify.vue
@@ -0,0 +1,49 @@
+<template>
+<div class="mk-notify">
+ <mk-notification-preview :notification="notification"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as anime from 'animejs';
+
+export default Vue.extend({
+ props: ['notification'],
+ mounted() {
+ this.$nextTick(() => {
+ anime({
+ targets: this.$el,
+ bottom: '0px',
+ duration: 500,
+ easing: 'easeOutQuad'
+ });
+
+ setTimeout(() => {
+ anime({
+ targets: this.$el,
+ bottom: '-64px',
+ duration: 500,
+ easing: 'easeOutQuad',
+ complete: () => this.$destroy()
+ });
+ }, 6000);
+ });
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-notify
+ position fixed
+ z-index 1024
+ bottom -64px
+ left 0
+ width 100%
+ height 64px
+ pointer-events none
+ -webkit-backdrop-filter blur(2px)
+ backdrop-filter blur(2px)
+ background-color rgba(#000, 0.5)
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/post-card.vue b/src/server/web/app/mobile/views/components/post-card.vue
new file mode 100644
index 0000000000..8ca7550c2e
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/post-card.vue
@@ -0,0 +1,89 @@
+<template>
+<div class="mk-post-card">
+ <a :href="`/@${acct}/${post.id}`">
+ <header>
+ <img :src="`${acct}?thumbnail&size=64`" alt="avatar"/><h3>{{ post.user.name }}</h3>
+ </header>
+ <div>
+ {{ text }}
+ </div>
+ <mk-time :time="post.created_at"/>
+ </a>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import summary from '../../../../../common/get-post-summary';
+import getAcct from '../../../../../common/user/get-acct';
+
+export default Vue.extend({
+ props: ['post'],
+ computed: {
+ acct() {
+ return getAcct(this.post.user);
+ },
+ text(): string {
+ return summary(this.post);
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-post-card
+ display inline-block
+ width 150px
+ //height 120px
+ font-size 12px
+ background #fff
+ border-radius 4px
+
+ > a
+ display block
+ color #2c3940
+
+ &:hover
+ text-decoration none
+
+ > header
+ > img
+ position absolute
+ top 8px
+ left 8px
+ width 28px
+ height 28px
+ border-radius 6px
+
+ > h3
+ display inline-block
+ overflow hidden
+ width calc(100% - 45px)
+ margin 8px 0 0 42px
+ line-height 28px
+ white-space nowrap
+ text-overflow ellipsis
+ font-size 12px
+
+ > div
+ padding 2px 8px 8px 8px
+ height 60px
+ overflow hidden
+ white-space normal
+
+ &:after
+ content ""
+ display block
+ position absolute
+ top 40px
+ left 0
+ width 100%
+ height 20px
+ background linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, #fff 100%)
+
+ > .mk-time
+ display inline-block
+ padding 8px
+ color #aaa
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/post-detail.sub.vue b/src/server/web/app/mobile/views/components/post-detail.sub.vue
new file mode 100644
index 0000000000..6906cf570e
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/post-detail.sub.vue
@@ -0,0 +1,109 @@
+<template>
+<div class="root sub">
+ <router-link class="avatar-anchor" :to="`/@${acct}`">
+ <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ </router-link>
+ <div class="main">
+ <header>
+ <router-link class="name" :to="`/@${acct}`">{{ post.user.name }}</router-link>
+ <span class="username">@{{ acct }}</span>
+ <router-link class="time" :to="`/@${acct}/${post.id}`">
+ <mk-time :time="post.created_at"/>
+ </router-link>
+ </header>
+ <div class="body">
+ <mk-sub-post-content class="text" :post="post"/>
+ </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);
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.root.sub
+ padding 8px
+ font-size 0.9em
+ background #fdfdfd
+
+ @media (min-width 500px)
+ padding 12px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ &:hover
+ > .main > footer > button
+ color #888
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 12px 0 0
+
+ > .avatar
+ display block
+ width 48px
+ height 48px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
+
+ > .main
+ float left
+ width calc(100% - 60px)
+
+ > header
+ display flex
+ margin-bottom 4px
+ white-space nowrap
+
+ > .name
+ display block
+ margin 0 .5em 0 0
+ padding 0
+ overflow hidden
+ color #607073
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+ text-overflow ellipsis
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 .5em 0 0
+ color #d1d8da
+
+ > .time
+ margin-left auto
+ color #b2b8bb
+
+ > .body
+
+ > .text
+ cursor default
+ margin 0
+ padding 0
+ font-size 1.1em
+ color #717171
+
+</style>
+
diff --git a/src/server/web/app/mobile/views/components/post-detail.vue b/src/server/web/app/mobile/views/components/post-detail.vue
new file mode 100644
index 0000000000..b5c9158300
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/post-detail.vue
@@ -0,0 +1,447 @@
+<template>
+<div class="mk-post-detail">
+ <button
+ class="more"
+ v-if="p.reply && p.reply.reply_id && context == null"
+ @click="fetchContext"
+ :disabled="fetchingContext"
+ >
+ <template v-if="!contextFetching">%fa:ellipsis-v%</template>
+ <template v-if="contextFetching">%fa:spinner .pulse%</template>
+ </button>
+ <div class="context">
+ <x-sub v-for="post in context" :key="post.id" :post="post"/>
+ </div>
+ <div class="reply-to" v-if="p.reply">
+ <x-sub :post="p.reply"/>
+ </div>
+ <div class="repost" v-if="isRepost">
+ <p>
+ <router-link class="avatar-anchor" :to="`/@${acct}`">
+ <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=32`" alt="avatar"/>
+ </router-link>
+ %fa:retweet%
+ <router-link class="name" :to="`/@${acct}`">
+ {{ post.user.name }}
+ </router-link>
+ がRepost
+ </p>
+ </div>
+ <article>
+ <header>
+ <router-link class="avatar-anchor" :to="`/@${pAcct}`">
+ <img class="avatar" :src="`${p.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ </router-link>
+ <div>
+ <router-link class="name" :to="`/@${pAcct}`">{{ p.user.name }}</router-link>
+ <span class="username">@{{ pAcct }}</span>
+ </div>
+ </header>
+ <div class="body">
+ <mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/>
+ <div class="tags" v-if="p.tags && p.tags.length > 0">
+ <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link>
+ </div>
+ <div class="media" v-if="p.media">
+ <mk-media-list :media-list="p.media"/>
+ </div>
+ <mk-poll v-if="p.poll" :post="p"/>
+ <mk-url-preview v-for="url in urls" :url="url" :key="url"/>
+ <a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.latitude},${p.geo.longitude}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
+ <div class="map" v-if="p.geo" ref="map"></div>
+ <div class="repost" v-if="p.repost">
+ <mk-post-preview :post="p.repost"/>
+ </div>
+ </div>
+ <router-link class="time" :to="`/@${pAcct}/${p.id}`">
+ <mk-time :time="p.created_at" mode="detail"/>
+ </router-link>
+ <footer>
+ <mk-reactions-viewer :post="p"/>
+ <button @click="reply" title="%i18n:mobile.tags.mk-post-detail.reply%">
+ %fa:reply%<p class="count" v-if="p.replies_count > 0">{{ p.replies_count }}</p>
+ </button>
+ <button @click="repost" title="Repost">
+ %fa:retweet%<p class="count" v-if="p.repost_count > 0">{{ p.repost_count }}</p>
+ </button>
+ <button :class="{ reacted: p.my_reaction != null }" @click="react" ref="reactButton" title="%i18n:mobile.tags.mk-post-detail.reaction%">
+ %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p>
+ </button>
+ <button @click="menu" ref="menuButton">
+ %fa:ellipsis-h%
+ </button>
+ </footer>
+ </article>
+ <div class="replies" v-if="!compact">
+ <x-sub v-for="post in replies" :key="post.id" :post="post"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import getAcct from '../../../../../common/user/get-acct';
+import MkPostMenu from '../../../common/views/components/post-menu.vue';
+import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
+import XSub from './post-detail.sub.vue';
+
+export default Vue.extend({
+ components: {
+ XSub
+ },
+ props: {
+ post: {
+ type: Object,
+ required: true
+ },
+ compact: {
+ default: false
+ }
+ },
+ data() {
+ return {
+ context: [],
+ contextFetching: false,
+ replies: [],
+ };
+ },
+ computed: {
+ acct() {
+ return getAcct(this.post.user);
+ },
+ pAcct() {
+ return getAcct(this.p.user);
+ },
+ isRepost(): boolean {
+ return (this.post.repost &&
+ this.post.text == null &&
+ this.post.media_ids == null &&
+ this.post.poll == null);
+ },
+ p(): any {
+ return this.isRepost ? this.post.repost : this.post;
+ },
+ reactionsCount(): number {
+ return this.p.reaction_counts
+ ? Object.keys(this.p.reaction_counts)
+ .map(key => this.p.reaction_counts[key])
+ .reduce((a, b) => a + b)
+ : 0;
+ },
+ urls(): string[] {
+ if (this.p.ast) {
+ return this.p.ast
+ .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+ .map(t => t.url);
+ } else {
+ return null;
+ }
+ }
+ },
+ mounted() {
+ // Get replies
+ if (!this.compact) {
+ (this as any).api('posts/replies', {
+ post_id: this.p.id,
+ limit: 8
+ }).then(replies => {
+ this.replies = replies;
+ });
+ }
+
+ // Draw map
+ if (this.p.geo) {
+ const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.account.client_settings.showMaps : true;
+ if (shouldShowMap) {
+ (this as any).os.getGoogleMaps().then(maps => {
+ const uluru = new maps.LatLng(this.p.geo.latitude, this.p.geo.longitude);
+ const map = new maps.Map(this.$refs.map, {
+ center: uluru,
+ zoom: 15
+ });
+ new maps.Marker({
+ position: uluru,
+ map: map
+ });
+ });
+ }
+ }
+ },
+ methods: {
+ fetchContext() {
+ this.contextFetching = true;
+
+ // Fetch context
+ (this as any).api('posts/context', {
+ post_id: this.p.reply_id
+ }).then(context => {
+ this.contextFetching = false;
+ this.context = context.reverse();
+ });
+ },
+ reply() {
+ (this as any).apis.post({
+ reply: this.p
+ });
+ },
+ repost() {
+ (this as any).apis.post({
+ repost: this.p
+ });
+ },
+ react() {
+ (this as any).os.new(MkReactionPicker, {
+ source: this.$refs.reactButton,
+ post: this.p,
+ compact: true
+ });
+ },
+ menu() {
+ (this as any).os.new(MkPostMenu, {
+ source: this.$refs.menuButton,
+ post: this.p,
+ compact: true
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+.mk-post-detail
+ overflow hidden
+ margin 0 auto
+ padding 0
+ width 100%
+ text-align left
+ background #fff
+ border-radius 8px
+ box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+
+ > .fetching
+ padding 64px 0
+
+ > .more
+ display block
+ margin 0
+ padding 10px 0
+ width 100%
+ font-size 1em
+ text-align center
+ color #999
+ cursor pointer
+ background #fafafa
+ outline none
+ border none
+ border-bottom solid 1px #eef0f2
+ border-radius 6px 6px 0 0
+ box-shadow none
+
+ &:hover
+ background #f6f6f6
+
+ &:active
+ background #f0f0f0
+
+ &:disabled
+ color #ccc
+
+ > .context
+ > *
+ border-bottom 1px solid #eef0f2
+
+ > .repost
+ color #9dbb00
+ background linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+
+ > p
+ margin 0
+ padding 16px 32px
+
+ .avatar-anchor
+ display inline-block
+
+ .avatar
+ vertical-align bottom
+ min-width 28px
+ min-height 28px
+ max-width 28px
+ max-height 28px
+ margin 0 8px 0 0
+ border-radius 6px
+
+ [data-fa]
+ margin-right 4px
+
+ .name
+ font-weight bold
+
+ & + article
+ padding-top 8px
+
+ > .reply-to
+ border-bottom 1px solid #eef0f2
+
+ > article
+ padding 14px 16px 9px 16px
+
+ @media (min-width 500px)
+ padding 28px 32px 18px 32px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ &:hover
+ > .main > footer > button
+ color #888
+
+ > header
+ display flex
+ line-height 1.1
+
+ > .avatar-anchor
+ display block
+ padding 0 .5em 0 0
+
+ > .avatar
+ display block
+ width 54px
+ height 54px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
+
+ @media (min-width 500px)
+ width 60px
+ height 60px
+
+ > div
+
+ > .name
+ display inline-block
+ margin .4em 0
+ color #777
+ font-size 16px
+ font-weight bold
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ display block
+ text-align left
+ margin 0
+ color #ccc
+
+ > .body
+ padding 8px 0
+
+ > .repost
+ margin 8px 0
+
+ > .mk-post-preview
+ padding 16px
+ border dashed 1px #c0dac6
+ border-radius 8px
+
+ > .location
+ margin 4px 0
+ font-size 12px
+ color #ccc
+
+ > .map
+ width 100%
+ height 200px
+
+ &:empty
+ display none
+
+ > .mk-url-preview
+ margin-top 8px
+
+ > .media
+ > img
+ display block
+ max-width 100%
+
+ > .tags
+ margin 4px 0 0 0
+
+ > *
+ display inline-block
+ margin 0 8px 0 0
+ padding 2px 8px 2px 16px
+ font-size 90%
+ color #8d969e
+ background #edf0f3
+ border-radius 4px
+
+ &:before
+ content ""
+ display block
+ position absolute
+ top 0
+ bottom 0
+ left 4px
+ width 8px
+ height 8px
+ margin auto 0
+ background #fff
+ border-radius 100%
+
+ > .time
+ font-size 16px
+ color #c0c0c0
+
+ > footer
+ font-size 1.2em
+
+ > button
+ margin 0
+ padding 8px
+ background transparent
+ border none
+ box-shadow none
+ font-size 1em
+ color #ddd
+ cursor pointer
+
+ &:not(:last-child)
+ margin-right 28px
+
+ &:hover
+ color #666
+
+ > .count
+ display inline
+ margin 0 0 0 8px
+ color #999
+
+ &.reacted
+ color $theme-color
+
+ > .replies
+ > *
+ border-top 1px solid #eef0f2
+
+</style>
+
+<style lang="stylus" module>
+.text
+ display block
+ margin 0
+ padding 0
+ overflow-wrap break-word
+ font-size 16px
+ color #717171
+
+ @media (min-width 500px)
+ font-size 24px
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/post-form.vue b/src/server/web/app/mobile/views/components/post-form.vue
new file mode 100644
index 0000000000..2aa3c6f6c0
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/post-form.vue
@@ -0,0 +1,276 @@
+<template>
+<div class="mk-post-form">
+ <header>
+ <button class="cancel" @click="cancel">%fa:times%</button>
+ <div>
+ <span class="text-count" :class="{ over: text.length > 1000 }">{{ 1000 - text.length }}</span>
+ <span class="geo" v-if="geo">%fa:map-marker-alt%</span>
+ <button class="submit" :disabled="posting" @click="post">{{ reply ? '返信' : '%i18n:mobile.tags.mk-post-form.submit%' }}</button>
+ </div>
+ </header>
+ <div class="form">
+ <mk-post-preview v-if="reply" :post="reply"/>
+ <textarea v-model="text" ref="text" :disabled="posting" :placeholder="reply ? '%i18n:mobile.tags.mk-post-form.reply-placeholder%' : '%i18n:mobile.tags.mk-post-form.post-placeholder%'"></textarea>
+ <div class="attaches" v-show="files.length != 0">
+ <x-draggable class="files" :list="files" :options="{ animation: 150 }">
+ <div class="file" v-for="file in files" :key="file.id">
+ <div class="img" :style="`background-image: url(${file.url}?thumbnail&size=128)`" @click="detachMedia(file)"></div>
+ </div>
+ </x-draggable>
+ </div>
+ <mk-poll-editor v-if="poll" ref="poll" @destroyed="poll = false"/>
+ <mk-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/>
+ <button class="upload" @click="chooseFile">%fa:upload%</button>
+ <button class="drive" @click="chooseFileFromDrive">%fa:cloud%</button>
+ <button class="kao" @click="kao">%fa:R smile%</button>
+ <button class="poll" @click="poll = true">%fa:chart-pie%</button>
+ <button class="geo" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button>
+ <input ref="file" class="file" type="file" accept="image/*" multiple="multiple" @change="onChangeFile"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as XDraggable from 'vuedraggable';
+import getKao from '../../../common/scripts/get-kao';
+
+export default Vue.extend({
+ components: {
+ XDraggable
+ },
+ props: ['reply'],
+ data() {
+ return {
+ posting: false,
+ text: '',
+ uploadings: [],
+ files: [],
+ poll: false,
+ geo: null
+ };
+ },
+ mounted() {
+ this.$nextTick(() => {
+ this.focus();
+ });
+ },
+ methods: {
+ focus() {
+ (this.$refs.text as any).focus();
+ },
+ chooseFile() {
+ (this.$refs.file as any).click();
+ },
+ chooseFileFromDrive() {
+ (this as any).apis.chooseDriveFile({
+ multiple: true
+ }).then(files => {
+ files.forEach(this.attachMedia);
+ });
+ },
+ attachMedia(driveFile) {
+ this.files.push(driveFile);
+ this.$emit('change-attached-media', this.files);
+ },
+ detachMedia(file) {
+ this.files = this.files.filter(x => x.id != file.id);
+ this.$emit('change-attached-media', this.files);
+ },
+ onChangeFile() {
+ Array.from((this.$refs.file as any).files).forEach(this.upload);
+ },
+ upload(file) {
+ (this.$refs.uploader as any).upload(file);
+ },
+ onChangeUploadings(uploads) {
+ this.$emit('change-uploadings', uploads);
+ },
+ setGeo() {
+ if (navigator.geolocation == null) {
+ alert('お使いの端末は位置情報に対応していません');
+ return;
+ }
+
+ navigator.geolocation.getCurrentPosition(pos => {
+ this.geo = pos.coords;
+ }, err => {
+ alert('エラー: ' + err.message);
+ }, {
+ enableHighAccuracy: true
+ });
+ },
+ removeGeo() {
+ this.geo = null;
+ },
+ clear() {
+ this.text = '';
+ this.files = [];
+ this.poll = false;
+ this.$emit('change-attached-media');
+ },
+ post() {
+ this.posting = true;
+ const viaMobile = (this as any).os.i.account.client_settings.disableViaMobile !== true;
+ (this as any).api('posts/create', {
+ text: this.text == '' ? undefined : this.text,
+ media_ids: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
+ reply_id: this.reply ? this.reply.id : undefined,
+ poll: this.poll ? (this.$refs.poll as any).get() : undefined,
+ geo: this.geo ? {
+ latitude: this.geo.latitude,
+ longitude: this.geo.longitude,
+ altitude: this.geo.altitude,
+ accuracy: this.geo.accuracy,
+ altitudeAccuracy: this.geo.altitudeAccuracy,
+ heading: isNaN(this.geo.heading) ? null : this.geo.heading,
+ speed: this.geo.speed,
+ } : null,
+ via_mobile: viaMobile
+ }).then(data => {
+ this.$emit('post');
+ this.$destroy();
+ }).catch(err => {
+ this.posting = false;
+ });
+ },
+ cancel() {
+ this.$emit('cancel');
+ this.$destroy();
+ },
+ kao() {
+ this.text += getKao();
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+.mk-post-form
+ max-width 500px
+ width calc(100% - 16px)
+ margin 8px auto
+ background #fff
+ border-radius 8px
+ box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+
+ @media (min-width 500px)
+ margin 16px auto
+ width calc(100% - 32px)
+
+ > header
+ z-index 1
+ height 50px
+ box-shadow 0 1px 0 0 rgba(0, 0, 0, 0.1)
+
+ > .cancel
+ padding 0
+ width 50px
+ line-height 50px
+ font-size 24px
+ color #555
+
+ > div
+ position absolute
+ top 0
+ right 0
+ color #657786
+
+ > .text-count
+ line-height 50px
+
+ > .geo
+ margin 0 8px
+ line-height 50px
+
+ > .submit
+ margin 8px
+ padding 0 16px
+ line-height 34px
+ vertical-align bottom
+ color $theme-color-foreground
+ background $theme-color
+ border-radius 4px
+
+ &:disabled
+ opacity 0.7
+
+ > .form
+ max-width 500px
+ margin 0 auto
+
+ > .mk-post-preview
+ padding 16px
+
+ > .attaches
+
+ > .files
+ display block
+ margin 0
+ padding 4px
+ list-style none
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .file
+ display block
+ float left
+ margin 0
+ padding 0
+ border solid 4px transparent
+
+ > .img
+ width 64px
+ height 64px
+ background-size cover
+ background-position center center
+
+ > .mk-uploader
+ margin 8px 0 0 0
+ padding 8px
+
+ > .file
+ display none
+
+ > textarea
+ display block
+ padding 12px
+ margin 0
+ width 100%
+ max-width 100%
+ min-width 100%
+ min-height 80px
+ font-size 16px
+ color #333
+ border none
+ border-bottom solid 1px #ddd
+ border-radius 0
+
+ &:disabled
+ opacity 0.5
+
+ > .upload
+ > .drive
+ > .kao
+ > .poll
+ > .geo
+ display inline-block
+ padding 0
+ margin 0
+ width 48px
+ height 48px
+ font-size 20px
+ color #657786
+ background transparent
+ outline none
+ border none
+ border-radius 0
+ box-shadow none
+
+</style>
+
diff --git a/src/server/web/app/mobile/views/components/post-preview.vue b/src/server/web/app/mobile/views/components/post-preview.vue
new file mode 100644
index 0000000000..0bd0a355b3
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/post-preview.vue
@@ -0,0 +1,106 @@
+<template>
+<div class="mk-post-preview">
+ <router-link class="avatar-anchor" :to="`/@${acct}`">
+ <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ </router-link>
+ <div class="main">
+ <header>
+ <router-link class="name" :to="`/@${acct}`">{{ post.user.name }}</router-link>
+ <span class="username">@{{ acct }}</span>
+ <router-link class="time" :to="`/@${acct}/${post.id}`">
+ <mk-time :time="post.created_at"/>
+ </router-link>
+ </header>
+ <div class="body">
+ <mk-sub-post-content class="text" :post="post"/>
+ </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);
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-post-preview
+ margin 0
+ padding 0
+ font-size 0.9em
+ background #fff
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ &:hover
+ > .main > footer > button
+ color #888
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 12px 0 0
+
+ > .avatar
+ display block
+ width 48px
+ height 48px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
+
+ > .main
+ float left
+ width calc(100% - 60px)
+
+ > header
+ display flex
+ margin-bottom 4px
+ white-space nowrap
+
+ > .name
+ display block
+ margin 0 .5em 0 0
+ padding 0
+ overflow hidden
+ color #607073
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+ text-overflow ellipsis
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 .5em 0 0
+ color #d1d8da
+
+ > .time
+ margin-left auto
+ color #b2b8bb
+
+ > .body
+
+ > .text
+ cursor default
+ margin 0
+ padding 0
+ font-size 1.1em
+ color #717171
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/post.sub.vue b/src/server/web/app/mobile/views/components/post.sub.vue
new file mode 100644
index 0000000000..b6ee7c1e08
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/post.sub.vue
@@ -0,0 +1,115 @@
+<template>
+<div class="sub">
+ <router-link class="avatar-anchor" :to="`/@${acct}`">
+ <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=96`" alt="avatar"/>
+ </router-link>
+ <div class="main">
+ <header>
+ <router-link class="name" :to="`/@${acct}`">{{ post.user.name }}</router-link>
+ <span class="username">@{{ acct }}</span>
+ <router-link class="created-at" :to="`/@${acct}/${post.id}`">
+ <mk-time :time="post.created_at"/>
+ </router-link>
+ </header>
+ <div class="body">
+ <mk-sub-post-content class="text" :post="post"/>
+ </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);
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.sub
+ font-size 0.9em
+ padding 16px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 10px 0 0
+
+ @media (min-width 500px)
+ margin-right 16px
+
+ > .avatar
+ display block
+ width 44px
+ height 44px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
+
+ @media (min-width 500px)
+ width 52px
+ height 52px
+
+ > .main
+ float left
+ width calc(100% - 54px)
+
+ @media (min-width 500px)
+ width calc(100% - 68px)
+
+ > header
+ display flex
+ margin-bottom 2px
+ white-space nowrap
+
+ > .name
+ display block
+ margin 0 0.5em 0 0
+ padding 0
+ overflow hidden
+ color #607073
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+ text-overflow ellipsis
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0
+ color #d1d8da
+
+ > .created-at
+ margin-left auto
+ color #b2b8bb
+
+ > .body
+
+ > .text
+ cursor default
+ margin 0
+ padding 0
+ font-size 1.1em
+ color #717171
+
+ pre
+ max-height 120px
+ font-size 80%
+
+</style>
+
diff --git a/src/server/web/app/mobile/views/components/post.vue b/src/server/web/app/mobile/views/components/post.vue
new file mode 100644
index 0000000000..e5bc964792
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/post.vue
@@ -0,0 +1,523 @@
+<template>
+<div class="post" :class="{ repost: isRepost }">
+ <div class="reply-to" v-if="p.reply">
+ <x-sub :post="p.reply"/>
+ </div>
+ <div class="repost" v-if="isRepost">
+ <p>
+ <router-link class="avatar-anchor" :to="`/@${acct}`">
+ <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ </router-link>
+ %fa:retweet%
+ <span>{{ '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('{')) }}</span>
+ <router-link class="name" :to="`/@${acct}`">{{ post.user.name }}</router-link>
+ <span>{{ '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr('%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1) }}</span>
+ </p>
+ <mk-time :time="post.created_at"/>
+ </div>
+ <article>
+ <router-link class="avatar-anchor" :to="`/@${pAcct}`">
+ <img class="avatar" :src="`${p.user.avatar_url}?thumbnail&size=96`" alt="avatar"/>
+ </router-link>
+ <div class="main">
+ <header>
+ <router-link class="name" :to="`/@${pAcct}`">{{ p.user.name }}</router-link>
+ <span class="is-bot" v-if="p.user.host === null && p.user.account.is_bot">bot</span>
+ <span class="username">@{{ pAcct }}</span>
+ <div class="info">
+ <span class="mobile" v-if="p.via_mobile">%fa:mobile-alt%</span>
+ <router-link class="created-at" :to="url">
+ <mk-time :time="p.created_at"/>
+ </router-link>
+ </div>
+ </header>
+ <div class="body">
+ <p class="channel" v-if="p.channel != null"><a target="_blank">{{ p.channel.title }}</a>:</p>
+ <div class="text">
+ <a class="reply" v-if="p.reply">
+ %fa:reply%
+ </a>
+ <mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/>
+ <a class="rp" v-if="p.repost != null">RP:</a>
+ </div>
+ <div class="media" v-if="p.media">
+ <mk-media-list :media-list="p.media"/>
+ </div>
+ <mk-poll v-if="p.poll" :post="p" ref="pollViewer"/>
+ <div class="tags" v-if="p.tags && p.tags.length > 0">
+ <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link>
+ </div>
+ <mk-url-preview v-for="url in urls" :url="url" :key="url"/>
+ <a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.latitude},${p.geo.longitude}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
+ <div class="map" v-if="p.geo" ref="map"></div>
+ <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span>
+ <div class="repost" v-if="p.repost">
+ <mk-post-preview :post="p.repost"/>
+ </div>
+ </div>
+ <footer>
+ <mk-reactions-viewer :post="p" ref="reactionsViewer"/>
+ <button @click="reply">
+ %fa:reply%<p class="count" v-if="p.replies_count > 0">{{ p.replies_count }}</p>
+ </button>
+ <button @click="repost" title="Repost">
+ %fa:retweet%<p class="count" v-if="p.repost_count > 0">{{ p.repost_count }}</p>
+ </button>
+ <button :class="{ reacted: p.my_reaction != null }" @click="react" ref="reactButton">
+ %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p>
+ </button>
+ <button class="menu" @click="menu" ref="menuButton">
+ %fa:ellipsis-h%
+ </button>
+ </footer>
+ </div>
+ </article>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import getAcct from '../../../../../common/user/get-acct';
+import MkPostMenu from '../../../common/views/components/post-menu.vue';
+import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
+import XSub from './post.sub.vue';
+
+export default Vue.extend({
+ components: {
+ XSub
+ },
+ props: ['post'],
+ data() {
+ return {
+ connection: null,
+ connectionId: null
+ };
+ },
+ computed: {
+ acct() {
+ return getAcct(this.post.user);
+ },
+ pAcct() {
+ return getAcct(this.p.user);
+ },
+ isRepost(): boolean {
+ return (this.post.repost &&
+ this.post.text == null &&
+ this.post.media_ids == null &&
+ this.post.poll == null);
+ },
+ p(): any {
+ return this.isRepost ? this.post.repost : this.post;
+ },
+ reactionsCount(): number {
+ return this.p.reaction_counts
+ ? Object.keys(this.p.reaction_counts)
+ .map(key => this.p.reaction_counts[key])
+ .reduce((a, b) => a + b)
+ : 0;
+ },
+ url(): string {
+ return `/@${this.pAcct}/${this.p.id}`;
+ },
+ urls(): string[] {
+ if (this.p.ast) {
+ return this.p.ast
+ .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+ .map(t => t.url);
+ } else {
+ return null;
+ }
+ }
+ },
+ created() {
+ if ((this as any).os.isSignedIn) {
+ this.connection = (this as any).os.stream.getConnection();
+ this.connectionId = (this as any).os.stream.use();
+ }
+ },
+ mounted() {
+ this.capture(true);
+
+ if ((this as any).os.isSignedIn) {
+ this.connection.on('_connected_', this.onStreamConnected);
+ }
+
+ // Draw map
+ if (this.p.geo) {
+ const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.account.client_settings.showMaps : true;
+ if (shouldShowMap) {
+ (this as any).os.getGoogleMaps().then(maps => {
+ const uluru = new maps.LatLng(this.p.geo.latitude, this.p.geo.longitude);
+ const map = new maps.Map(this.$refs.map, {
+ center: uluru,
+ zoom: 15
+ });
+ new maps.Marker({
+ position: uluru,
+ map: map
+ });
+ });
+ }
+ }
+ },
+ beforeDestroy() {
+ this.decapture(true);
+
+ if ((this as any).os.isSignedIn) {
+ this.connection.off('_connected_', this.onStreamConnected);
+ (this as any).os.stream.dispose(this.connectionId);
+ }
+ },
+ methods: {
+ capture(withHandler = false) {
+ if ((this as any).os.isSignedIn) {
+ this.connection.send({
+ type: 'capture',
+ id: this.p.id
+ });
+ if (withHandler) this.connection.on('post-updated', this.onStreamPostUpdated);
+ }
+ },
+ decapture(withHandler = false) {
+ if ((this as any).os.isSignedIn) {
+ this.connection.send({
+ type: 'decapture',
+ id: this.p.id
+ });
+ if (withHandler) this.connection.off('post-updated', this.onStreamPostUpdated);
+ }
+ },
+ onStreamConnected() {
+ this.capture();
+ },
+ onStreamPostUpdated(data) {
+ const post = data.post;
+ if (post.id == this.post.id) {
+ this.$emit('update:post', post);
+ } else if (post.id == this.post.repost_id) {
+ this.post.repost = post;
+ }
+ },
+ reply() {
+ (this as any).apis.post({
+ reply: this.p
+ });
+ },
+ repost() {
+ (this as any).apis.post({
+ repost: this.p
+ });
+ },
+ react() {
+ (this as any).os.new(MkReactionPicker, {
+ source: this.$refs.reactButton,
+ post: this.p,
+ compact: true
+ });
+ },
+ menu() {
+ (this as any).os.new(MkPostMenu, {
+ source: this.$refs.menuButton,
+ post: this.p,
+ compact: true
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+.post
+ font-size 12px
+ border-bottom solid 1px #eaeaea
+
+ &:first-child
+ border-radius 8px 8px 0 0
+
+ > .repost
+ border-radius 8px 8px 0 0
+
+ &:last-of-type
+ border-bottom none
+
+ @media (min-width 350px)
+ font-size 14px
+
+ @media (min-width 500px)
+ font-size 16px
+
+ > .repost
+ color #9dbb00
+ background linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+
+ > p
+ margin 0
+ padding 8px 16px
+ line-height 28px
+
+ @media (min-width 500px)
+ padding 16px
+
+ .avatar-anchor
+ display inline-block
+
+ .avatar
+ vertical-align bottom
+ width 28px
+ height 28px
+ margin 0 8px 0 0
+ border-radius 6px
+
+ [data-fa]
+ margin-right 4px
+
+ .name
+ font-weight bold
+
+ > .mk-time
+ position absolute
+ top 8px
+ right 16px
+ font-size 0.9em
+ line-height 28px
+
+ @media (min-width 500px)
+ top 16px
+
+ & + article
+ padding-top 8px
+
+ > .reply-to
+ background rgba(0, 0, 0, 0.0125)
+
+ > .mk-post-preview
+ background transparent
+
+ > article
+ padding 14px 16px 9px 16px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 10px 8px 0
+ position -webkit-sticky
+ position sticky
+ top 62px
+
+ @media (min-width 500px)
+ margin-right 16px
+
+ > .avatar
+ display block
+ width 48px
+ height 48px
+ margin 0
+ border-radius 6px
+ vertical-align bottom
+
+ @media (min-width 500px)
+ width 58px
+ height 58px
+ border-radius 8px
+
+ > .main
+ float left
+ width calc(100% - 58px)
+
+ @media (min-width 500px)
+ width calc(100% - 74px)
+
+ > header
+ display flex
+ align-items center
+ white-space nowrap
+
+ @media (min-width 500px)
+ margin-bottom 2px
+
+ > .name
+ display block
+ margin 0 0.5em 0 0
+ padding 0
+ overflow hidden
+ color #627079
+ font-size 1em
+ font-weight bold
+ text-decoration none
+ text-overflow ellipsis
+
+ &:hover
+ text-decoration underline
+
+ > .is-bot
+ margin 0 0.5em 0 0
+ padding 1px 6px
+ font-size 12px
+ color #aaa
+ border solid 1px #ddd
+ border-radius 3px
+
+ > .username
+ margin 0 0.5em 0 0
+ color #ccc
+
+ > .info
+ margin-left auto
+ font-size 0.9em
+
+ > .mobile
+ margin-right 6px
+ color #c0c0c0
+
+ > .created-at
+ color #c0c0c0
+
+ > .body
+
+ > .text
+ display block
+ margin 0
+ padding 0
+ overflow-wrap break-word
+ font-size 1.1em
+ color #717171
+
+ >>> .quote
+ margin 8px
+ padding 6px 12px
+ color #aaa
+ border-left solid 3px #eee
+
+ > .reply
+ margin-right 8px
+ color #717171
+
+ > .rp
+ margin-left 4px
+ font-style oblique
+ color #a0bf46
+
+ [data-is-me]:after
+ content "you"
+ padding 0 4px
+ margin-left 4px
+ font-size 80%
+ color $theme-color-foreground
+ background $theme-color
+ border-radius 4px
+
+ .mk-url-preview
+ margin-top 8px
+
+ > .channel
+ margin 0
+
+ > .tags
+ margin 4px 0 0 0
+
+ > *
+ display inline-block
+ margin 0 8px 0 0
+ padding 2px 8px 2px 16px
+ font-size 90%
+ color #8d969e
+ background #edf0f3
+ border-radius 4px
+
+ &:before
+ content ""
+ display block
+ position absolute
+ top 0
+ bottom 0
+ left 4px
+ width 8px
+ height 8px
+ margin auto 0
+ background #fff
+ border-radius 100%
+
+ > .media
+ > img
+ display block
+ max-width 100%
+
+ > .location
+ margin 4px 0
+ font-size 12px
+ color #ccc
+
+ > .map
+ width 100%
+ height 200px
+
+ &:empty
+ display none
+
+ > .app
+ font-size 12px
+ color #ccc
+
+ > .mk-poll
+ font-size 80%
+
+ > .repost
+ margin 8px 0
+
+ > .mk-post-preview
+ padding 16px
+ border dashed 1px #c0dac6
+ border-radius 8px
+
+ > footer
+ > button
+ margin 0
+ padding 8px
+ background transparent
+ border none
+ box-shadow none
+ font-size 1em
+ color #ddd
+ cursor pointer
+
+ &:not(:last-child)
+ margin-right 28px
+
+ &:hover
+ color #666
+
+ > .count
+ display inline
+ margin 0 0 0 8px
+ color #999
+
+ &.reacted
+ color $theme-color
+
+ &.menu
+ @media (max-width 350px)
+ display none
+
+</style>
+
+<style lang="stylus" module>
+.text
+ code
+ padding 4px 8px
+ margin 0 0.5em
+ font-size 80%
+ color #525252
+ background #f8f8f8
+ border-radius 2px
+
+ pre > code
+ padding 16px
+ margin 0
+</style>
diff --git a/src/server/web/app/mobile/views/components/posts.vue b/src/server/web/app/mobile/views/components/posts.vue
new file mode 100644
index 0000000000..7e71fa0982
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/posts.vue
@@ -0,0 +1,111 @@
+<template>
+<div class="mk-posts">
+ <slot name="head"></slot>
+ <slot></slot>
+ <template v-for="(post, i) in _posts">
+ <mk-post :post="post" :key="post.id" @update:post="onPostUpdated(i, $event)"/>
+ <p class="date" v-if="i != posts.length - 1 && post._date != _posts[i + 1]._date">
+ <span>%fa:angle-up%{{ post._datetext }}</span>
+ <span>%fa:angle-down%{{ _posts[i + 1]._datetext }}</span>
+ </p>
+ </template>
+ <footer>
+ <slot name="tail"></slot>
+ </footer>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+ props: {
+ posts: {
+ type: Array,
+ default: () => []
+ }
+ },
+ computed: {
+ _posts(): any[] {
+ return (this.posts as any).map(post => {
+ const date = new Date(post.created_at).getDate();
+ const month = new Date(post.created_at).getMonth() + 1;
+ post._date = date;
+ post._datetext = `${month}月 ${date}日`;
+ return post;
+ });
+ }
+ },
+ methods: {
+ onPostUpdated(i, post) {
+ Vue.set((this as any).posts, i, post);
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+.mk-posts
+ background #fff
+ border-radius 8px
+ box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+
+ > .init
+ padding 64px 0
+ text-align center
+ color #999
+
+ > [data-fa]
+ margin-right 4px
+
+ > .empty
+ margin 0 auto
+ padding 32px
+ max-width 400px
+ text-align center
+ color #999
+
+ > [data-fa]
+ display block
+ margin-bottom 16px
+ font-size 3em
+ color #ccc
+
+ > .date
+ display block
+ margin 0
+ line-height 32px
+ text-align center
+ font-size 0.9em
+ color #aaa
+ background #fdfdfd
+ border-bottom solid 1px #eaeaea
+
+ span
+ margin 0 16px
+
+ [data-fa]
+ margin-right 8px
+
+ > footer
+ text-align center
+ border-top solid 1px #eaeaea
+ border-bottom-left-radius 4px
+ border-bottom-right-radius 4px
+
+ &:empty
+ display none
+
+ > button
+ margin 0
+ padding 16px
+ width 100%
+ color $theme-color
+ border-radius 0 0 8px 8px
+
+ &:disabled
+ opacity 0.7
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/sub-post-content.vue b/src/server/web/app/mobile/views/components/sub-post-content.vue
new file mode 100644
index 0000000000..389fc420ea
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/sub-post-content.vue
@@ -0,0 +1,43 @@
+<template>
+<div class="mk-sub-post-content">
+ <div class="body">
+ <a class="reply" v-if="post.reply_id">%fa:reply%</a>
+ <mk-post-html v-if="post.ast" :ast="post.ast" :i="os.i"/>
+ <a class="rp" v-if="post.repost_id">RP: ...</a>
+ </div>
+ <details v-if="post.media">
+ <summary>({{ post.media.length }}個のメディア)</summary>
+ <mk-media-list :media-list="post.media"/>
+ </details>
+ <details v-if="post.poll">
+ <summary>%i18n:mobile.tags.mk-sub-post-content.poll%</summary>
+ <mk-poll :post="post"/>
+ </details>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ props: ['post']
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-sub-post-content
+ overflow-wrap break-word
+
+ > .body
+ > .reply
+ margin-right 6px
+ color #717171
+
+ > .rp
+ margin-left 4px
+ font-style oblique
+ color #a0bf46
+
+ mk-poll
+ font-size 80%
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/timeline.vue b/src/server/web/app/mobile/views/components/timeline.vue
new file mode 100644
index 0000000000..c0e766523f
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/timeline.vue
@@ -0,0 +1,109 @@
+<template>
+<div class="mk-timeline">
+ <mk-friends-maker v-if="alone"/>
+ <mk-posts :posts="posts">
+ <div class="init" v-if="fetching">
+ %fa:spinner .pulse%%i18n:common.loading%
+ </div>
+ <div class="empty" v-if="!fetching && posts.length == 0">
+ %fa:R comments%
+ %i18n:mobile.tags.mk-home-timeline.empty-timeline%
+ </div>
+ <button v-if="!fetching && existMore" @click="more" :disabled="moreFetching" slot="tail">
+ <span v-if="!moreFetching">%i18n:mobile.tags.mk-timeline.load-more%</span>
+ <span v-if="moreFetching">%i18n:common.loading%<mk-ellipsis/></span>
+ </button>
+ </mk-posts>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+const limit = 10;
+
+export default Vue.extend({
+ props: {
+ date: {
+ type: Date,
+ required: false
+ }
+ },
+ data() {
+ return {
+ fetching: true,
+ moreFetching: false,
+ posts: [],
+ existMore: false,
+ connection: null,
+ connectionId: null
+ };
+ },
+ computed: {
+ alone(): boolean {
+ return (this as any).os.i.following_count == 0;
+ }
+ },
+ mounted() {
+ this.connection = (this as any).os.stream.getConnection();
+ this.connectionId = (this as any).os.stream.use();
+
+ this.connection.on('post', this.onPost);
+ this.connection.on('follow', this.onChangeFollowing);
+ this.connection.on('unfollow', this.onChangeFollowing);
+
+ this.fetch();
+ },
+ beforeDestroy() {
+ this.connection.off('post', this.onPost);
+ this.connection.off('follow', this.onChangeFollowing);
+ this.connection.off('unfollow', this.onChangeFollowing);
+ (this as any).os.stream.dispose(this.connectionId);
+ },
+ methods: {
+ fetch(cb?) {
+ this.fetching = true;
+ (this as any).api('posts/timeline', {
+ limit: limit + 1,
+ until_date: this.date ? (this.date as any).getTime() : undefined
+ }).then(posts => {
+ if (posts.length == limit + 1) {
+ posts.pop();
+ this.existMore = true;
+ }
+ this.posts = posts;
+ this.fetching = false;
+ this.$emit('loaded');
+ if (cb) cb();
+ });
+ },
+ more() {
+ this.moreFetching = true;
+ (this as any).api('posts/timeline', {
+ limit: limit + 1,
+ until_id: this.posts[this.posts.length - 1].id
+ }).then(posts => {
+ if (posts.length == limit + 1) {
+ posts.pop();
+ this.existMore = true;
+ } else {
+ this.existMore = false;
+ }
+ this.posts = this.posts.concat(posts);
+ this.moreFetching = false;
+ });
+ },
+ onPost(post) {
+ this.posts.unshift(post);
+ },
+ onChangeFollowing() {
+ this.fetch();
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-friends-maker
+ margin-bottom 8px
+</style>
diff --git a/src/server/web/app/mobile/views/components/ui.header.vue b/src/server/web/app/mobile/views/components/ui.header.vue
new file mode 100644
index 0000000000..66e10a0f8a
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/ui.header.vue
@@ -0,0 +1,242 @@
+<template>
+<div class="header">
+ <mk-special-message/>
+ <div class="main" ref="main">
+ <div class="backdrop"></div>
+ <p ref="welcomeback" v-if="os.isSignedIn">おかえりなさい、<b>{{ os.i.name }}</b>さん</p>
+ <div class="content" ref="mainContainer">
+ <button class="nav" @click="$parent.isDrawerOpening = true">%fa:bars%</button>
+ <template v-if="hasUnreadNotifications || hasUnreadMessagingMessages || hasGameInvitations">%fa:circle%</template>
+ <h1>
+ <slot>Misskey</slot>
+ </h1>
+ <slot name="func"></slot>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as anime from 'animejs';
+
+export default Vue.extend({
+ props: ['func'],
+ data() {
+ return {
+ hasUnreadNotifications: false,
+ hasUnreadMessagingMessages: false,
+ hasGameInvitations: false,
+ connection: null,
+ connectionId: null
+ };
+ },
+ mounted() {
+ if ((this as any).os.isSignedIn) {
+ this.connection = (this as any).os.stream.getConnection();
+ this.connectionId = (this as any).os.stream.use();
+
+ this.connection.on('read_all_notifications', this.onReadAllNotifications);
+ this.connection.on('unread_notification', this.onUnreadNotification);
+ this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages);
+ this.connection.on('unread_messaging_message', this.onUnreadMessagingMessage);
+ this.connection.on('othello_invited', this.onOthelloInvited);
+ this.connection.on('othello_no_invites', this.onOthelloNoInvites);
+
+ // Fetch count of unread notifications
+ (this as any).api('notifications/get_unread_count').then(res => {
+ if (res.count > 0) {
+ this.hasUnreadNotifications = true;
+ }
+ });
+
+ // Fetch count of unread messaging messages
+ (this as any).api('messaging/unread').then(res => {
+ if (res.count > 0) {
+ this.hasUnreadMessagingMessages = true;
+ }
+ });
+
+ const ago = (new Date().getTime() - new Date((this as any).os.i.account.last_used_at).getTime()) / 1000
+ const isHisasiburi = ago >= 3600;
+ (this as any).os.i.account.last_used_at = new Date();
+ if (isHisasiburi) {
+ (this.$refs.welcomeback as any).style.display = 'block';
+ (this.$refs.main as any).style.overflow = 'hidden';
+
+ anime({
+ targets: this.$refs.welcomeback,
+ top: '0',
+ opacity: 1,
+ delay: 1000,
+ duration: 500,
+ easing: 'easeOutQuad'
+ });
+
+ anime({
+ targets: this.$refs.mainContainer,
+ opacity: 0,
+ delay: 1000,
+ duration: 500,
+ easing: 'easeOutQuad'
+ });
+
+ setTimeout(() => {
+ anime({
+ targets: this.$refs.welcomeback,
+ top: '-48px',
+ opacity: 0,
+ duration: 500,
+ complete: () => {
+ (this.$refs.welcomeback as any).style.display = 'none';
+ (this.$refs.main as any).style.overflow = 'initial';
+ },
+ easing: 'easeInQuad'
+ });
+
+ anime({
+ targets: this.$refs.mainContainer,
+ opacity: 1,
+ duration: 500,
+ easing: 'easeInQuad'
+ });
+ }, 2500);
+ }
+ }
+ },
+ beforeDestroy() {
+ if ((this as any).os.isSignedIn) {
+ this.connection.off('read_all_notifications', this.onReadAllNotifications);
+ this.connection.off('unread_notification', this.onUnreadNotification);
+ this.connection.off('read_all_messaging_messages', this.onReadAllMessagingMessages);
+ this.connection.off('unread_messaging_message', this.onUnreadMessagingMessage);
+ this.connection.off('othello_invited', this.onOthelloInvited);
+ this.connection.off('othello_no_invites', this.onOthelloNoInvites);
+ (this as any).os.stream.dispose(this.connectionId);
+ }
+ },
+ methods: {
+ onReadAllNotifications() {
+ this.hasUnreadNotifications = false;
+ },
+ onUnreadNotification() {
+ this.hasUnreadNotifications = true;
+ },
+ onReadAllMessagingMessages() {
+ this.hasUnreadMessagingMessages = false;
+ },
+ onUnreadMessagingMessage() {
+ this.hasUnreadMessagingMessages = true;
+ },
+ onOthelloInvited() {
+ this.hasGameInvitations = true;
+ },
+ onOthelloNoInvites() {
+ this.hasGameInvitations = false;
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+.header
+ $height = 48px
+
+ position fixed
+ top 0
+ z-index 1024
+ width 100%
+ box-shadow 0 1px 0 rgba(#000, 0.075)
+
+ > .main
+ color rgba(#fff, 0.9)
+
+ > .backdrop
+ position absolute
+ top 0
+ z-index 1000
+ width 100%
+ height $height
+ -webkit-backdrop-filter blur(12px)
+ backdrop-filter blur(12px)
+ //background-color rgba(#1b2023, 0.75)
+ background-color #1b2023
+
+ > p
+ display none
+ position absolute
+ z-index 1002
+ top $height
+ width 100%
+ line-height $height
+ margin 0
+ text-align center
+ color #fff
+ opacity 0
+
+ > .content
+ z-index 1001
+
+ > h1
+ display block
+ margin 0 auto
+ padding 0
+ width 100%
+ max-width calc(100% - 112px)
+ text-align center
+ font-size 1.1em
+ font-weight normal
+ line-height $height
+ white-space nowrap
+ overflow hidden
+ text-overflow ellipsis
+
+ [data-fa], [data-icon]
+ margin-right 4px
+
+ > img
+ display inline-block
+ vertical-align bottom
+ width ($height - 16px)
+ height ($height - 16px)
+ margin 8px
+ border-radius 6px
+
+ > .nav
+ display block
+ position absolute
+ top 0
+ left 0
+ padding 0
+ width $height
+ font-size 1.4em
+ line-height $height
+ border-right solid 1px rgba(#000, 0.1)
+
+ > [data-fa]
+ transition all 0.2s ease
+
+ > [data-fa].circle
+ position absolute
+ top 8px
+ left 8px
+ pointer-events none
+ font-size 10px
+ color $theme-color
+
+ > button:last-child
+ display block
+ position absolute
+ top 0
+ right 0
+ padding 0
+ width $height
+ text-align center
+ font-size 1.4em
+ color inherit
+ line-height $height
+ border-left solid 1px rgba(#000, 0.1)
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/ui.nav.vue b/src/server/web/app/mobile/views/components/ui.nav.vue
new file mode 100644
index 0000000000..760a5b5184
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/ui.nav.vue
@@ -0,0 +1,244 @@
+<template>
+<div class="nav">
+ <transition name="back">
+ <div class="backdrop"
+ v-if="isOpen"
+ @click="$parent.isDrawerOpening = false"
+ @touchstart="$parent.isDrawerOpening = false"
+ ></div>
+ </transition>
+ <transition name="nav">
+ <div class="body" v-if="isOpen">
+ <router-link class="me" v-if="os.isSignedIn" :to="`/@${os.i.username}`">
+ <img class="avatar" :src="`${os.i.avatar_url}?thumbnail&size=128`" alt="avatar"/>
+ <p class="name">{{ os.i.name }}</p>
+ </router-link>
+ <div class="links">
+ <ul>
+ <li><router-link to="/">%fa:home%%i18n:mobile.tags.mk-ui-nav.home%%fa:angle-right%</router-link></li>
+ <li><router-link to="/i/notifications">%fa:R bell%%i18n:mobile.tags.mk-ui-nav.notifications%<template v-if="hasUnreadNotifications">%fa:circle%</template>%fa:angle-right%</router-link></li>
+ <li><router-link to="/i/messaging">%fa:R comments%%i18n:mobile.tags.mk-ui-nav.messaging%<template v-if="hasUnreadMessagingMessages">%fa:circle%</template>%fa:angle-right%</router-link></li>
+ <li><router-link to="/othello">%fa:gamepad%ゲーム<template v-if="hasGameInvitations">%fa:circle%</template>%fa:angle-right%</router-link></li>
+ </ul>
+ <ul>
+ <li><a :href="chUrl" target="_blank">%fa:tv%%i18n:mobile.tags.mk-ui-nav.ch%%fa:angle-right%</a></li>
+ <li><router-link to="/i/drive">%fa:cloud%%i18n:mobile.tags.mk-ui-nav.drive%%fa:angle-right%</router-link></li>
+ </ul>
+ <ul>
+ <li><a @click="search">%fa:search%%i18n:mobile.tags.mk-ui-nav.search%%fa:angle-right%</a></li>
+ </ul>
+ <ul>
+ <li><router-link to="/i/settings">%fa:cog%%i18n:mobile.tags.mk-ui-nav.settings%%fa:angle-right%</router-link></li>
+ </ul>
+ </div>
+ <a :href="aboutUrl"><p class="about">%i18n:mobile.tags.mk-ui-nav.about%</p></a>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { docsUrl, chUrl, lang } from '../../../config';
+
+export default Vue.extend({
+ props: ['isOpen'],
+ data() {
+ return {
+ hasUnreadNotifications: false,
+ hasUnreadMessagingMessages: false,
+ hasGameInvitations: false,
+ connection: null,
+ connectionId: null,
+ aboutUrl: `${docsUrl}/${lang}/about`,
+ chUrl
+ };
+ },
+ mounted() {
+ if ((this as any).os.isSignedIn) {
+ this.connection = (this as any).os.stream.getConnection();
+ this.connectionId = (this as any).os.stream.use();
+
+ this.connection.on('read_all_notifications', this.onReadAllNotifications);
+ this.connection.on('unread_notification', this.onUnreadNotification);
+ this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages);
+ this.connection.on('unread_messaging_message', this.onUnreadMessagingMessage);
+ this.connection.on('othello_invited', this.onOthelloInvited);
+ this.connection.on('othello_no_invites', this.onOthelloNoInvites);
+
+ // Fetch count of unread notifications
+ (this as any).api('notifications/get_unread_count').then(res => {
+ if (res.count > 0) {
+ this.hasUnreadNotifications = true;
+ }
+ });
+
+ // Fetch count of unread messaging messages
+ (this as any).api('messaging/unread').then(res => {
+ if (res.count > 0) {
+ this.hasUnreadMessagingMessages = true;
+ }
+ });
+ }
+ },
+ beforeDestroy() {
+ if ((this as any).os.isSignedIn) {
+ this.connection.off('read_all_notifications', this.onReadAllNotifications);
+ this.connection.off('unread_notification', this.onUnreadNotification);
+ this.connection.off('read_all_messaging_messages', this.onReadAllMessagingMessages);
+ this.connection.off('unread_messaging_message', this.onUnreadMessagingMessage);
+ this.connection.off('othello_invited', this.onOthelloInvited);
+ this.connection.off('othello_no_invites', this.onOthelloNoInvites);
+ (this as any).os.stream.dispose(this.connectionId);
+ }
+ },
+ methods: {
+ search() {
+ const query = window.prompt('%i18n:mobile.tags.mk-ui-nav.search%');
+ if (query == null || query == '') return;
+ this.$router.push('/search?q=' + encodeURIComponent(query));
+ },
+ onReadAllNotifications() {
+ this.hasUnreadNotifications = false;
+ },
+ onUnreadNotification() {
+ this.hasUnreadNotifications = true;
+ },
+ onReadAllMessagingMessages() {
+ this.hasUnreadMessagingMessages = false;
+ },
+ onUnreadMessagingMessage() {
+ this.hasUnreadMessagingMessages = true;
+ },
+ onOthelloInvited() {
+ this.hasGameInvitations = true;
+ },
+ onOthelloNoInvites() {
+ this.hasGameInvitations = false;
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+.nav
+ .backdrop
+ position fixed
+ top 0
+ left 0
+ z-index 1025
+ width 100%
+ height 100%
+ background rgba(0, 0, 0, 0.2)
+
+ .body
+ position fixed
+ top 0
+ left 0
+ z-index 1026
+ width 240px
+ height 100%
+ overflow auto
+ -webkit-overflow-scrolling touch
+ color #777
+ background #fff
+
+ .me
+ display block
+ margin 0
+ padding 16px
+
+ .avatar
+ display inline
+ max-width 64px
+ border-radius 32px
+ vertical-align middle
+
+ .name
+ display block
+ margin 0 16px
+ position absolute
+ top 0
+ left 80px
+ padding 0
+ width calc(100% - 112px)
+ color #777
+ line-height 96px
+ overflow hidden
+ text-overflow ellipsis
+ white-space nowrap
+
+ ul
+ display block
+ margin 16px 0
+ padding 0
+ list-style none
+
+ &:first-child
+ margin-top 0
+
+ li
+ display block
+ font-size 1em
+ line-height 1em
+
+ a
+ display block
+ padding 0 20px
+ line-height 3rem
+ line-height calc(1rem + 30px)
+ color #777
+ text-decoration none
+
+ > [data-fa]:first-child
+ margin-right 0.5em
+
+ > [data-fa].circle
+ margin-left 6px
+ font-size 10px
+ color $theme-color
+
+ > [data-fa]:last-child
+ position absolute
+ top 0
+ right 0
+ padding 0 20px
+ font-size 1.2em
+ line-height calc(1rem + 30px)
+ color #ccc
+
+ .about
+ margin 0
+ padding 1em 0
+ text-align center
+ font-size 0.8em
+ opacity 0.5
+
+ a
+ color #777
+
+.nav-enter-active,
+.nav-leave-active {
+ opacity: 1;
+ transform: translateX(0);
+ transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.nav-enter,
+.nav-leave-active {
+ opacity: 0;
+ transform: translateX(-240px);
+}
+
+.back-enter-active,
+.back-leave-active {
+ opacity: 1;
+ transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.back-enter,
+.back-leave-active {
+ opacity: 0;
+}
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/ui.vue b/src/server/web/app/mobile/views/components/ui.vue
new file mode 100644
index 0000000000..325ce9d40e
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/ui.vue
@@ -0,0 +1,75 @@
+<template>
+<div class="mk-ui">
+ <x-header>
+ <template slot="func"><slot name="func"></slot></template>
+ <slot name="header"></slot>
+ </x-header>
+ <x-nav :is-open="isDrawerOpening"/>
+ <div class="content">
+ <slot></slot>
+ </div>
+ <mk-stream-indicator v-if="os.isSignedIn"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import MkNotify from './notify.vue';
+import XHeader from './ui.header.vue';
+import XNav from './ui.nav.vue';
+
+export default Vue.extend({
+ components: {
+ XHeader,
+ XNav
+ },
+ props: ['title'],
+ data() {
+ return {
+ isDrawerOpening: false,
+ connection: null,
+ connectionId: null
+ };
+ },
+ mounted() {
+ if ((this as any).os.isSignedIn) {
+ this.connection = (this as any).os.stream.getConnection();
+ this.connectionId = (this as any).os.stream.use();
+
+ this.connection.on('notification', this.onNotification);
+ }
+ },
+ beforeDestroy() {
+ if ((this as any).os.isSignedIn) {
+ this.connection.off('notification', this.onNotification);
+ (this as any).os.stream.dispose(this.connectionId);
+ }
+ },
+ methods: {
+ onNotification(notification) {
+ // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
+ this.connection.send({
+ type: 'read_notification',
+ id: notification.id
+ });
+
+ (this as any).os.new(MkNotify, {
+ notification
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-ui
+ display flex
+ flex 1
+ flex-direction column
+ padding-top 48px
+
+ > .content
+ display flex
+ flex 1
+ flex-direction column
+</style>
diff --git a/src/server/web/app/mobile/views/components/user-card.vue b/src/server/web/app/mobile/views/components/user-card.vue
new file mode 100644
index 0000000000..5a7309cfd3
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/user-card.vue
@@ -0,0 +1,69 @@
+<template>
+<div class="mk-user-card">
+ <header :style="user.banner_url ? `background-image: url(${user.banner_url}?thumbnail&size=1024)` : ''">
+ <a :href="`/@${acct}`">
+ <img :src="`${user.avatar_url}?thumbnail&size=200`" alt="avatar"/>
+ </a>
+ </header>
+ <a class="name" :href="`/@${acct}`" target="_blank">{{ user.name }}</a>
+ <p class="username">@{{ acct }}</p>
+ <mk-follow-button :user="user"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import getAcct from '../../../../../common/user/get-acct';
+
+export default Vue.extend({
+ props: ['user'],
+ computed: {
+ acct() {
+ return getAcct(this.user);
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-user-card
+ display inline-block
+ width 200px
+ text-align center
+ border-radius 8px
+ background #fff
+
+ > header
+ display block
+ height 80px
+ background-color #ddd
+ background-size cover
+ background-position center
+ border-radius 8px 8px 0 0
+
+ > a
+ > img
+ position absolute
+ top 20px
+ left calc(50% - 40px)
+ width 80px
+ height 80px
+ border solid 2px #fff
+ border-radius 8px
+
+ > .name
+ display block
+ margin 24px 0 0 0
+ font-size 16px
+ color #555
+
+ > .username
+ margin 0
+ font-size 15px
+ color #ccc
+
+ > .mk-follow-button
+ display inline-block
+ margin 8px 0 16px 0
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/user-preview.vue b/src/server/web/app/mobile/views/components/user-preview.vue
new file mode 100644
index 0000000000..be80582cac
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/user-preview.vue
@@ -0,0 +1,110 @@
+<template>
+<div class="mk-user-preview">
+ <router-link class="avatar-anchor" :to="`/@${acct}`">
+ <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ </router-link>
+ <div class="main">
+ <header>
+ <router-link class="name" :to="`/@${acct}`">{{ user.name }}</router-link>
+ <span class="username">@{{ acct }}</span>
+ </header>
+ <div class="body">
+ <div class="description">{{ user.description }}</div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import getAcct from '../../../../../common/user/get-acct';
+
+export default Vue.extend({
+ props: ['user'],
+ computed: {
+ acct() {
+ return getAcct(this.user);
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-user-preview
+ margin 0
+ padding 16px
+ font-size 12px
+
+ @media (min-width 350px)
+ font-size 14px
+
+ @media (min-width 500px)
+ font-size 16px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 10px 0 0
+
+ @media (min-width 500px)
+ margin-right 16px
+
+ > .avatar
+ display block
+ width 48px
+ height 48px
+ margin 0
+ border-radius 6px
+ vertical-align bottom
+
+ @media (min-width 500px)
+ width 58px
+ height 58px
+ border-radius 8px
+
+ > .main
+ float left
+ width calc(100% - 58px)
+
+ @media (min-width 500px)
+ width calc(100% - 74px)
+
+ > header
+ @media (min-width 500px)
+ margin-bottom 2px
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #777
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #ccc
+
+ > .body
+
+ > .description
+ cursor default
+ display block
+ margin 0
+ padding 0
+ overflow-wrap break-word
+ font-size 1.1em
+ color #717171
+
+</style>
diff --git a/src/server/web/app/mobile/views/components/user-timeline.vue b/src/server/web/app/mobile/views/components/user-timeline.vue
new file mode 100644
index 0000000000..39f959187c
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/user-timeline.vue
@@ -0,0 +1,76 @@
+<template>
+<div class="mk-user-timeline">
+ <mk-posts :posts="posts">
+ <div class="init" v-if="fetching">
+ %fa:spinner .pulse%%i18n:common.loading%
+ </div>
+ <div class="empty" v-if="!fetching && posts.length == 0">
+ %fa:R comments%
+ {{ withMedia ? '%i18n:mobile.tags.mk-user-timeline.no-posts-with-media%' : '%i18n:mobile.tags.mk-user-timeline.no-posts%' }}
+ </div>
+ <button v-if="!fetching && existMore" @click="more" :disabled="moreFetching" slot="tail">
+ <span v-if="!moreFetching">%i18n:mobile.tags.mk-user-timeline.load-more%</span>
+ <span v-if="moreFetching">%i18n:common.loading%<mk-ellipsis/></span>
+ </button>
+ </mk-posts>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+const limit = 10;
+
+export default Vue.extend({
+ props: ['user', 'withMedia'],
+ data() {
+ return {
+ fetching: true,
+ posts: [],
+ existMore: false,
+ moreFetching: false
+ };
+ },
+ mounted() {
+ (this as any).api('users/posts', {
+ user_id: this.user.id,
+ with_media: this.withMedia,
+ limit: limit + 1
+ }).then(posts => {
+ if (posts.length == limit + 1) {
+ posts.pop();
+ this.existMore = true;
+ }
+ this.posts = posts;
+ this.fetching = false;
+ this.$emit('loaded');
+ });
+ },
+ methods: {
+ more() {
+ this.moreFetching = true;
+ (this as any).api('users/posts', {
+ user_id: this.user.id,
+ with_media: this.withMedia,
+ limit: limit + 1,
+ until_id: this.posts[this.posts.length - 1].id
+ }).then(posts => {
+ if (posts.length == limit + 1) {
+ posts.pop();
+ this.existMore = true;
+ } else {
+ this.existMore = false;
+ }
+ this.posts = this.posts.concat(posts);
+ this.moreFetching = false;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-user-timeline
+ max-width 600px
+ margin 0 auto
+</style>
diff --git a/src/server/web/app/mobile/views/components/users-list.vue b/src/server/web/app/mobile/views/components/users-list.vue
new file mode 100644
index 0000000000..b11e4549d6
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/users-list.vue
@@ -0,0 +1,133 @@
+<template>
+<div class="mk-users-list">
+ <nav>
+ <span :data-is-active="mode == 'all'" @click="mode = 'all'">%i18n:mobile.tags.mk-users-list.all%<span>{{ count }}</span></span>
+ <span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">%i18n:mobile.tags.mk-users-list.known%<span>{{ youKnowCount }}</span></span>
+ </nav>
+ <div class="users" v-if="!fetching && users.length != 0">
+ <mk-user-preview v-for="u in users" :user="u" :key="u.id"/>
+ </div>
+ <button class="more" v-if="!fetching && next != null" @click="more" :disabled="moreFetching">
+ <span v-if="!moreFetching">%i18n:mobile.tags.mk-users-list.load-more%</span>
+ <span v-if="moreFetching">%i18n:common.loading%<mk-ellipsis/></span>
+ </button>
+ <p class="no" v-if="!fetching && users.length == 0">
+ <slot></slot>
+ </p>
+ <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ props: ['fetch', 'count', 'youKnowCount'],
+ data() {
+ return {
+ limit: 30,
+ mode: 'all',
+ fetching: true,
+ moreFetching: false,
+ users: [],
+ next: null
+ };
+ },
+ watch: {
+ mode() {
+ this._fetch();
+ }
+ },
+ mounted() {
+ this._fetch(() => {
+ this.$emit('loaded');
+ });
+ },
+ methods: {
+ _fetch(cb?) {
+ this.fetching = true;
+ this.fetch(this.mode == 'iknow', this.limit, null, obj => {
+ this.users = obj.users;
+ this.next = obj.next;
+ this.fetching = false;
+ if (cb) cb();
+ });
+ },
+ more() {
+ this.moreFetching = true;
+ this.fetch(this.mode == 'iknow', this.limit, this.next, obj => {
+ this.moreFetching = false;
+ this.users = this.users.concat(obj.users);
+ this.next = obj.next;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+.mk-users-list
+
+ > nav
+ display flex
+ justify-content center
+ margin 0 auto
+ max-width 600px
+ border-bottom solid 1px rgba(0, 0, 0, 0.2)
+
+ > span
+ display block
+ flex 1 1
+ text-align center
+ line-height 52px
+ font-size 14px
+ color #657786
+ border-bottom solid 2px transparent
+
+ &[data-is-active]
+ font-weight bold
+ color $theme-color
+ border-color $theme-color
+
+ > span
+ display inline-block
+ margin-left 4px
+ padding 2px 5px
+ font-size 12px
+ line-height 1
+ color #fff
+ background rgba(0, 0, 0, 0.3)
+ border-radius 20px
+
+ > .users
+ margin 8px auto
+ max-width 500px
+ width calc(100% - 16px)
+ background #fff
+ border-radius 8px
+ box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+
+ @media (min-width 500px)
+ margin 16px auto
+ width calc(100% - 32px)
+
+ > *
+ border-bottom solid 1px rgba(0, 0, 0, 0.05)
+
+ > .no
+ 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/mobile/views/components/widget-container.vue b/src/server/web/app/mobile/views/components/widget-container.vue
new file mode 100644
index 0000000000..7319c90849
--- /dev/null
+++ b/src/server/web/app/mobile/views/components/widget-container.vue
@@ -0,0 +1,68 @@
+<template>
+<div class="mk-widget-container" :class="{ naked, hideHeader: !showHeader }">
+ <header v-if="showHeader">
+ <div class="title"><slot name="header"></slot></div>
+ <slot name="func"></slot>
+ </header>
+ <slot></slot>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ props: {
+ showHeader: {
+ type: Boolean,
+ default: true
+ },
+ naked: {
+ type: Boolean,
+ default: false
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-widget-container
+ background #eee
+ border-radius 8px
+ box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ overflow hidden
+
+ &.hideHeader
+ background #fff
+
+ &.naked
+ background transparent !important
+ box-shadow none !important
+
+ > header
+ > .title
+ margin 0
+ padding 8px 10px
+ font-size 15px
+ font-weight normal
+ color #465258
+ background #fff
+ border-radius 8px 8px 0 0
+
+ > [data-fa]
+ margin-right 6px
+
+ &:empty
+ display none
+
+ > button
+ position absolute
+ z-index 2
+ top 0
+ right 0
+ padding 0
+ width 42px
+ height 100%
+ font-size 15px
+ color #465258
+
+</style>
diff --git a/src/server/web/app/mobile/views/directives/index.ts b/src/server/web/app/mobile/views/directives/index.ts
new file mode 100644
index 0000000000..324e07596d
--- /dev/null
+++ b/src/server/web/app/mobile/views/directives/index.ts
@@ -0,0 +1,6 @@
+import Vue from 'vue';
+
+import userPreview from './user-preview';
+
+Vue.directive('userPreview', userPreview);
+Vue.directive('user-preview', userPreview);
diff --git a/src/server/web/app/mobile/views/directives/user-preview.ts b/src/server/web/app/mobile/views/directives/user-preview.ts
new file mode 100644
index 0000000000..1a54abc20d
--- /dev/null
+++ b/src/server/web/app/mobile/views/directives/user-preview.ts
@@ -0,0 +1,2 @@
+// nope
+export default {};
diff --git a/src/server/web/app/mobile/views/pages/drive.vue b/src/server/web/app/mobile/views/pages/drive.vue
new file mode 100644
index 0000000000..200379f222
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/drive.vue
@@ -0,0 +1,107 @@
+<template>
+<mk-ui>
+ <span slot="header">
+ <template v-if="folder">%fa:R folder-open%{{ folder.name }}</template>
+ <template v-if="file"><mk-file-type-icon data-icon :type="file.type"/>{{ file.name }}</template>
+ <template v-if="!folder && !file">%fa:cloud%%i18n:mobile.tags.mk-drive-page.drive%</template>
+ </span>
+ <template slot="func"><button @click="fn">%fa:ellipsis-h%</button></template>
+ <mk-drive
+ ref="browser"
+ :init-folder="initFolder"
+ :init-file="initFile"
+ :is-naked="true"
+ :top="48"
+ @begin-fetch="Progress.start()"
+ @fetched-mid="Progress.set(0.5)"
+ @fetched="Progress.done()"
+ @move-root="onMoveRoot"
+ @open-folder="onOpenFolder"
+ @open-file="onOpenFile"
+ />
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Progress from '../../../common/scripts/loading';
+
+export default Vue.extend({
+ data() {
+ return {
+ Progress,
+ folder: null,
+ file: null,
+ initFolder: null,
+ initFile: null
+ };
+ },
+ created() {
+ this.initFolder = this.$route.params.folder;
+ this.initFile = this.$route.params.file;
+
+ window.addEventListener('popstate', this.onPopState);
+ },
+ mounted() {
+ document.title = 'Misskey Drive';
+ document.documentElement.style.background = '#fff';
+ },
+ beforeDestroy() {
+ window.removeEventListener('popstate', this.onPopState);
+ },
+ methods: {
+ onPopState() {
+ if (this.$route.params.folder) {
+ (this.$refs as any).browser.cd(this.$route.params.folder, true);
+ } else if (this.$route.params.file) {
+ (this.$refs as any).browser.cf(this.$route.params.file, true);
+ } else {
+ (this.$refs as any).browser.goRoot(true);
+ }
+ },
+ fn() {
+ (this.$refs as any).browser.openContextMenu();
+ },
+ onMoveRoot(silent) {
+ const title = 'Misskey Drive';
+
+ if (!silent) {
+ // Rewrite URL
+ history.pushState(null, title, '/i/drive');
+ }
+
+ document.title = title;
+
+ this.file = null;
+ this.folder = null;
+ },
+ onOpenFolder(folder, silent) {
+ const title = folder.name + ' | Misskey Drive';
+
+ if (!silent) {
+ // Rewrite URL
+ history.pushState(null, title, '/i/drive/folder/' + folder.id);
+ }
+
+ document.title = title;
+
+ this.file = null;
+ this.folder = folder;
+ },
+ onOpenFile(file, silent) {
+ const title = file.name + ' | Misskey Drive';
+
+ if (!silent) {
+ // Rewrite URL
+ history.pushState(null, title, '/i/drive/file/' + file.id);
+ }
+
+ document.title = title;
+
+ this.file = file;
+ this.folder = null;
+ }
+ }
+});
+</script>
+
diff --git a/src/server/web/app/mobile/views/pages/followers.vue b/src/server/web/app/mobile/views/pages/followers.vue
new file mode 100644
index 0000000000..1edf4e38ad
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/followers.vue
@@ -0,0 +1,65 @@
+<template>
+<mk-ui>
+ <template slot="header" v-if="!fetching">
+ <img :src="`${user.avatar_url}?thumbnail&size=64`" alt="">
+ {{ '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name) }}
+ </template>
+ <mk-users-list
+ v-if="!fetching"
+ :fetch="fetchUsers"
+ :count="user.followers_count"
+ :you-know-count="user.followers_you_know_count"
+ @loaded="onLoaded"
+ >
+ %i18n:mobile.tags.mk-user-followers.no-users%
+ </mk-users-list>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Progress from '../../../common/scripts/loading';
+import parseAcct from '../../../../../common/user/parse-acct';
+
+export default Vue.extend({
+ data() {
+ return {
+ fetching: true,
+ user: null
+ };
+ },
+ watch: {
+ $route: 'fetch'
+ },
+ created() {
+ this.fetch();
+ },
+ mounted() {
+ document.documentElement.style.background = '#313a42';
+ },
+ methods: {
+ fetch() {
+ Progress.start();
+ this.fetching = true;
+
+ (this as any).api('users/show', parseAcct(this.$route.params.user)).then(user => {
+ this.user = user;
+ this.fetching = false;
+
+ document.title = '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name) + ' | Misskey';
+ });
+ },
+ onLoaded() {
+ Progress.done();
+ },
+ fetchUsers(iknow, limit, cursor, cb) {
+ (this as any).api('users/followers', {
+ user_id: this.user.id,
+ iknow: iknow,
+ limit: limit,
+ cursor: cursor ? cursor : undefined
+ }).then(cb);
+ }
+ }
+});
+</script>
diff --git a/src/server/web/app/mobile/views/pages/following.vue b/src/server/web/app/mobile/views/pages/following.vue
new file mode 100644
index 0000000000..0dd171cce1
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/following.vue
@@ -0,0 +1,65 @@
+<template>
+<mk-ui>
+ <template slot="header" v-if="!fetching">
+ <img :src="`${user.avatar_url}?thumbnail&size=64`" alt="">
+ {{ '%i18n:mobile.tags.mk-user-following-page.following-of%'.replace('{}', user.name) }}
+ </template>
+ <mk-users-list
+ v-if="!fetching"
+ :fetch="fetchUsers"
+ :count="user.following_count"
+ :you-know-count="user.following_you_know_count"
+ @loaded="onLoaded"
+ >
+ %i18n:mobile.tags.mk-user-following.no-users%
+ </mk-users-list>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Progress from '../../../common/scripts/loading';
+import parseAcct from '../../../../../common/user/parse-acct';
+
+export default Vue.extend({
+ data() {
+ return {
+ fetching: true,
+ user: null
+ };
+ },
+ watch: {
+ $route: 'fetch'
+ },
+ created() {
+ this.fetch();
+ },
+ mounted() {
+ document.documentElement.style.background = '#313a42';
+ },
+ methods: {
+ fetch() {
+ Progress.start();
+ this.fetching = true;
+
+ (this as any).api('users/show', parseAcct(this.$route.params.user)).then(user => {
+ this.user = user;
+ this.fetching = false;
+
+ document.title = '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name) + ' | Misskey';
+ });
+ },
+ onLoaded() {
+ Progress.done();
+ },
+ fetchUsers(iknow, limit, cursor, cb) {
+ (this as any).api('users/following', {
+ user_id: this.user.id,
+ iknow: iknow,
+ limit: limit,
+ cursor: cursor ? cursor : undefined
+ }).then(cb);
+ }
+ }
+});
+</script>
diff --git a/src/server/web/app/mobile/views/pages/home.vue b/src/server/web/app/mobile/views/pages/home.vue
new file mode 100644
index 0000000000..b110fc4091
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/home.vue
@@ -0,0 +1,259 @@
+<template>
+<mk-ui>
+ <span slot="header" @click="showTl = !showTl">
+ <template v-if="showTl">%fa:home%タイムライン</template>
+ <template v-else>%fa:home%ウィジェット</template>
+ <span style="margin-left:8px">
+ <template v-if="showTl">%fa:angle-down%</template>
+ <template v-else>%fa:angle-up%</template>
+ </span>
+ </span>
+ <template slot="func">
+ <button @click="fn" v-if="showTl">%fa:pencil-alt%</button>
+ <button @click="customizing = !customizing" v-else>%fa:cog%</button>
+ </template>
+ <main>
+ <div class="tl">
+ <mk-timeline @loaded="onLoaded" v-show="showTl"/>
+ </div>
+ <div class="widgets" v-show="!showTl">
+ <template v-if="customizing">
+ <header>
+ <select v-model="widgetAdderSelected">
+ <option value="profile">プロフィール</option>
+ <option value="calendar">カレンダー</option>
+ <option value="activity">アクティビティ</option>
+ <option value="rss">RSSリーダー</option>
+ <option value="photo-stream">フォトストリーム</option>
+ <option value="slideshow">スライドショー</option>
+ <option value="version">バージョン</option>
+ <option value="access-log">アクセスログ</option>
+ <option value="server">サーバー情報</option>
+ <option value="donation">寄付のお願い</option>
+ <option value="nav">ナビゲーション</option>
+ <option value="tips">ヒント</option>
+ </select>
+ <button @click="addWidget">追加</button>
+ <p><a @click="hint">カスタマイズのヒント</a></p>
+ </header>
+ <x-draggable
+ :list="widgets"
+ :options="{ handle: '.handle', animation: 150 }"
+ @sort="onWidgetSort"
+ >
+ <div v-for="widget in widgets" class="customize-container" :key="widget.id">
+ <header>
+ <span class="handle">%fa:bars%</span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)">%fa:times%</button>
+ </header>
+ <div @click="widgetFunc(widget.id)">
+ <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :is-mobile="true"/>
+ </div>
+ </div>
+ </x-draggable>
+ </template>
+ <template v-else>
+ <component class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :is-mobile="true" @chosen="warp"/>
+ </template>
+ </div>
+ </main>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as XDraggable from 'vuedraggable';
+import * as uuid from 'uuid';
+import Progress from '../../../common/scripts/loading';
+import getPostSummary from '../../../../../common/get-post-summary';
+
+export default Vue.extend({
+ components: {
+ XDraggable
+ },
+ data() {
+ return {
+ connection: null,
+ connectionId: null,
+ unreadCount: 0,
+ showTl: true,
+ widgets: [],
+ customizing: false,
+ widgetAdderSelected: null
+ };
+ },
+ created() {
+ if ((this as any).os.i.account.client_settings.mobile_home == null) {
+ Vue.set((this as any).os.i.account.client_settings, 'mobile_home', [{
+ name: 'calendar',
+ id: 'a', data: {}
+ }, {
+ name: 'activity',
+ id: 'b', data: {}
+ }, {
+ name: 'rss',
+ id: 'c', data: {}
+ }, {
+ name: 'photo-stream',
+ id: 'd', data: {}
+ }, {
+ name: 'donation',
+ id: 'e', data: {}
+ }, {
+ name: 'nav',
+ id: 'f', data: {}
+ }, {
+ name: 'version',
+ id: 'g', data: {}
+ }]);
+ this.widgets = (this as any).os.i.account.client_settings.mobile_home;
+ this.saveHome();
+ } else {
+ this.widgets = (this as any).os.i.account.client_settings.mobile_home;
+ }
+
+ this.$watch('os.i.account.client_settings', i => {
+ this.widgets = (this as any).os.i.account.client_settings.mobile_home;
+ }, {
+ deep: true
+ });
+ },
+ mounted() {
+ document.title = 'Misskey';
+ document.documentElement.style.background = '#313a42';
+
+ this.connection = (this as any).os.stream.getConnection();
+ this.connectionId = (this as any).os.stream.use();
+
+ this.connection.on('post', this.onStreamPost);
+ this.connection.on('mobile_home_updated', this.onHomeUpdated);
+ document.addEventListener('visibilitychange', this.onVisibilitychange, false);
+
+ Progress.start();
+ },
+ beforeDestroy() {
+ this.connection.off('post', this.onStreamPost);
+ this.connection.off('mobile_home_updated', this.onHomeUpdated);
+ (this as any).os.stream.dispose(this.connectionId);
+ document.removeEventListener('visibilitychange', this.onVisibilitychange);
+ },
+ methods: {
+ fn() {
+ (this as any).apis.post();
+ },
+ onLoaded() {
+ Progress.done();
+ },
+ onStreamPost(post) {
+ if (document.hidden && post.user_id !== (this as any).os.i.id) {
+ this.unreadCount++;
+ document.title = `(${this.unreadCount}) ${getPostSummary(post)}`;
+ }
+ },
+ onVisibilitychange() {
+ if (!document.hidden) {
+ this.unreadCount = 0;
+ document.title = 'Misskey';
+ }
+ },
+ onHomeUpdated(data) {
+ if (data.home) {
+ (this as any).os.i.account.client_settings.mobile_home = data.home;
+ this.widgets = data.home;
+ } else {
+ const w = (this as any).os.i.account.client_settings.mobile_home.find(w => w.id == data.id);
+ if (w != null) {
+ w.data = data.data;
+ this.$refs[w.id][0].preventSave = true;
+ this.$refs[w.id][0].props = w.data;
+ this.widgets = (this as any).os.i.account.client_settings.mobile_home;
+ }
+ }
+ },
+ hint() {
+ alert('ウィジェットを追加/削除したり並べ替えたりできます。ウィジェットを移動するには「三」をドラッグします。ウィジェットを削除するには「x」をタップします。いくつかのウィジェットはタップすることで表示を変更できます。');
+ },
+ widgetFunc(id) {
+ const w = this.$refs[id][0];
+ if (w.func) w.func();
+ },
+ onWidgetSort() {
+ this.saveHome();
+ },
+ addWidget() {
+ const widget = {
+ name: this.widgetAdderSelected,
+ id: uuid(),
+ data: {}
+ };
+
+ this.widgets.unshift(widget);
+ this.saveHome();
+ },
+ removeWidget(widget) {
+ this.widgets = this.widgets.filter(w => w.id != widget.id);
+ this.saveHome();
+ },
+ saveHome() {
+ (this as any).os.i.account.client_settings.mobile_home = this.widgets;
+ (this as any).api('i/update_mobile_home', {
+ home: this.widgets
+ });
+ },
+ warp() {
+
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+main
+
+ > .tl
+ > .mk-timeline
+ max-width 600px
+ margin 0 auto
+ padding 8px
+
+ @media (min-width 500px)
+ padding 16px
+
+ > .widgets
+ margin 0 auto
+ max-width 500px
+
+ @media (min-width 500px)
+ padding 8px
+
+ > header
+ padding 8px
+ background #fff
+
+ .widget
+ margin 8px
+
+ .customize-container
+ margin 8px
+ background #fff
+
+ > header
+ line-height 32px
+ background #eee
+
+ > .handle
+ padding 0 8px
+
+ > .remove
+ position absolute
+ top 0
+ right 0
+ padding 0 8px
+ line-height 32px
+
+ > div
+ padding 8px
+
+ > *
+ pointer-events none
+
+</style>
diff --git a/src/server/web/app/mobile/views/pages/index.vue b/src/server/web/app/mobile/views/pages/index.vue
new file mode 100644
index 0000000000..0ea47d913b
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/index.vue
@@ -0,0 +1,16 @@
+<template>
+<component :is="os.isSignedIn ? 'home' : 'welcome'"></component>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Home from './home.vue';
+import Welcome from './welcome.vue';
+
+export default Vue.extend({
+ components: {
+ Home,
+ Welcome
+ }
+});
+</script>
diff --git a/src/server/web/app/mobile/views/pages/messaging-room.vue b/src/server/web/app/mobile/views/pages/messaging-room.vue
new file mode 100644
index 0000000000..193c41179c
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/messaging-room.vue
@@ -0,0 +1,42 @@
+<template>
+<mk-ui>
+ <span slot="header">
+ <template v-if="user">%fa:R comments%{{ user.name }}</template>
+ <template v-else><mk-ellipsis/></template>
+ </span>
+ <mk-messaging-room v-if="!fetching" :user="user" :is-naked="true"/>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import parseAcct from '../../../../../common/user/parse-acct';
+
+export default Vue.extend({
+ data() {
+ return {
+ fetching: true,
+ user: null
+ };
+ },
+ watch: {
+ $route: 'fetch'
+ },
+ created() {
+ document.documentElement.style.background = '#fff';
+ this.fetch();
+ },
+ methods: {
+ fetch() {
+ this.fetching = true;
+ (this as any).api('users/show', parseAcct(this.$route.params.user)).then(user => {
+ this.user = user;
+ this.fetching = false;
+
+ document.title = `%i18n:mobile.tags.mk-messaging-room-page.message%: ${user.name} | Misskey`;
+ });
+ }
+ }
+});
+</script>
+
diff --git a/src/server/web/app/mobile/views/pages/messaging.vue b/src/server/web/app/mobile/views/pages/messaging.vue
new file mode 100644
index 0000000000..e92068eda5
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/messaging.vue
@@ -0,0 +1,23 @@
+<template>
+<mk-ui>
+ <span slot="header">%fa:R comments%%i18n:mobile.tags.mk-messaging-page.message%</span>
+ <mk-messaging @navigate="navigate" :header-top="48"/>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import getAcct from '../../../../../common/user/get-acct';
+
+export default Vue.extend({
+ mounted() {
+ document.title = 'Misskey %i18n:mobile.tags.mk-messaging-page.message%';
+ document.documentElement.style.background = '#fff';
+ },
+ methods: {
+ navigate(user) {
+ (this as any).$router.push(`/i/messaging/${getAcct(user)}`);
+ }
+ }
+});
+</script>
diff --git a/src/server/web/app/mobile/views/pages/notifications.vue b/src/server/web/app/mobile/views/pages/notifications.vue
new file mode 100644
index 0000000000..3dcfb2f38c
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/notifications.vue
@@ -0,0 +1,32 @@
+<template>
+<mk-ui>
+ <span slot="header">%fa:R bell%%i18n:mobile.tags.mk-notifications-page.notifications%</span>
+ <template slot="func"><button @click="fn">%fa:check%</button></template>
+ <mk-notifications @fetched="onFetched"/>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Progress from '../../../common/scripts/loading';
+
+export default Vue.extend({
+ mounted() {
+ document.title = 'Misskey | %i18n:mobile.tags.mk-notifications-page.notifications%';
+ document.documentElement.style.background = '#313a42';
+
+ Progress.start();
+ },
+ methods: {
+ fn() {
+ const ok = window.confirm('%i18n:mobile.tags.mk-notifications-page.read-all%');
+ if (!ok) return;
+
+ (this as any).api('notifications/mark_as_read_all');
+ },
+ onFetched() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/src/server/web/app/mobile/views/pages/othello.vue b/src/server/web/app/mobile/views/pages/othello.vue
new file mode 100644
index 0000000000..b110bf309e
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/othello.vue
@@ -0,0 +1,50 @@
+<template>
+<mk-ui>
+ <span slot="header">%fa:gamepad%オセロ</span>
+ <mk-othello v-if="!fetching" :init-game="game" @gamed="onGamed"/>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Progress from '../../../common/scripts/loading';
+
+export default Vue.extend({
+ data() {
+ return {
+ fetching: false,
+ game: null
+ };
+ },
+ watch: {
+ $route: 'fetch'
+ },
+ created() {
+ this.fetch();
+ },
+ mounted() {
+ document.title = 'Misskey オセロ';
+ document.documentElement.style.background = '#fff';
+ },
+ methods: {
+ fetch() {
+ if (this.$route.params.game == null) return;
+
+ Progress.start();
+ this.fetching = true;
+
+ (this as any).api('othello/games/show', {
+ game_id: this.$route.params.game
+ }).then(game => {
+ this.game = game;
+ this.fetching = false;
+
+ Progress.done();
+ });
+ },
+ onGamed(game) {
+ history.pushState(null, null, '/othello/' + game.id);
+ }
+ }
+});
+</script>
diff --git a/src/server/web/app/mobile/views/pages/post.vue b/src/server/web/app/mobile/views/pages/post.vue
new file mode 100644
index 0000000000..2ed2ebfcfd
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/post.vue
@@ -0,0 +1,85 @@
+<template>
+<mk-ui>
+ <span slot="header">%fa:R sticky-note%%i18n:mobile.tags.mk-post-page.title%</span>
+ <main v-if="!fetching">
+ <a v-if="post.next" :href="post.next">%fa:angle-up%%i18n:mobile.tags.mk-post-page.next%</a>
+ <div>
+ <mk-post-detail :post="post"/>
+ </div>
+ <a v-if="post.prev" :href="post.prev">%fa:angle-down%%i18n:mobile.tags.mk-post-page.prev%</a>
+ </main>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Progress from '../../../common/scripts/loading';
+
+export default Vue.extend({
+ data() {
+ return {
+ fetching: true,
+ post: null
+ };
+ },
+ watch: {
+ $route: 'fetch'
+ },
+ created() {
+ this.fetch();
+ },
+ mounted() {
+ document.title = 'Misskey';
+ document.documentElement.style.background = '#313a42';
+ },
+ methods: {
+ fetch() {
+ Progress.start();
+ this.fetching = true;
+
+ (this as any).api('posts/show', {
+ post_id: this.$route.params.post
+ }).then(post => {
+ this.post = post;
+ this.fetching = false;
+
+ Progress.done();
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+main
+ text-align center
+
+ > div
+ margin 8px auto
+ padding 0
+ max-width 500px
+ width calc(100% - 16px)
+
+ @media (min-width 500px)
+ margin 16px auto
+ width calc(100% - 32px)
+
+ > a
+ display inline-block
+
+ &:first-child
+ margin-top 8px
+
+ @media (min-width 500px)
+ margin-top 16px
+
+ &:last-child
+ margin-bottom 8px
+
+ @media (min-width 500px)
+ margin-bottom 16px
+
+ > [data-fa]
+ margin-right 4px
+
+</style>
diff --git a/src/server/web/app/mobile/views/pages/profile-setting.vue b/src/server/web/app/mobile/views/pages/profile-setting.vue
new file mode 100644
index 0000000000..941165c99e
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/profile-setting.vue
@@ -0,0 +1,226 @@
+<template>
+<mk-ui>
+ <span slot="header">%fa:user%%i18n:mobile.tags.mk-profile-setting-page.title%</span>
+ <div :class="$style.content">
+ <p>%fa:info-circle%%i18n:mobile.tags.mk-profile-setting.will-be-published%</p>
+ <div :class="$style.form">
+ <div :style="os.i.banner_url ? `background-image: url(${os.i.banner_url}?thumbnail&size=1024)` : ''" @click="setBanner">
+ <img :src="`${os.i.avatar_url}?thumbnail&size=200`" alt="avatar" @click="setAvatar"/>
+ </div>
+ <label>
+ <p>%i18n:mobile.tags.mk-profile-setting.name%</p>
+ <input v-model="name" type="text"/>
+ </label>
+ <label>
+ <p>%i18n:mobile.tags.mk-profile-setting.location%</p>
+ <input v-model="location" type="text"/>
+ </label>
+ <label>
+ <p>%i18n:mobile.tags.mk-profile-setting.description%</p>
+ <textarea v-model="description"></textarea>
+ </label>
+ <label>
+ <p>%i18n:mobile.tags.mk-profile-setting.birthday%</p>
+ <input v-model="birthday" type="date"/>
+ </label>
+ <label>
+ <p>%i18n:mobile.tags.mk-profile-setting.avatar%</p>
+ <button @click="setAvatar" :disabled="avatarSaving">%i18n:mobile.tags.mk-profile-setting.set-avatar%</button>
+ </label>
+ <label>
+ <p>%i18n:mobile.tags.mk-profile-setting.banner%</p>
+ <button @click="setBanner" :disabled="bannerSaving">%i18n:mobile.tags.mk-profile-setting.set-banner%</button>
+ </label>
+ </div>
+ <button :class="$style.save" @click="save" :disabled="saving">%fa:check%%i18n:mobile.tags.mk-profile-setting.save%</button>
+ </div>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ data() {
+ return {
+ name: null,
+ location: null,
+ description: null,
+ birthday: null,
+ avatarSaving: false,
+ bannerSaving: false,
+ saving: false
+ };
+ },
+ created() {
+ this.name = (this as any).os.i.name;
+ this.location = (this as any).os.i.account.profile.location;
+ this.description = (this as any).os.i.description;
+ this.birthday = (this as any).os.i.account.profile.birthday;
+ },
+ mounted() {
+ document.title = 'Misskey | %i18n:mobile.tags.mk-profile-setting-page.title%';
+ document.documentElement.style.background = '#313a42';
+ },
+ methods: {
+ setAvatar() {
+ (this as any).apis.chooseDriveFile({
+ multiple: false
+ }).then(file => {
+ this.avatarSaving = true;
+
+ (this as any).api('i/update', {
+ avatar_id: file.id
+ }).then(() => {
+ this.avatarSaving = false;
+ alert('%i18n:mobile.tags.mk-profile-setting.avatar-saved%');
+ });
+ });
+ },
+ setBanner() {
+ (this as any).apis.chooseDriveFile({
+ multiple: false
+ }).then(file => {
+ this.bannerSaving = true;
+
+ (this as any).api('i/update', {
+ banner_id: file.id
+ }).then(() => {
+ this.bannerSaving = false;
+ alert('%i18n:mobile.tags.mk-profile-setting.banner-saved%');
+ });
+ });
+ },
+ save() {
+ this.saving = true;
+
+ (this as any).api('i/update', {
+ name: this.name,
+ location: this.location || null,
+ description: this.description || null,
+ birthday: this.birthday || null
+ }).then(() => {
+ this.saving = false;
+ alert('%i18n:mobile.tags.mk-profile-setting.saved%');
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" module>
+@import '~const.styl'
+
+.content
+ margin 8px auto
+ max-width 500px
+ width calc(100% - 16px)
+
+ @media (min-width 500px)
+ margin 16px auto
+ width calc(100% - 32px)
+
+ > p
+ display block
+ margin 0 0 8px 0
+ padding 12px 16px
+ font-size 14px
+ color #79d4e6
+ border solid 1px #71afbb
+ //color #276f86
+ //background #f8ffff
+ //border solid 1px #a9d5de
+ border-radius 8px
+
+ > [data-fa]
+ margin-right 6px
+
+.form
+ position relative
+ background #fff
+ box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ border-radius 8px
+
+ &:before
+ content ""
+ display block
+ position absolute
+ bottom -20px
+ left calc(50% - 10px)
+ border-top solid 10px rgba(0, 0, 0, 0.2)
+ border-right solid 10px transparent
+ border-bottom solid 10px transparent
+ border-left solid 10px transparent
+
+ &:after
+ content ""
+ display block
+ position absolute
+ bottom -16px
+ left calc(50% - 8px)
+ border-top solid 8px #fff
+ border-right solid 8px transparent
+ border-bottom solid 8px transparent
+ border-left solid 8px transparent
+
+ > div
+ height 128px
+ background-color #e4e4e4
+ background-size cover
+ background-position center
+ border-radius 8px 8px 0 0
+
+ > img
+ position absolute
+ top 25px
+ left calc(50% - 40px)
+ width 80px
+ height 80px
+ border solid 2px #fff
+ border-radius 8px
+
+ > label
+ display block
+ margin 0
+ padding 16px
+ border-bottom solid 1px #eee
+
+ &:last-of-type
+ border none
+
+ > p:first-child
+ display block
+ margin 0
+ padding 0 0 4px 0
+ font-weight bold
+ color #2f3c42
+
+ > input[type="text"]
+ > textarea
+ display block
+ width 100%
+ padding 12px
+ font-size 16px
+ color #192427
+ border solid 2px #ddd
+ border-radius 4px
+
+ > textarea
+ min-height 80px
+
+.save
+ display block
+ margin 8px 0 0 0
+ padding 16px
+ width 100%
+ font-size 16px
+ color $theme-color-foreground
+ background $theme-color
+ border-radius 8px
+
+ &:disabled
+ opacity 0.7
+
+ > [data-fa]
+ margin-right 4px
+
+</style>
diff --git a/src/server/web/app/mobile/views/pages/search.vue b/src/server/web/app/mobile/views/pages/search.vue
new file mode 100644
index 0000000000..cbab504e3c
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/search.vue
@@ -0,0 +1,93 @@
+<template>
+<mk-ui>
+ <span slot="header">%fa:search% {{ q }}</span>
+ <main v-if="!fetching">
+ <mk-posts :class="$style.posts" :posts="posts">
+ <span v-if="posts.length == 0">{{ '%i18n:mobile.tags.mk-search-posts.empty%'.replace('{}', q) }}</span>
+ <button v-if="existMore" @click="more" :disabled="fetching" slot="tail">
+ <span v-if="!fetching">%i18n:mobile.tags.mk-timeline.load-more%</span>
+ <span v-if="fetching">%i18n:common.loading%<mk-ellipsis/></span>
+ </button>
+ </mk-posts>
+ </main>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Progress from '../../../common/scripts/loading';
+import parse from '../../../common/scripts/parse-search-query';
+
+const limit = 20;
+
+export default Vue.extend({
+ data() {
+ return {
+ fetching: true,
+ existMore: false,
+ posts: [],
+ offset: 0
+ };
+ },
+ watch: {
+ $route: 'fetch'
+ },
+ computed: {
+ q(): string {
+ return this.$route.query.q;
+ }
+ },
+ mounted() {
+ document.title = `%i18n:mobile.tags.mk-search-page.search%: ${this.q} | Misskey`;
+ document.documentElement.style.background = '#313a42';
+
+ this.fetch();
+ },
+ methods: {
+ fetch() {
+ this.fetching = true;
+ Progress.start();
+
+ (this as any).api('posts/search', Object.assign({
+ limit: limit + 1
+ }, parse(this.q))).then(posts => {
+ if (posts.length == limit + 1) {
+ posts.pop();
+ this.existMore = true;
+ }
+ this.posts = posts;
+ this.fetching = false;
+ Progress.done();
+ });
+ },
+ more() {
+ this.offset += limit;
+ (this as any).api('posts/search', Object.assign({
+ limit: limit + 1,
+ offset: this.offset
+ }, parse(this.q))).then(posts => {
+ if (posts.length == limit + 1) {
+ posts.pop();
+ } else {
+ this.existMore = false;
+ }
+ this.posts = this.posts.concat(posts);
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" module>
+.posts
+ margin 8px auto
+ max-width 500px
+ width calc(100% - 16px)
+ background #fff
+ border-radius 8px
+ box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+
+ @media (min-width 500px)
+ margin 16px auto
+ width calc(100% - 32px)
+</style>
diff --git a/src/server/web/app/mobile/views/pages/selectdrive.vue b/src/server/web/app/mobile/views/pages/selectdrive.vue
new file mode 100644
index 0000000000..3480a0d103
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/selectdrive.vue
@@ -0,0 +1,96 @@
+<template>
+<div class="mk-selectdrive">
+ <header>
+ <h1>%i18n:mobile.tags.mk-selectdrive-page.select-file%<span class="count" v-if="files.length > 0">({{ files.length }})</span></h1>
+ <button class="upload" @click="upload">%fa:upload%</button>
+ <button v-if="multiple" class="ok" @click="ok">%fa:check%</button>
+ </header>
+ <mk-drive ref="browser" select-file :multiple="multiple" is-naked :top="42"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+ data() {
+ return {
+ files: []
+ };
+ },
+ computed: {
+ multiple(): boolean {
+ const q = (new URL(location.toString())).searchParams;
+ return q.get('multiple') == 'true';
+ }
+ },
+ mounted() {
+ document.title = '%i18n:desktop.tags.mk-selectdrive-page.title%';
+ },
+ methods: {
+ onSelected(file) {
+ this.files = [file];
+ this.ok();
+ },
+ onChangeSelection(files) {
+ this.files = files;
+ },
+ upload() {
+ (this.$refs.browser as any).selectLocalFile();
+ },
+ close() {
+ window.close();
+ },
+ ok() {
+ window.opener.cb(this.multiple ? this.files : this.files[0]);
+ this.close();
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-selectdrive
+ width 100%
+ height 100%
+ background #fff
+
+ > header
+ position fixed
+ top 0
+ left 0
+ width 100%
+ z-index 1000
+ background #fff
+ box-shadow 0 1px rgba(0, 0, 0, 0.1)
+
+ > h1
+ margin 0
+ padding 0
+ text-align center
+ line-height 42px
+ font-size 1em
+ font-weight normal
+
+ > .count
+ margin-left 4px
+ opacity 0.5
+
+ > .upload
+ position absolute
+ top 0
+ left 0
+ line-height 42px
+ width 42px
+
+ > .ok
+ position absolute
+ top 0
+ right 0
+ line-height 42px
+ width 42px
+
+ > .mk-drive
+ top 42px
+
+</style>
diff --git a/src/server/web/app/mobile/views/pages/settings.vue b/src/server/web/app/mobile/views/pages/settings.vue
new file mode 100644
index 0000000000..3250999e12
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/settings.vue
@@ -0,0 +1,102 @@
+<template>
+<mk-ui>
+ <span slot="header">%fa:cog%%i18n:mobile.tags.mk-settings-page.settings%</span>
+ <div :class="$style.content">
+ <p v-html="'%i18n:mobile.tags.mk-settings.signed-in-as%'.replace('{}', '<b>' + os.i.name + '</b>')"></p>
+ <ul>
+ <li><router-link to="./settings/profile">%fa:user%%i18n:mobile.tags.mk-settings-page.profile%%fa:angle-right%</router-link></li>
+ <li><router-link to="./settings/authorized-apps">%fa:puzzle-piece%%i18n:mobile.tags.mk-settings-page.applications%%fa:angle-right%</router-link></li>
+ <li><router-link to="./settings/twitter">%fa:B twitter%%i18n:mobile.tags.mk-settings-page.twitter-integration%%fa:angle-right%</router-link></li>
+ <li><router-link to="./settings/signin-history">%fa:sign-in-alt%%i18n:mobile.tags.mk-settings-page.signin-history%%fa:angle-right%</router-link></li>
+ </ul>
+ <ul>
+ <li><a @click="signout">%fa:power-off%%i18n:mobile.tags.mk-settings-page.signout%</a></li>
+ </ul>
+ <p><small>ver {{ v }} (葵 aoi)</small></p>
+ </div>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { version } from '../../../config';
+
+export default Vue.extend({
+ data() {
+ return {
+ v: version
+ };
+ },
+ mounted() {
+ document.title = 'Misskey | %i18n:mobile.tags.mk-settings-page.settings%';
+ document.documentElement.style.background = '#313a42';
+ },
+ methods: {
+ signout() {
+ (this as any).os.signout();
+ }
+ }
+});
+</script>
+
+<style lang="stylus" module>
+.content
+
+ > p
+ display block
+ margin 24px
+ text-align center
+ color #cad2da
+
+ > ul
+ $radius = 8px
+
+ display block
+ margin 16px auto
+ padding 0
+ max-width 500px
+ width calc(100% - 32px)
+ list-style none
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.2)
+ border-radius $radius
+
+ > li
+ display block
+ border-bottom solid 1px #ddd
+
+ &:hover
+ background rgba(0, 0, 0, 0.1)
+
+ &:first-child
+ border-top-left-radius $radius
+ border-top-right-radius $radius
+
+ &:last-child
+ border-bottom-left-radius $radius
+ border-bottom-right-radius $radius
+ border-bottom none
+
+ > a
+ $height = 48px
+
+ display block
+ position relative
+ padding 0 16px
+ line-height $height
+ color #4d635e
+
+ > [data-fa]:nth-of-type(1)
+ margin-right 4px
+
+ > [data-fa]:nth-of-type(2)
+ display block
+ position absolute
+ top 0
+ right 8px
+ z-index 1
+ padding 0 20px
+ font-size 1.2em
+ line-height $height
+
+</style>
diff --git a/src/server/web/app/mobile/views/pages/signup.vue b/src/server/web/app/mobile/views/pages/signup.vue
new file mode 100644
index 0000000000..9dc07a4b86
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/signup.vue
@@ -0,0 +1,57 @@
+<template>
+<div class="signup">
+ <h1>Misskeyをはじめる</h1>
+ <p>いつでも、どこからでもMisskeyを利用できます。もちろん、無料です。</p>
+ <div class="form">
+ <p>新規登録</p>
+ <div>
+ <mk-signup/>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ mounted() {
+ document.documentElement.style.background = '#293946';
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.signup
+ padding 16px
+ margin 0 auto
+ max-width 500px
+
+ h1
+ margin 0
+ padding 8px
+ font-size 1.5em
+ font-weight normal
+ color #c3c6ca
+
+ & + p
+ margin 0 0 16px 0
+ padding 0 8px 0 8px
+ color #949fa9
+
+ .form
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.2)
+ border-radius 8px
+ overflow hidden
+
+ > p
+ margin 0
+ padding 12px 20px
+ color #555
+ background #f5f5f5
+ border-bottom solid 1px #ddd
+
+ > div
+ padding 16px
+
+</style>
diff --git a/src/server/web/app/mobile/views/pages/user.vue b/src/server/web/app/mobile/views/pages/user.vue
new file mode 100644
index 0000000000..7ff897e42d
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/user.vue
@@ -0,0 +1,247 @@
+<template>
+<mk-ui>
+ <span slot="header" v-if="!fetching">%fa:user% {{ user.name }}</span>
+ <main v-if="!fetching">
+ <header>
+ <div class="banner" :style="user.banner_url ? `background-image: url(${user.banner_url}?thumbnail&size=1024)` : ''"></div>
+ <div class="body">
+ <div class="top">
+ <a class="avatar">
+ <img :src="`${user.avatar_url}?thumbnail&size=200`" alt="avatar"/>
+ </a>
+ <mk-follow-button v-if="os.isSignedIn && os.i.id != user.id" :user="user"/>
+ </div>
+ <div class="title">
+ <h1>{{ user.name }}</h1>
+ <span class="username">@{{ acct }}</span>
+ <span class="followed" v-if="user.is_followed">%i18n:mobile.tags.mk-user.follows-you%</span>
+ </div>
+ <div class="description">{{ user.description }}</div>
+ <div class="info">
+ <p class="location" v-if="user.host === null && user.account.profile.location">
+ %fa:map-marker%{{ user.account.profile.location }}
+ </p>
+ <p class="birthday" v-if="user.host === null && user.account.profile.birthday">
+ %fa:birthday-cake%{{ user.account.profile.birthday.replace('-', '年').replace('-', '月') + '日' }} ({{ age }}歳)
+ </p>
+ </div>
+ <div class="status">
+ <a>
+ <b>{{ user.posts_count | number }}</b>
+ <i>%i18n:mobile.tags.mk-user.posts%</i>
+ </a>
+ <a :href="`@${acct}/following`">
+ <b>{{ user.following_count | number }}</b>
+ <i>%i18n:mobile.tags.mk-user.following%</i>
+ </a>
+ <a :href="`@${acct}/followers`">
+ <b>{{ user.followers_count | number }}</b>
+ <i>%i18n:mobile.tags.mk-user.followers%</i>
+ </a>
+ </div>
+ </div>
+ </header>
+ <nav>
+ <div class="nav-container">
+ <a :data-is-active=" page == 'home' " @click="page = 'home'">%i18n:mobile.tags.mk-user.overview%</a>
+ <a :data-is-active=" page == 'posts' " @click="page = 'posts'">%i18n:mobile.tags.mk-user.timeline%</a>
+ <a :data-is-active=" page == 'media' " @click="page = 'media'">%i18n:mobile.tags.mk-user.media%</a>
+ </div>
+ </nav>
+ <div class="body">
+ <x-home v-if="page == 'home'" :user="user"/>
+ <mk-user-timeline v-if="page == 'posts'" :user="user"/>
+ <mk-user-timeline v-if="page == 'media'" :user="user" with-media/>
+ </div>
+ </main>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as age from 's-age';
+import getAcct from '../../../../../common/user/get-acct';
+import getAcct from '../../../../../common/user/parse-acct';
+import Progress from '../../../common/scripts/loading';
+import XHome from './user/home.vue';
+
+export default Vue.extend({
+ components: {
+ XHome
+ },
+ data() {
+ return {
+ fetching: true,
+ user: null,
+ page: 'home'
+ };
+ },
+ computed: {
+ acct() {
+ return this.getAcct(this.user);
+ },
+ age(): number {
+ return age(this.user.account.profile.birthday);
+ }
+ },
+ watch: {
+ $route: 'fetch'
+ },
+ created() {
+ this.fetch();
+ },
+ mounted() {
+ document.documentElement.style.background = '#313a42';
+ },
+ methods: {
+ fetch() {
+ Progress.start();
+
+ (this as any).api('users/show', parseAcct(this.$route.params.user)).then(user => {
+ this.user = user;
+ this.fetching = false;
+
+ Progress.done();
+ document.title = user.name + ' | Misskey';
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+main
+ > header
+
+ > .banner
+ padding-bottom 33.3%
+ background-color #1b1b1b
+ background-size cover
+ background-position center
+
+ > .body
+ padding 12px
+ margin 0 auto
+ max-width 600px
+
+ > .top
+ &:after
+ content ''
+ display block
+ clear both
+
+ > .avatar
+ display block
+ float left
+ width 25%
+ height 40px
+
+ > img
+ display block
+ position absolute
+ left -2px
+ bottom -2px
+ width 100%
+ border 3px solid #313a42
+ border-radius 6px
+
+ @media (min-width 500px)
+ left -4px
+ bottom -4px
+ border 4px solid #313a42
+ border-radius 12px
+
+ > .mk-follow-button
+ float right
+ height 40px
+
+ > .title
+ margin 8px 0
+
+ > h1
+ margin 0
+ line-height 22px
+ font-size 20px
+ color #fff
+
+ > .username
+ display inline-block
+ line-height 20px
+ font-size 16px
+ font-weight bold
+ color #657786
+
+ > .followed
+ margin-left 8px
+ padding 2px 4px
+ font-size 12px
+ color #657786
+ background #f8f8f8
+ border-radius 4px
+
+ > .description
+ margin 8px 0
+ color #fff
+
+ > .info
+ margin 8px 0
+
+ > p
+ display inline
+ margin 0 16px 0 0
+ color #a9b9c1
+
+ > i
+ margin-right 4px
+
+ > .status
+ > a
+ color #657786
+
+ &:not(:last-child)
+ margin-right 16px
+
+ > b
+ margin-right 4px
+ font-size 16px
+ color #fff
+
+ > i
+ font-size 14px
+
+ > nav
+ position sticky
+ top 48px
+ box-shadow 0 4px 4px rgba(0, 0, 0, 0.3)
+ background-color #313a42
+ z-index 1
+ > .nav-container
+ display flex
+ justify-content center
+ margin 0 auto
+ max-width 600px
+
+ > a
+ display block
+ flex 1 1
+ text-align center
+ line-height 52px
+ font-size 14px
+ text-decoration none
+ color #657786
+ border-bottom solid 2px transparent
+
+ &[data-is-active]
+ font-weight bold
+ color $theme-color
+ border-color $theme-color
+
+ > .body
+ padding 8px
+
+ @media (min-width 500px)
+ padding 16px
+
+</style>
diff --git a/src/server/web/app/mobile/views/pages/user/home.followers-you-know.vue b/src/server/web/app/mobile/views/pages/user/home.followers-you-know.vue
new file mode 100644
index 0000000000..1a2b8f7083
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/user/home.followers-you-know.vue
@@ -0,0 +1,67 @@
+<template>
+<div class="root followers-you-know">
+ <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-followers-you-know.loading%<mk-ellipsis/></p>
+ <div v-if="!fetching && users.length > 0">
+ <a v-for="user in users" :key="user.id" :href="`/@${getAcct(user)}`">
+ <img :src="`${user.avatar_url}?thumbnail&size=64`" :alt="user.name"/>
+ </a>
+ </div>
+ <p class="empty" v-if="!fetching && users.length == 0">%i18n:mobile.tags.mk-user-overview-followers-you-know.no-users%</p>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import getAcct from '../../../../../../common/user/get-acct';
+
+export default Vue.extend({
+ props: ['user'],
+ data() {
+ return {
+ fetching: true,
+ users: []
+ };
+ },
+ methods: {
+ getAcct
+ },
+ mounted() {
+ (this as any).api('users/followers', {
+ user_id: this.user.id,
+ iknow: true,
+ limit: 30
+ }).then(res => {
+ this.fetching = false;
+ this.users = res.users;
+ });
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.root.followers-you-know
+
+ > div
+ padding 4px
+
+ > a
+ display inline-block
+ margin 4px
+
+ > img
+ width 48px
+ height 48px
+ vertical-align bottom
+ border-radius 100%
+
+ > .initializing
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+</style>
diff --git a/src/server/web/app/mobile/views/pages/user/home.friends.vue b/src/server/web/app/mobile/views/pages/user/home.friends.vue
new file mode 100644
index 0000000000..b37f1a2fe8
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/user/home.friends.vue
@@ -0,0 +1,54 @@
+<template>
+<div class="root friends">
+ <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-frequently-replied-users.loading%<mk-ellipsis/></p>
+ <div v-if="!fetching && users.length > 0">
+ <mk-user-card v-for="user in users" :key="user.id" :user="user"/>
+ </div>
+ <p class="empty" v-if="!fetching && users.length == 0">%i18n:mobile.tags.mk-user-overview-frequently-replied-users.no-users%</p>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ props: ['user'],
+ data() {
+ return {
+ fetching: true,
+ users: []
+ };
+ },
+ mounted() {
+ (this as any).api('users/get_frequently_replied_users', {
+ user_id: this.user.id
+ }).then(res => {
+ this.users = res.map(x => x.user);
+ this.fetching = false;
+ });
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.root.friends
+ > div
+ overflow-x scroll
+ -webkit-overflow-scrolling touch
+ white-space nowrap
+ padding 8px
+
+ > .mk-user-card
+ &:not(:last-child)
+ margin-right 8px
+
+ > .fetching
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+</style>
diff --git a/src/server/web/app/mobile/views/pages/user/home.photos.vue b/src/server/web/app/mobile/views/pages/user/home.photos.vue
new file mode 100644
index 0000000000..f12f59a407
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/user/home.photos.vue
@@ -0,0 +1,83 @@
+<template>
+<div class="root photos">
+ <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-photos.loading%<mk-ellipsis/></p>
+ <div class="stream" v-if="!fetching && images.length > 0">
+ <a v-for="image in images"
+ class="img"
+ :style="`background-image: url(${image.media.url}?thumbnail&size=256)`"
+ :href="`/@${getAcct(image.post.user)}/${image.post.id}`"
+ ></a>
+ </div>
+ <p class="empty" v-if="!fetching && images.length == 0">%i18n:mobile.tags.mk-user-overview-photos.no-photos%</p>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import getAcct from '../../../../../../common/user/get-acct';
+
+export default Vue.extend({
+ props: ['user'],
+ data() {
+ return {
+ fetching: true,
+ images: []
+ };
+ },
+ methods: {
+ getAcct
+ },
+ mounted() {
+ (this as any).api('users/posts', {
+ user_id: this.user.id,
+ with_media: true,
+ limit: 6
+ }).then(posts => {
+ posts.forEach(post => {
+ post.media.forEach(media => {
+ if (this.images.length < 9) this.images.push({
+ post,
+ media
+ });
+ });
+ });
+ this.fetching = false;
+ });
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.root.photos
+
+ > .stream
+ display -webkit-flex
+ display -moz-flex
+ display -ms-flex
+ display flex
+ justify-content center
+ flex-wrap wrap
+ padding 8px
+
+ > .img
+ flex 1 1 33%
+ width 33%
+ height 80px
+ background-position center center
+ background-size cover
+ background-clip content-box
+ border solid 2px transparent
+ border-radius 4px
+
+ > .initializing
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+</style>
+
diff --git a/src/server/web/app/mobile/views/pages/user/home.posts.vue b/src/server/web/app/mobile/views/pages/user/home.posts.vue
new file mode 100644
index 0000000000..70b20ce943
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/user/home.posts.vue
@@ -0,0 +1,57 @@
+<template>
+<div class="root posts">
+ <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-posts.loading%<mk-ellipsis/></p>
+ <div v-if="!fetching && posts.length > 0">
+ <mk-post-card v-for="post in posts" :key="post.id" :post="post"/>
+ </div>
+ <p class="empty" v-if="!fetching && posts.length == 0">%i18n:mobile.tags.mk-user-overview-posts.no-posts%</p>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ props: ['user'],
+ data() {
+ return {
+ fetching: true,
+ posts: []
+ };
+ },
+ mounted() {
+ (this as any).api('users/posts', {
+ user_id: this.user.id
+ }).then(posts => {
+ this.posts = posts;
+ this.fetching = false;
+ });
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.root.posts
+
+ > div
+ overflow-x scroll
+ -webkit-overflow-scrolling touch
+ white-space nowrap
+ padding 8px
+
+ > *
+ vertical-align top
+
+ &:not(:last-child)
+ margin-right 8px
+
+ > .fetching
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+</style>
diff --git a/src/server/web/app/mobile/views/pages/user/home.vue b/src/server/web/app/mobile/views/pages/user/home.vue
new file mode 100644
index 0000000000..e3def61512
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/user/home.vue
@@ -0,0 +1,94 @@
+<template>
+<div class="root home">
+ <mk-post-detail v-if="user.pinned_post" :post="user.pinned_post" :compact="true"/>
+ <section class="recent-posts">
+ <h2>%fa:R comments%%i18n:mobile.tags.mk-user-overview.recent-posts%</h2>
+ <div>
+ <x-posts :user="user"/>
+ </div>
+ </section>
+ <section class="images">
+ <h2>%fa:image%%i18n:mobile.tags.mk-user-overview.images%</h2>
+ <div>
+ <x-photos :user="user"/>
+ </div>
+ </section>
+ <section class="activity">
+ <h2>%fa:chart-bar%%i18n:mobile.tags.mk-user-overview.activity%</h2>
+ <div>
+ <mk-activity :user="user"/>
+ </div>
+ </section>
+ <section class="frequently-replied-users">
+ <h2>%fa:users%%i18n:mobile.tags.mk-user-overview.frequently-replied-users%</h2>
+ <div>
+ <x-friends :user="user"/>
+ </div>
+ </section>
+ <section class="followers-you-know" v-if="os.isSignedIn && os.i.id !== user.id">
+ <h2>%fa:users%%i18n:mobile.tags.mk-user-overview.followers-you-know%</h2>
+ <div>
+ <x-followers-you-know :user="user"/>
+ </div>
+ </section>
+ <p v-if="user.host === null">%i18n:mobile.tags.mk-user-overview.last-used-at%: <b><mk-time :time="user.account.last_used_at"/></b></p>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XPosts from './home.posts.vue';
+import XPhotos from './home.photos.vue';
+import XFriends from './home.friends.vue';
+import XFollowersYouKnow from './home.followers-you-know.vue';
+
+export default Vue.extend({
+ components: {
+ XPosts,
+ XPhotos,
+ XFriends,
+ XFollowersYouKnow
+ },
+ props: ['user']
+});
+</script>
+
+<style lang="stylus" scoped>
+.root.home
+ max-width 600px
+ margin 0 auto
+
+ > .mk-post-detail
+ margin 0 0 8px 0
+
+ > section
+ background #eee
+ border-radius 8px
+ box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+
+ &:not(:last-child)
+ margin-bottom 8px
+
+ > h2
+ margin 0
+ padding 8px 10px
+ font-size 15px
+ font-weight normal
+ color #465258
+ background #fff
+ border-radius 8px 8px 0 0
+
+ > i
+ margin-right 6px
+
+ > .activity
+ > div
+ padding 8px
+
+ > p
+ display block
+ margin 16px
+ text-align center
+ color #cad2da
+
+</style>
diff --git a/src/server/web/app/mobile/views/pages/welcome.vue b/src/server/web/app/mobile/views/pages/welcome.vue
new file mode 100644
index 0000000000..3384ee6997
--- /dev/null
+++ b/src/server/web/app/mobile/views/pages/welcome.vue
@@ -0,0 +1,206 @@
+<template>
+<div class="welcome">
+ <h1><b>Misskey</b>へようこそ</h1>
+ <p>Twitter風ミニブログSNS、Misskeyへようこそ。共有したいことを投稿したり、タイムラインでみんなの投稿を読むこともできます。<br><a href="/signup">アカウントを作成する</a></p>
+ <div class="form">
+ <p>%fa:lock% ログイン</p>
+ <div>
+ <form @submit.prevent="onSubmit">
+ <input v-model="username" type="text" pattern="^[a-zA-Z0-9-]+$" placeholder="ユーザー名" autofocus required @change="onUsernameChange"/>
+ <input v-model="password" type="password" placeholder="パスワード" required/>
+ <input v-if="user && user.account.two_factor_enabled" v-model="token" type="number" placeholder="トークン" required/>
+ <button type="submit" :disabled="signing">{{ signing ? 'ログインしています' : 'ログイン' }}</button>
+ </form>
+ <div>
+ <a :href="`${apiUrl}/signin/twitter`">Twitterでログイン</a>
+ </div>
+ </div>
+ </div>
+ <div class="tl">
+ <p>%fa:comments R% タイムラインを見てみる</p>
+ <mk-welcome-timeline/>
+ </div>
+ <div class="users">
+ <router-link v-for="user in users" :key="user.id" class="avatar-anchor" :to="`/@${user.username}`">
+ <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=64`" alt="avatar"/>
+ </router-link>
+ </div>
+ <footer>
+ <small>{{ copyright }}</small>
+ </footer>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { apiUrl, copyright } from '../../../config';
+
+export default Vue.extend({
+ data() {
+ return {
+ signing: false,
+ user: null,
+ username: '',
+ password: '',
+ token: '',
+ apiUrl,
+ copyright,
+ users: []
+ };
+ },
+ mounted() {
+ (this as any).api('users', {
+ sort: '+follower',
+ limit: 20
+ }).then(users => {
+ this.users = users;
+ });
+ },
+ methods: {
+ onUsernameChange() {
+ (this as any).api('users/show', {
+ username: this.username
+ }).then(user => {
+ this.user = user;
+ });
+ },
+ onSubmit() {
+ this.signing = true;
+
+ (this as any).api('signin', {
+ username: this.username,
+ password: this.password,
+ token: this.user && this.user.account.two_factor_enabled ? this.token : undefined
+ }).then(() => {
+ location.reload();
+ }).catch(() => {
+ alert('something happened');
+ this.signing = false;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.welcome
+ padding 16px
+ margin 0 auto
+ max-width 500px
+
+ h1
+ margin 0
+ padding 8px
+ font-size 1.5em
+ font-weight normal
+ color #cacac3
+
+ & + p
+ margin 0 0 16px 0
+ padding 0 8px 0 8px
+ color #949fa9
+
+ .form
+ margin-bottom 16px
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.2)
+ border-radius 8px
+ overflow hidden
+
+ > p
+ margin 0
+ padding 12px 20px
+ color #555
+ background #f5f5f5
+ border-bottom solid 1px #ddd
+
+ > div
+
+ > form
+ padding 16px
+ border-bottom solid 1px #ddd
+
+ input
+ display block
+ padding 12px
+ margin 0 0 16px 0
+ width 100%
+ font-size 1em
+ color rgba(0, 0, 0, 0.7)
+ background #fff
+ outline none
+ border solid 1px #ddd
+ border-radius 4px
+
+ button
+ display block
+ width 100%
+ padding 10px
+ margin 0
+ color #333
+ font-size 1em
+ text-align center
+ text-decoration none
+ text-shadow 0 1px 0 rgba(255, 255, 255, 0.9)
+ background-image linear-gradient(#fafafa, #eaeaea)
+ border 1px solid #ddd
+ border-bottom-color #cecece
+ border-radius 4px
+
+ &:active
+ background-color #767676
+ background-image none
+ border-color #444
+ box-shadow 0 1px 3px rgba(0, 0, 0, 0.075), inset 0 0 5px rgba(0, 0, 0, 0.2)
+
+ > div
+ padding 16px
+ text-align center
+
+ > .tl
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.2)
+ border-radius 8px
+ overflow hidden
+
+ > p
+ margin 0
+ padding 12px 20px
+ color #555
+ background #f5f5f5
+ border-bottom solid 1px #ddd
+
+ > .mk-welcome-timeline
+ max-height 300px
+ overflow auto
+
+ > .users
+ margin 12px 0 0 0
+
+ > *
+ display inline-block
+ margin 4px
+
+ > *
+ display inline-block
+ width 38px
+ height 38px
+ vertical-align top
+ border-radius 6px
+
+ > footer
+ text-align center
+ color #fff
+
+ > small
+ display block
+ margin 16px 0 0 0
+ opacity 0.7
+
+</style>
+
+<style lang="stylus">
+html
+body
+ background linear-gradient(to bottom, #1e1d65, #bd6659)
+</style>
diff --git a/src/server/web/app/mobile/views/widgets/activity.vue b/src/server/web/app/mobile/views/widgets/activity.vue
new file mode 100644
index 0000000000..48dcafb3ed
--- /dev/null
+++ b/src/server/web/app/mobile/views/widgets/activity.vue
@@ -0,0 +1,32 @@
+<template>
+<div class="mkw-activity">
+ <mk-widget-container :show-header="!props.compact">
+ <template slot="header">%fa:chart-bar%アクティビティ</template>
+ <div :class="$style.body">
+ <mk-activity :user="os.i"/>
+ </div>
+ </mk-widget-container>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../common/define-widget';
+
+export default define({
+ name: 'activity',
+ props: () => ({
+ compact: false
+ })
+}).extend({
+ methods: {
+ func() {
+ this.props.compact = !this.props.compact;
+ }
+ }
+});
+</script>
+
+<style lang="stylus" module>
+.body
+ padding 8px
+</style>
diff --git a/src/server/web/app/mobile/views/widgets/index.ts b/src/server/web/app/mobile/views/widgets/index.ts
new file mode 100644
index 0000000000..4de912b64c
--- /dev/null
+++ b/src/server/web/app/mobile/views/widgets/index.ts
@@ -0,0 +1,7 @@
+import Vue from 'vue';
+
+import wActivity from './activity.vue';
+import wProfile from './profile.vue';
+
+Vue.component('mkw-activity', wActivity);
+Vue.component('mkw-profile', wProfile);
diff --git a/src/server/web/app/mobile/views/widgets/profile.vue b/src/server/web/app/mobile/views/widgets/profile.vue
new file mode 100644
index 0000000000..1c9d038b4c
--- /dev/null
+++ b/src/server/web/app/mobile/views/widgets/profile.vue
@@ -0,0 +1,62 @@
+<template>
+<div class="mkw-profile">
+ <mk-widget-container>
+ <div :class="$style.banner"
+ :style="os.i.banner_url ? `background-image: url(${os.i.banner_url}?thumbnail&size=256)` : ''"
+ ></div>
+ <img :class="$style.avatar"
+ :src="`${os.i.avatar_url}?thumbnail&size=96`"
+ alt="avatar"
+ />
+ <router-link :class="$style.name" :to="`/@${os.i.username}`">{{ os.i.name }}</router-link>
+ </mk-widget-container>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../common/define-widget';
+export default define({
+ name: 'profile'
+});
+</script>
+
+<style lang="stylus" module>
+.banner
+ height 100px
+ background-color #f5f5f5
+ background-size cover
+ background-position center
+ cursor pointer
+
+.banner:before
+ content ""
+ display block
+ width 100%
+ height 100%
+ background rgba(0, 0, 0, 0.5)
+
+.avatar
+ display block
+ position absolute
+ width 58px
+ height 58px
+ margin 0
+ vertical-align bottom
+ 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
+ display block
+ position absolute
+ top 0
+ left 92px
+ margin 0
+ line-height 100px
+ color #fff
+ font-weight bold
+ text-shadow 0 0 8px rgba(0, 0, 0, 0.5)
+
+</style>