diff options
| author | syuilo <syuilotan@yahoo.co.jp> | 2018-03-29 20:32:18 +0900 |
|---|---|---|
| committer | syuilo <syuilotan@yahoo.co.jp> | 2018-03-29 20:32:18 +0900 |
| commit | cf33e483f7e6f40e8cbbbc0118a7df70bdaf651f (patch) | |
| tree | 318279530d3392ee40d91968477fc0e78d5cf0f7 /src/client/app/mobile | |
| parent | Update .travis.yml (diff) | |
| download | sharkey-cf33e483f7e6f40e8cbbbc0118a7df70bdaf651f.tar.gz sharkey-cf33e483f7e6f40e8cbbbc0118a7df70bdaf651f.tar.bz2 sharkey-cf33e483f7e6f40e8cbbbc0118a7df70bdaf651f.zip | |
整理した
Diffstat (limited to 'src/client/app/mobile')
69 files changed, 7589 insertions, 0 deletions
diff --git a/src/client/app/mobile/api/choose-drive-file.ts b/src/client/app/mobile/api/choose-drive-file.ts new file mode 100644 index 0000000000..b1a78f2364 --- /dev/null +++ b/src/client/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/client/app/mobile/api/choose-drive-folder.ts b/src/client/app/mobile/api/choose-drive-folder.ts new file mode 100644 index 0000000000..d1f97d1487 --- /dev/null +++ b/src/client/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/client/app/mobile/api/dialog.ts b/src/client/app/mobile/api/dialog.ts new file mode 100644 index 0000000000..a2378767be --- /dev/null +++ b/src/client/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/client/app/mobile/api/input.ts b/src/client/app/mobile/api/input.ts new file mode 100644 index 0000000000..38d0fb61eb --- /dev/null +++ b/src/client/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/client/app/mobile/api/notify.ts b/src/client/app/mobile/api/notify.ts new file mode 100644 index 0000000000..82780d196f --- /dev/null +++ b/src/client/app/mobile/api/notify.ts @@ -0,0 +1,3 @@ +export default function(message) { + alert(message); +} diff --git a/src/client/app/mobile/api/post.ts b/src/client/app/mobile/api/post.ts new file mode 100644 index 0000000000..841103fee1 --- /dev/null +++ b/src/client/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', { + repostId: 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/client/app/mobile/script.ts b/src/client/app/mobile/script.ts new file mode 100644 index 0000000000..4776fccddb --- /dev/null +++ b/src/client/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/client/app/mobile/style.styl b/src/client/app/mobile/style.styl new file mode 100644 index 0000000000..81912a2483 --- /dev/null +++ b/src/client/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/client/app/mobile/views/components/activity.vue b/src/client/app/mobile/views/components/activity.vue new file mode 100644 index 0000000000..2e44017e77 --- /dev/null +++ b/src/client/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', { + userId: 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/client/app/mobile/views/components/drive-file-chooser.vue b/src/client/app/mobile/views/components/drive-file-chooser.vue new file mode 100644 index 0000000000..6806af0f1e --- /dev/null +++ b/src/client/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/client/app/mobile/views/components/drive-folder-chooser.vue b/src/client/app/mobile/views/components/drive-folder-chooser.vue new file mode 100644 index 0000000000..853078664f --- /dev/null +++ b/src/client/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/client/app/mobile/views/components/drive.file-detail.vue b/src/client/app/mobile/views/components/drive.file-detail.vue new file mode 100644 index 0000000000..f3274f677f --- /dev/null +++ b/src/client/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.createdAt"/></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.avgColor ? { + 'background-color': `rgb(${ this.file.properties.avgColor.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', { + fileId: 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', { + fileId: this.file.id, + folderId: folder == null ? null : folder.id + }).then(() => { + this.browser.cf(this.file, true); + }); + }); + }, + showCreatedAt() { + alert(new Date(this.file.createdAt).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/client/app/mobile/views/components/drive.file.vue b/src/client/app/mobile/views/components/drive.file.vue new file mode 100644 index 0000000000..7d1957042b --- /dev/null +++ b/src/client/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.createdAt"/> + </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.avgColor ? `rgb(${this.file.properties.avgColor.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/client/app/mobile/views/components/drive.folder.vue b/src/client/app/mobile/views/components/drive.folder.vue new file mode 100644 index 0000000000..22ff38fecb --- /dev/null +++ b/src/client/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/client/app/mobile/views/components/drive.vue b/src/client/app/mobile/views/components/drive.vue new file mode 100644 index 0000000000..ff5366a0ad --- /dev/null +++ b/src/client/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.foldersCount > 0 || folder.filesCount > 0)"> + <template v-if="folder.foldersCount > 0">{{ folder.foldersCount }} %i18n:mobile.tags.mk-drive.folder-count%</template> + <template v-if="folder.foldersCount > 0 && folder.filesCount > 0">%i18n:mobile.tags.mk-drive.count-separator%</template> + <template v-if="folder.filesCount > 0">{{ folder.filesCount }} %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.folderId) { + 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.parentId) { + 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', { + folderId: 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.parentId) 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.folderId) return; + + if (this.files.some(f => f.id == file.id)) { + const exist = this.files.map(f => f.id).indexOf(file.id); + Vue.set(this.files, exist, file); + return; + } + + if (unshift) { + this.files.unshift(file); + } else { + this.files.push(file); + } + }, + + removeFolder(folder) { + if (typeof folder == 'object') folder = folder.id; + this.folders = this.folders.filter(f => f.id != folder); + }, + + removeFile(file) { + if (typeof file == 'object') file = file.id; + this.files = this.files.filter(f => f.id != file); + }, + + appendFile(file) { + this.addFile(file); + }, + appendFolder(folder) { + this.addFolder(folder); + }, + prependFile(file) { + this.addFile(file, true); + }, + prependFolder(folder) { + this.addFolder(folder, true); + }, + + goRoot(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', { + folderId: this.folder ? this.folder.id : null, + limit: foldersMax + 1 + }).then(folders => { + if (folders.length == foldersMax + 1) { + this.moreFolders = true; + folders.pop(); + } + fetchedFolders = folders; + complete(); + }); + + // ファイル一覧取得 + (this as any).api('drive/files', { + folderId: 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', { + folderId: this.folder ? this.folder.id : null, + limit: max + 1, + untilId: 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', { + fileId: 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, + parentId: 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, + folderId: 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', { + parentId: folder ? folder.id : null, + folderId: 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, + folderId: 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/client/app/mobile/views/components/follow-button.vue b/src/client/app/mobile/views/components/follow-button.vue new file mode 100644 index 0000000000..43c69d4e02 --- /dev/null +++ b/src/client/app/mobile/views/components/follow-button.vue @@ -0,0 +1,123 @@ +<template> +<button class="mk-follow-button" + :class="{ wait: wait, follow: !user.isFollowing, unfollow: user.isFollowing }" + @click="onClick" + :disabled="wait" +> + <template v-if="!wait && user.isFollowing">%fa:minus%</template> + <template v-if="!wait && !user.isFollowing">%fa:plus%</template> + <template v-if="wait">%fa:spinner .pulse .fw%</template> + {{ user.isFollowing ? '%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.isFollowing = user.isFollowing; + } + }, + + onUnfollow(user) { + if (user.id == this.user.id) { + this.user.isFollowing = user.isFollowing; + } + }, + + onClick() { + this.wait = true; + if (this.user.isFollowing) { + (this as any).api('following/delete', { + userId: this.user.id + }).then(() => { + this.user.isFollowing = false; + }).catch(err => { + console.error(err); + }).then(() => { + this.wait = false; + }); + } else { + (this as any).api('following/create', { + userId: this.user.id + }).then(() => { + this.user.isFollowing = 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/client/app/mobile/views/components/friends-maker.vue b/src/client/app/mobile/views/components/friends-maker.vue new file mode 100644 index 0000000000..961a5f568a --- /dev/null +++ b/src/client/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/client/app/mobile/views/components/index.ts b/src/client/app/mobile/views/components/index.ts new file mode 100644 index 0000000000..fb8f65f47d --- /dev/null +++ b/src/client/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/client/app/mobile/views/components/media-image.vue b/src/client/app/mobile/views/components/media-image.vue new file mode 100644 index 0000000000..cfc2134988 --- /dev/null +++ b/src/client/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.avgColor ? `rgb(${this.image.properties.avgColor.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/client/app/mobile/views/components/media-video.vue b/src/client/app/mobile/views/components/media-video.vue new file mode 100644 index 0000000000..68cd48587a --- /dev/null +++ b/src/client/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/client/app/mobile/views/components/notification-preview.vue b/src/client/app/mobile/views/components/notification-preview.vue new file mode 100644 index 0000000000..fce9ed82f9 --- /dev/null +++ b/src/client/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.avatarUrl}?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.avatarUrl}?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.avatarUrl}?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.avatarUrl}?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.avatarUrl}?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.avatarUrl}?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.avatarUrl}?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/client/app/mobile/views/components/notification.vue b/src/client/app/mobile/views/components/notification.vue new file mode 100644 index 0000000000..e221fb3ac4 --- /dev/null +++ b/src/client/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.createdAt"/> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${notification.user.avatarUrl}?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.createdAt"/> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${notification.post.user.avatarUrl}?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.createdAt"/> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${notification.user.avatarUrl}?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.createdAt"/> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${notification.user.avatarUrl}?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/client/app/mobile/views/components/notifications.vue b/src/client/app/mobile/views/components/notifications.vue new file mode 100644 index 0000000000..d68b990dfa --- /dev/null +++ b/src/client/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.createdAt).getDate(); + const month = new Date(notification.createdAt).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, + untilId: 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/client/app/mobile/views/components/notify.vue b/src/client/app/mobile/views/components/notify.vue new file mode 100644 index 0000000000..6d4a481dbe --- /dev/null +++ b/src/client/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/client/app/mobile/views/components/post-card.vue b/src/client/app/mobile/views/components/post-card.vue new file mode 100644 index 0000000000..10dfd92415 --- /dev/null +++ b/src/client/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.createdAt"/> + </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/client/app/mobile/views/components/post-detail.sub.vue b/src/client/app/mobile/views/components/post-detail.sub.vue new file mode 100644 index 0000000000..db7567834a --- /dev/null +++ b/src/client/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.avatarUrl}?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.createdAt"/> + </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/client/app/mobile/views/components/post-detail.vue b/src/client/app/mobile/views/components/post-detail.vue new file mode 100644 index 0000000000..f0af1a61aa --- /dev/null +++ b/src/client/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.replyId && 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.avatarUrl}?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.avatarUrl}?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.coordinates[1]},${p.geo.coordinates[0]}`" 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.createdAt" 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.repliesCount > 0">{{ p.repliesCount }}</p> + </button> + <button @click="repost" title="Repost"> + %fa:retweet%<p class="count" v-if="p.repostCount > 0">{{ p.repostCount }}</p> + </button> + <button :class="{ reacted: p.myReaction != 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.mediaIds == null && + this.post.poll == null); + }, + p(): any { + return this.isRepost ? this.post.repost : this.post; + }, + reactionsCount(): number { + return this.p.reactionCounts + ? Object.keys(this.p.reactionCounts) + .map(key => this.p.reactionCounts[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', { + postId: 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.clientSettings.showMaps : true; + if (shouldShowMap) { + (this as any).os.getGoogleMaps().then(maps => { + const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]); + 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', { + postId: this.p.replyId + }).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/client/app/mobile/views/components/post-form.vue b/src/client/app/mobile/views/components/post-form.vue new file mode 100644 index 0000000000..5b78a25710 --- /dev/null +++ b/src/client/app/mobile/views/components/post-form.vue @@ -0,0 +1,275 @@ +<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.clientSettings.disableViaMobile !== true; + (this as any).api('posts/create', { + text: this.text == '' ? undefined : this.text, + mediaIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + replyId: this.reply ? this.reply.id : undefined, + poll: this.poll ? (this.$refs.poll as any).get() : undefined, + geo: this.geo ? { + coordinates: [this.geo.longitude, this.geo.latitude], + 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, + viaMobile: 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/client/app/mobile/views/components/post-preview.vue b/src/client/app/mobile/views/components/post-preview.vue new file mode 100644 index 0000000000..a6141dc8e3 --- /dev/null +++ b/src/client/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.avatarUrl}?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.createdAt"/> + </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/client/app/mobile/views/components/post.sub.vue b/src/client/app/mobile/views/components/post.sub.vue new file mode 100644 index 0000000000..adf444a2d6 --- /dev/null +++ b/src/client/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.avatarUrl}?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.createdAt"/> + </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/client/app/mobile/views/components/post.vue b/src/client/app/mobile/views/components/post.vue new file mode 100644 index 0000000000..a01eb7669e --- /dev/null +++ b/src/client/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.avatarUrl}?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.createdAt"/> + </div> + <article> + <router-link class="avatar-anchor" :to="`/@${pAcct}`"> + <img class="avatar" :src="`${p.user.avatarUrl}?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.isBot">bot</span> + <span class="username">@{{ pAcct }}</span> + <div class="info"> + <span class="mobile" v-if="p.viaMobile">%fa:mobile-alt%</span> + <router-link class="created-at" :to="url"> + <mk-time :time="p.createdAt"/> + </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.coordinates[1]},${p.geo.coordinates[0]}`" 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.repliesCount > 0">{{ p.repliesCount }}</p> + </button> + <button @click="repost" title="Repost"> + %fa:retweet%<p class="count" v-if="p.repostCount > 0">{{ p.repostCount }}</p> + </button> + <button :class="{ reacted: p.myReaction != 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.mediaIds == null && + this.post.poll == null); + }, + p(): any { + return this.isRepost ? this.post.repost : this.post; + }, + reactionsCount(): number { + return this.p.reactionCounts + ? Object.keys(this.p.reactionCounts) + .map(key => this.p.reactionCounts[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.clientSettings.showMaps : true; + if (shouldShowMap) { + (this as any).os.getGoogleMaps().then(maps => { + const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]); + 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.repostId) { + 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/client/app/mobile/views/components/posts.vue b/src/client/app/mobile/views/components/posts.vue new file mode 100644 index 0000000000..4695f1beaa --- /dev/null +++ b/src/client/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.createdAt).getDate(); + const month = new Date(post.createdAt).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/client/app/mobile/views/components/sub-post-content.vue b/src/client/app/mobile/views/components/sub-post-content.vue new file mode 100644 index 0000000000..b95883de77 --- /dev/null +++ b/src/client/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.replyId">%fa:reply%</a> + <mk-post-html v-if="post.ast" :ast="post.ast" :i="os.i"/> + <a class="rp" v-if="post.repostId">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/client/app/mobile/views/components/timeline.vue b/src/client/app/mobile/views/components/timeline.vue new file mode 100644 index 0000000000..7b5948faf1 --- /dev/null +++ b/src/client/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.followingCount == 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, + untilDate: 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, + untilId: 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/client/app/mobile/views/components/ui.header.vue b/src/client/app/mobile/views/components/ui.header.vue new file mode 100644 index 0000000000..2bf47a90a9 --- /dev/null +++ b/src/client/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.lastUsedAt).getTime()) / 1000 + const isHisasiburi = ago >= 3600; + (this as any).os.i.account.lastUsedAt = 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/client/app/mobile/views/components/ui.nav.vue b/src/client/app/mobile/views/components/ui.nav.vue new file mode 100644 index 0000000000..a923774a73 --- /dev/null +++ b/src/client/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.avatarUrl}?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/client/app/mobile/views/components/ui.vue b/src/client/app/mobile/views/components/ui.vue new file mode 100644 index 0000000000..325ce9d40e --- /dev/null +++ b/src/client/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/client/app/mobile/views/components/user-card.vue b/src/client/app/mobile/views/components/user-card.vue new file mode 100644 index 0000000000..ffa1100519 --- /dev/null +++ b/src/client/app/mobile/views/components/user-card.vue @@ -0,0 +1,69 @@ +<template> +<div class="mk-user-card"> + <header :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=1024)` : ''"> + <a :href="`/@${acct}`"> + <img :src="`${user.avatarUrl}?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/client/app/mobile/views/components/user-preview.vue b/src/client/app/mobile/views/components/user-preview.vue new file mode 100644 index 0000000000..e51e4353d3 --- /dev/null +++ b/src/client/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.avatarUrl}?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/client/app/mobile/views/components/user-timeline.vue b/src/client/app/mobile/views/components/user-timeline.vue new file mode 100644 index 0000000000..bd3e3d0c87 --- /dev/null +++ b/src/client/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', { + userId: this.user.id, + withMedia: 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', { + userId: this.user.id, + withMedia: this.withMedia, + limit: limit + 1, + untilId: 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/client/app/mobile/views/components/users-list.vue b/src/client/app/mobile/views/components/users-list.vue new file mode 100644 index 0000000000..b11e4549d6 --- /dev/null +++ b/src/client/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/client/app/mobile/views/components/widget-container.vue b/src/client/app/mobile/views/components/widget-container.vue new file mode 100644 index 0000000000..7319c90849 --- /dev/null +++ b/src/client/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/client/app/mobile/views/directives/index.ts b/src/client/app/mobile/views/directives/index.ts new file mode 100644 index 0000000000..324e07596d --- /dev/null +++ b/src/client/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/client/app/mobile/views/directives/user-preview.ts b/src/client/app/mobile/views/directives/user-preview.ts new file mode 100644 index 0000000000..1a54abc20d --- /dev/null +++ b/src/client/app/mobile/views/directives/user-preview.ts @@ -0,0 +1,2 @@ +// nope +export default {}; diff --git a/src/client/app/mobile/views/pages/drive.vue b/src/client/app/mobile/views/pages/drive.vue new file mode 100644 index 0000000000..200379f222 --- /dev/null +++ b/src/client/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/client/app/mobile/views/pages/followers.vue b/src/client/app/mobile/views/pages/followers.vue new file mode 100644 index 0000000000..8c058eb4e6 --- /dev/null +++ b/src/client/app/mobile/views/pages/followers.vue @@ -0,0 +1,65 @@ +<template> +<mk-ui> + <template slot="header" v-if="!fetching"> + <img :src="`${user.avatarUrl}?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.followersCount" + :you-know-count="user.followersYouKnowCount" + @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', { + userId: this.user.id, + iknow: iknow, + limit: limit, + cursor: cursor ? cursor : undefined + }).then(cb); + } + } +}); +</script> diff --git a/src/client/app/mobile/views/pages/following.vue b/src/client/app/mobile/views/pages/following.vue new file mode 100644 index 0000000000..a73c9d1710 --- /dev/null +++ b/src/client/app/mobile/views/pages/following.vue @@ -0,0 +1,65 @@ +<template> +<mk-ui> + <template slot="header" v-if="!fetching"> + <img :src="`${user.avatarUrl}?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.followingCount" + :you-know-count="user.followingYouKnowCount" + @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', { + userId: this.user.id, + iknow: iknow, + limit: limit, + cursor: cursor ? cursor : undefined + }).then(cb); + } + } +}); +</script> diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue new file mode 100644 index 0000000000..be9101aa7f --- /dev/null +++ b/src/client/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.clientSettings.mobile_home == null) { + Vue.set((this as any).os.i.account.clientSettings, '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.clientSettings.mobile_home; + this.saveHome(); + } else { + this.widgets = (this as any).os.i.account.clientSettings.mobile_home; + } + + this.$watch('os.i.account.clientSettings', i => { + this.widgets = (this as any).os.i.account.clientSettings.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.userId !== (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.clientSettings.mobile_home = data.home; + this.widgets = data.home; + } else { + const w = (this as any).os.i.account.clientSettings.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.clientSettings.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.clientSettings.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/client/app/mobile/views/pages/index.vue b/src/client/app/mobile/views/pages/index.vue new file mode 100644 index 0000000000..0ea47d913b --- /dev/null +++ b/src/client/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/client/app/mobile/views/pages/messaging-room.vue b/src/client/app/mobile/views/pages/messaging-room.vue new file mode 100644 index 0000000000..193c41179c --- /dev/null +++ b/src/client/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/client/app/mobile/views/pages/messaging.vue b/src/client/app/mobile/views/pages/messaging.vue new file mode 100644 index 0000000000..e92068eda5 --- /dev/null +++ b/src/client/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/client/app/mobile/views/pages/notifications.vue b/src/client/app/mobile/views/pages/notifications.vue new file mode 100644 index 0000000000..6d45e22a9c --- /dev/null +++ b/src/client/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/markAsRead_all'); + }, + onFetched() { + Progress.done(); + } + } +}); +</script> diff --git a/src/client/app/mobile/views/pages/othello.vue b/src/client/app/mobile/views/pages/othello.vue new file mode 100644 index 0000000000..e04e583c20 --- /dev/null +++ b/src/client/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', { + gameId: 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/client/app/mobile/views/pages/post.vue b/src/client/app/mobile/views/pages/post.vue new file mode 100644 index 0000000000..49a4bfd9dc --- /dev/null +++ b/src/client/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', { + postId: 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/client/app/mobile/views/pages/profile-setting.vue b/src/client/app/mobile/views/pages/profile-setting.vue new file mode 100644 index 0000000000..15f9bc9b68 --- /dev/null +++ b/src/client/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.bannerUrl ? `background-image: url(${os.i.bannerUrl}?thumbnail&size=1024)` : ''" @click="setBanner"> + <img :src="`${os.i.avatarUrl}?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', { + avatarId: 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', { + bannerId: 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/client/app/mobile/views/pages/search.vue b/src/client/app/mobile/views/pages/search.vue new file mode 100644 index 0000000000..cbab504e3c --- /dev/null +++ b/src/client/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/client/app/mobile/views/pages/selectdrive.vue b/src/client/app/mobile/views/pages/selectdrive.vue new file mode 100644 index 0000000000..3480a0d103 --- /dev/null +++ b/src/client/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/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue new file mode 100644 index 0000000000..a945a21c5c --- /dev/null +++ b/src/client/app/mobile/views/pages/settings.vue @@ -0,0 +1,103 @@ +<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 {{ version }} ({{ codename }})</small></p> + </div> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { version, codename } from '../../../config'; + +export default Vue.extend({ + data() { + return { + version, + codename + }; + }, + 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/client/app/mobile/views/pages/signup.vue b/src/client/app/mobile/views/pages/signup.vue new file mode 100644 index 0000000000..9dc07a4b86 --- /dev/null +++ b/src/client/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/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue new file mode 100644 index 0000000000..114decb8e4 --- /dev/null +++ b/src/client/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.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=1024)` : ''"></div> + <div class="body"> + <div class="top"> + <a class="avatar"> + <img :src="`${user.avatarUrl}?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.isFollowed">%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.postsCount | number }}</b> + <i>%i18n:mobile.tags.mk-user.posts%</i> + </a> + <a :href="`@${acct}/following`"> + <b>{{ user.followingCount | number }}</b> + <i>%i18n:mobile.tags.mk-user.following%</i> + </a> + <a :href="`@${acct}/followers`"> + <b>{{ user.followersCount | 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/client/app/mobile/views/pages/user/home.followers-you-know.vue b/src/client/app/mobile/views/pages/user/home.followers-you-know.vue new file mode 100644 index 0000000000..8c84d2dbba --- /dev/null +++ b/src/client/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.avatarUrl}?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', { + userId: 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/client/app/mobile/views/pages/user/home.friends.vue b/src/client/app/mobile/views/pages/user/home.friends.vue new file mode 100644 index 0000000000..469781abb9 --- /dev/null +++ b/src/client/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', { + userId: 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/client/app/mobile/views/pages/user/home.photos.vue b/src/client/app/mobile/views/pages/user/home.photos.vue new file mode 100644 index 0000000000..f703f8a740 --- /dev/null +++ b/src/client/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', { + userId: this.user.id, + withMedia: 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/client/app/mobile/views/pages/user/home.posts.vue b/src/client/app/mobile/views/pages/user/home.posts.vue new file mode 100644 index 0000000000..654f7f63e0 --- /dev/null +++ b/src/client/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', { + userId: 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/client/app/mobile/views/pages/user/home.vue b/src/client/app/mobile/views/pages/user/home.vue new file mode 100644 index 0000000000..1afcd1f5ba --- /dev/null +++ b/src/client/app/mobile/views/pages/user/home.vue @@ -0,0 +1,94 @@ +<template> +<div class="root home"> + <mk-post-detail v-if="user.pinnedPost" :post="user.pinnedPost" :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.lastUsedAt"/></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/client/app/mobile/views/pages/welcome.vue b/src/client/app/mobile/views/pages/welcome.vue new file mode 100644 index 0000000000..17cdf93065 --- /dev/null +++ b/src/client/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.twoFactorEnabled" 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.avatarUrl}?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.twoFactorEnabled ? 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/client/app/mobile/views/widgets/activity.vue b/src/client/app/mobile/views/widgets/activity.vue new file mode 100644 index 0000000000..48dcafb3ed --- /dev/null +++ b/src/client/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/client/app/mobile/views/widgets/index.ts b/src/client/app/mobile/views/widgets/index.ts new file mode 100644 index 0000000000..4de912b64c --- /dev/null +++ b/src/client/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/client/app/mobile/views/widgets/profile.vue b/src/client/app/mobile/views/widgets/profile.vue new file mode 100644 index 0000000000..f1d283e45a --- /dev/null +++ b/src/client/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.bannerUrl ? `background-image: url(${os.i.bannerUrl}?thumbnail&size=256)` : ''" + ></div> + <img :class="$style.avatar" + :src="`${os.i.avatarUrl}?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> |