diff options
Diffstat (limited to 'src/client/app')
314 files changed, 33368 insertions, 0 deletions
diff --git a/src/client/app/animation.styl b/src/client/app/animation.styl new file mode 100644 index 0000000000..8f121b313b --- /dev/null +++ b/src/client/app/animation.styl @@ -0,0 +1,12 @@ +.zoom-in-top-enter-active, +.zoom-in-top-leave-active { + opacity: 1; + transform: scaleY(1); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); + transform-origin: center top; +} +.zoom-in-top-enter, +.zoom-in-top-leave-active { + opacity: 0; + transform: scaleY(0); +} diff --git a/src/client/app/app.styl b/src/client/app/app.styl new file mode 100644 index 0000000000..431b9daa65 --- /dev/null +++ b/src/client/app/app.styl @@ -0,0 +1,128 @@ +@import "../style" +@import "../animation" + +html + &.progress + &, * + cursor progress !important + +body + overflow-wrap break-word + +#error + padding 32px + color #fff + + hr + border solid 1px #fff + +#nprogress + pointer-events none + + position absolute + z-index 65536 + + .bar + background $theme-color + + position fixed + z-index 65537 + top 0 + left 0 + + width 100% + height 2px + + /* Fancy blur effect */ + .peg + display block + position absolute + right 0px + width 100px + height 100% + box-shadow 0 0 10px $theme-color, 0 0 5px $theme-color + opacity 1 + + transform rotate(3deg) translate(0px, -4px) + +#wait + display block + position fixed + z-index 65537 + top 15px + right 15px + + &:before + content "" + display block + width 18px + height 18px + box-sizing border-box + + border solid 2px transparent + border-top-color $theme-color + border-left-color $theme-color + border-radius 50% + + animation progress-spinner 400ms linear infinite + + @keyframes progress-spinner + 0% + transform rotate(0deg) + 100% + transform rotate(360deg) + +code + font-family Consolas, 'Courier New', Courier, Monaco, monospace + + .comment + opacity 0.5 + + .string + color #e96900 + + .regexp + color #e9003f + + .keyword + color #2973b7 + + &.true + &.false + &.null + &.nil + &.undefined + color #ae81ff + + .symbol + color #42b983 + + .number + .nan + color #ae81ff + + .var:not(.keyword) + font-weight bold + font-style italic + //text-decoration underline + + .method + font-style italic + color #8964c1 + + .property + color #a71d5d + + .label + color #e9003f + +pre + display block + + > code + display block + overflow auto + tab-size 2 + +[data-fa] + display inline-block diff --git a/src/client/app/app.vue b/src/client/app/app.vue new file mode 100644 index 0000000000..7a46e7dea0 --- /dev/null +++ b/src/client/app/app.vue @@ -0,0 +1,3 @@ +<template> +<router-view id="app"></router-view> +</template> diff --git a/src/client/app/auth/assets/logo.svg b/src/client/app/auth/assets/logo.svg new file mode 100644 index 0000000000..19b8a2737e --- /dev/null +++ b/src/client/app/auth/assets/logo.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="1024px" height="512px" viewBox="0 256 1024 512" enable-background="new 0 256 1024 512" xml:space="preserve">
+<polyline opacity="0.5" fill="none" stroke="#000000" stroke-width="34" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" points="
+ 896.5,608.5 800.5,416.5 704.5,608.5 608.5,416.5 512.5,608.5 416.5,416.5 320.5,608.5 224.5,416.5 128.5,608.5 "/>
+</svg>
diff --git a/src/client/app/auth/script.ts b/src/client/app/auth/script.ts new file mode 100644 index 0000000000..31c758ebc2 --- /dev/null +++ b/src/client/app/auth/script.ts @@ -0,0 +1,25 @@ +/** + * Authorize Form + */ + +// Style +import './style.styl'; + +import init from '../init'; + +import Index from './views/index.vue'; + +/** + * init + */ +init(async (launch) => { + document.title = 'Misskey | アプリの連携'; + + // Launch the app + const [app] = launch(); + + // Routing + app.$router.addRoutes([ + { path: '/:token', component: Index }, + ]); +}); diff --git a/src/client/app/auth/style.styl b/src/client/app/auth/style.styl new file mode 100644 index 0000000000..bd25e1b572 --- /dev/null +++ b/src/client/app/auth/style.styl @@ -0,0 +1,15 @@ +@import "../app" +@import "../reset" + +html + background #eee + + @media (max-width 600px) + background #fff + +body + margin 0 + padding 32px 0 + + @media (max-width 600px) + padding 0 diff --git a/src/client/app/auth/views/form.vue b/src/client/app/auth/views/form.vue new file mode 100644 index 0000000000..9d9e8cdb1b --- /dev/null +++ b/src/client/app/auth/views/form.vue @@ -0,0 +1,141 @@ +<template> +<div class="form"> + <header> + <h1><i>{{ app.name }}</i>があなたのアカウントにアクセスすることを<b>許可</b>しますか?</h1> + <img :src="`${app.icon_url}?thumbnail&size=64`"/> + </header> + <div class="app"> + <section> + <h2>{{ app.name }}</h2> + <p class="nid">{{ app.nameId }}</p> + <p class="description">{{ app.description }}</p> + </section> + <section> + <h2>このアプリは次の権限を要求しています:</h2> + <ul> + <template v-for="p in app.permission"> + <li v-if="p == 'account-read'">アカウントの情報を見る。</li> + <li v-if="p == 'account-write'">アカウントの情報を操作する。</li> + <li v-if="p == 'post-write'">投稿する。</li> + <li v-if="p == 'like-write'">いいねしたりいいね解除する。</li> + <li v-if="p == 'following-write'">フォローしたりフォロー解除する。</li> + <li v-if="p == 'drive-read'">ドライブを見る。</li> + <li v-if="p == 'drive-write'">ドライブを操作する。</li> + <li v-if="p == 'notification-read'">通知を見る。</li> + <li v-if="p == 'notification-write'">通知を操作する。</li> + </template> + </ul> + </section> + </div> + <div class="action"> + <button @click="cancel">キャンセル</button> + <button @click="accept">アクセスを許可</button> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['session'], + computed: { + app(): any { + return this.session.app; + } + }, + methods: { + cancel() { + (this as any).api('auth/deny', { + token: this.session.token + }).then(() => { + this.$emit('denied'); + }); + }, + + accept() { + (this as any).api('auth/accept', { + token: this.session.token + }).then(() => { + this.$emit('accepted'); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.form + + > header + > h1 + margin 0 + padding 32px 32px 20px 32px + font-size 24px + font-weight normal + color #777 + + i + color #77aeca + + &:before + content '「' + + &:after + content '」' + + b + color #666 + + > img + display block + z-index 1 + width 84px + height 84px + margin 0 auto -38px auto + border solid 5px #fff + border-radius 100% + box-shadow 0 2px 2px rgba(0, 0, 0, 0.1) + + > .app + padding 44px 16px 0 16px + color #555 + background #eee + box-shadow 0 2px 2px rgba(0, 0, 0, 0.1) inset + + &:after + content '' + display block + clear both + + > section + float left + width 50% + padding 8px + text-align left + + > h2 + margin 0 + font-size 16px + color #777 + + > .action + padding 16px + + > button + margin 0 8px + padding 0 + + @media (max-width 600px) + > header + > img + box-shadow none + + > .app + box-shadow none + + @media (max-width 500px) + > header + > h1 + font-size 16px + +</style> diff --git a/src/client/app/auth/views/index.vue b/src/client/app/auth/views/index.vue new file mode 100644 index 0000000000..e1e1b265e1 --- /dev/null +++ b/src/client/app/auth/views/index.vue @@ -0,0 +1,149 @@ +<template> +<div class="index"> + <main v-if="os.isSignedIn"> + <p class="fetching" v-if="fetching">読み込み中<mk-ellipsis/></p> + <x-form + ref="form" + v-if="state == 'waiting'" + :session="session" + @denied="state = 'denied'" + @accepted="accepted" + /> + <div class="denied" v-if="state == 'denied'"> + <h1>アプリケーションの連携をキャンセルしました。</h1> + <p>このアプリがあなたのアカウントにアクセスすることはありません。</p> + </div> + <div class="accepted" v-if="state == 'accepted'"> + <h1>{{ session.app.isAuthorized ? 'このアプリは既に連携済みです' : 'アプリケーションの連携を許可しました' }}</h1> + <p v-if="session.app.callbackUrl">アプリケーションに戻っています<mk-ellipsis/></p> + <p v-if="!session.app.callbackUrl">アプリケーションに戻って、やっていってください。</p> + </div> + <div class="error" v-if="state == 'fetch-session-error'"> + <p>セッションが存在しません。</p> + </div> + </main> + <main class="signin" v-if="!os.isSignedIn"> + <h1>サインインしてください</h1> + <mk-signin/> + </main> + <footer><img src="/assets/auth/logo.svg" alt="Misskey"/></footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XForm from './form.vue'; + +export default Vue.extend({ + components: { + XForm + }, + data() { + return { + state: null, + session: null, + fetching: true + }; + }, + computed: { + token(): string { + return this.$route.params.token; + } + }, + mounted() { + if (!this.$root.$data.os.isSignedIn) return; + + // Fetch session + (this as any).api('auth/session/show', { + token: this.token + }).then(session => { + this.session = session; + this.fetching = false; + + // 既に連携していた場合 + if (this.session.app.isAuthorized) { + this.$root.$data.os.api('auth/accept', { + token: this.session.token + }).then(() => { + this.accepted(); + }); + } else { + this.state = 'waiting'; + } + }).catch(error => { + this.state = 'fetch-session-error'; + }); + }, + methods: { + accepted() { + this.state = 'accepted'; + if (this.session.app.callbackUrl) { + location.href = this.session.app.callbackUrl + '?token=' + this.session.token; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.index + + > main + width 100% + max-width 500px + margin 0 auto + text-align center + background #fff + box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) + + > .fetching + margin 0 + padding 32px + color #555 + + > div + padding 64px + + > h1 + margin 0 0 8px 0 + padding 0 + font-size 20px + font-weight normal + + > p + margin 0 + color #555 + + &.denied > h1 + color #e65050 + + &.accepted > h1 + color #54af7c + + &.signin + padding 32px 32px 16px 32px + + > h1 + margin 0 0 22px 0 + padding 0 + font-size 20px + font-weight normal + color #555 + + @media (max-width 600px) + max-width none + box-shadow none + + @media (max-width 500px) + > div + > h1 + font-size 16px + + > footer + > img + display block + width 64px + height 64px + margin 0 auto + +</style> diff --git a/src/client/app/base.pug b/src/client/app/base.pug new file mode 100644 index 0000000000..32a95a6c99 --- /dev/null +++ b/src/client/app/base.pug @@ -0,0 +1,38 @@ +doctype html + +!= '\n<!-- Thank you for using Misskey! @syuilo -->\n' + +html + + head + meta(charset='utf-8') + meta(name='application-name' content='Misskey') + meta(name='theme-color' content=themeColor) + meta(name='referrer' content='origin') + link(rel='manifest' href='/manifest.json') + + title Misskey + + style + include ./../../../built/client/assets/init.css + script + include ./../../../built/client/assets/boot.js + + script + include ./../../../built/client/assets/safe.js + + //- FontAwesome style + style #{facss} + + //- highlight.js style + style #{hljscss} + + body + noscript: p + | JavaScriptを有効にしてください + br + | Please turn on your JavaScript + div#ini: p + span . + span . + span . diff --git a/src/client/app/boot.js b/src/client/app/boot.js new file mode 100644 index 0000000000..0846e4bd55 --- /dev/null +++ b/src/client/app/boot.js @@ -0,0 +1,120 @@ +/** + * MISSKEY BOOT LOADER + * (ENTRY POINT) + */ + +/** + * ドメインに基づいて適切なスクリプトを読み込みます。 + * ユーザーの言語およびモバイル端末か否かも考慮します。 + * webpackは介さないためrequireやimportは使えません。 + */ + +'use strict'; + +// Chromeで確認したことなのですが、constやletを用いたとしても +// グローバルなスコープで定数/変数を定義するとwindowのプロパティ +// としてそれがアクセスできるようになる訳ではありませんが、普通に +// コンソールから定数/変数名を入力するとアクセスできてしまいます。 +// ブロック内に入れてスコープを非グローバル化するとそれが防げます +// (Chrome以外のブラウザでは検証していません) +{ + // Get the current url information + const url = new URL(location.href); + + //#region Detect app name + let app = null; + + if (url.pathname == '/docs') app = 'docs'; + if (url.pathname == '/dev') app = 'dev'; + if (url.pathname == '/auth') app = 'auth'; + //#endregion + + // Detect the user language + // Note: The default language is English + let lang = navigator.language.split('-')[0]; + if (!/^(en|ja)$/.test(lang)) lang = 'en'; + if (localStorage.getItem('lang')) lang = localStorage.getItem('lang'); + if (ENV != 'production') lang = 'ja'; + + // Detect the user agent + const ua = navigator.userAgent.toLowerCase(); + const isMobile = /mobile|iphone|ipad|android/.test(ua); + + // Get the <head> element + const head = document.getElementsByTagName('head')[0]; + + // If mobile, insert the viewport meta tag + if (isMobile) { + const meta = document.createElement('meta'); + meta.setAttribute('name', 'viewport'); + meta.setAttribute('content', + 'width=device-width,' + + 'initial-scale=1,' + + 'minimum-scale=1,' + + 'maximum-scale=1,' + + 'user-scalable=no'); + head.appendChild(meta); + } + + // Switch desktop or mobile version + if (app == null) { + app = isMobile ? 'mobile' : 'desktop'; + } + + // Script version + const ver = localStorage.getItem('v') || VERSION; + + // Whether in debug mode + const isDebug = localStorage.getItem('debug') == 'true'; + + // Whether use raw version script + const raw = (localStorage.getItem('useRawScript') == 'true' && isDebug) + || ENV != 'production'; + + // Load an app script + // Note: 'async' make it possible to load the script asyncly. + // 'defer' make it possible to run the script when the dom loaded. + const script = document.createElement('script'); + script.setAttribute('src', `/assets/${app}.${ver}.${lang}.${raw ? 'raw' : 'min'}.js`); + script.setAttribute('async', 'true'); + script.setAttribute('defer', 'true'); + head.appendChild(script); + + // 1秒経ってもスクリプトがロードされない場合はバージョンが古くて + // 404になっているせいかもしれないので、バージョンを確認して古ければ更新する + // + // 読み込まれたスクリプトからこのタイマーを解除できるように、 + // グローバルにタイマーIDを代入しておく + window.mkBootTimer = window.setTimeout(async () => { + // Fetch meta + const res = await fetch(API + '/meta', { + method: 'POST', + cache: 'no-cache' + }); + + // Parse + const meta = await res.json(); + + // Compare versions + if (meta.version != ver) { + alert( + 'Misskeyの新しいバージョンがあります。ページを再度読み込みします。' + + '\n\n' + + 'New version of Misskey available. The page will be reloaded.'); + + // Clear cache (serive worker) + try { + navigator.serviceWorker.controller.postMessage('clear'); + + navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => registration.unregister()); + }); + } catch (e) { + console.error(e); + } + + // Force reload + location.reload(true); + } + }, 1000); +} diff --git a/src/client/app/ch/script.ts b/src/client/app/ch/script.ts new file mode 100644 index 0000000000..4c6b6dfd1b --- /dev/null +++ b/src/client/app/ch/script.ts @@ -0,0 +1,15 @@ +/** + * Channels + */ + +// Style +import './style.styl'; + +require('./tags'); +import init from '../init'; + +/** + * init + */ +init(() => { +}); diff --git a/src/client/app/ch/style.styl b/src/client/app/ch/style.styl new file mode 100644 index 0000000000..21ca648cbe --- /dev/null +++ b/src/client/app/ch/style.styl @@ -0,0 +1,10 @@ +@import "../app" + +html + padding 8px + background #efefef + +#wait + top auto + bottom 15px + left 15px diff --git a/src/client/app/ch/tags/channel.tag b/src/client/app/ch/tags/channel.tag new file mode 100644 index 0000000000..2abfb106a5 --- /dev/null +++ b/src/client/app/ch/tags/channel.tag @@ -0,0 +1,403 @@ +<mk-channel> + <mk-header/> + <hr> + <main v-if="!fetching"> + <h1>{ channel.title }</h1> + + <div v-if="$root.$data.os.isSignedIn"> + <p v-if="channel.isWatching">このチャンネルをウォッチしています <a @click="unwatch">ウォッチ解除</a></p> + <p v-if="!channel.isWatching"><a @click="watch">このチャンネルをウォッチする</a></p> + </div> + + <div class="share"> + <mk-twitter-button/> + <mk-line-button/> + </div> + + <div class="body"> + <p v-if="postsFetching">読み込み中<mk-ellipsis/></p> + <div v-if="!postsFetching"> + <p v-if="posts == null || posts.length == 0">まだ投稿がありません</p> + <template v-if="posts != null"> + <mk-channel-post each={ post in posts.slice().reverse() } post={ post } form={ parent.refs.form }/> + </template> + </div> + </div> + <hr> + <mk-channel-form v-if="$root.$data.os.isSignedIn" channel={ channel } ref="form"/> + <div v-if="!$root.$data.os.isSignedIn"> + <p>参加するには<a href={ _URL_ }>ログインまたは新規登録</a>してください</p> + </div> + <hr> + <footer> + <small><a href={ _URL_ }>Misskey</a> ver { _VERSION_ } (葵 aoi)</small> + </footer> + </main> + <style lang="stylus" scoped> + :scope + display block + + > main + > h1 + font-size 1.5em + color #f00 + + > .share + > * + margin-right 4px + + > .body + margin 8px 0 0 0 + + > mk-channel-form + max-width 500px + + </style> + <script lang="typescript"> + import Progress from '../../common/scripts/loading'; + import ChannelStream from '../../common/scripts/streaming/channel-stream'; + + this.mixin('i'); + this.mixin('api'); + + this.id = this.opts.id; + this.fetching = true; + this.postsFetching = true; + this.channel = null; + this.posts = null; + this.connection = new ChannelStream(this.id); + this.unreadCount = 0; + + this.on('mount', () => { + document.documentElement.style.background = '#efefef'; + + Progress.start(); + + let fetched = false; + + // チャンネル概要読み込み + this.$root.$data.os.api('channels/show', { + channelId: this.id + }).then(channel => { + if (fetched) { + Progress.done(); + } else { + Progress.set(0.5); + fetched = true; + } + + this.update({ + fetching: false, + channel: channel + }); + + document.title = channel.title + ' | Misskey' + }); + + // 投稿読み込み + this.$root.$data.os.api('channels/posts', { + channelId: this.id + }).then(posts => { + if (fetched) { + Progress.done(); + } else { + Progress.set(0.5); + fetched = true; + } + + this.update({ + postsFetching: false, + posts: posts + }); + }); + + this.connection.on('post', this.onPost); + document.addEventListener('visibilitychange', this.onVisibilitychange, false); + }); + + this.on('unmount', () => { + this.connection.off('post', this.onPost); + this.connection.close(); + document.removeEventListener('visibilitychange', this.onVisibilitychange); + }); + + this.onPost = post => { + this.posts.unshift(post); + this.update(); + + if (document.hidden && this.$root.$data.os.isSignedIn && post.userId !== this.$root.$data.os.i.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${this.channel.title} | Misskey`; + } + }; + + this.onVisibilitychange = () => { + if (!document.hidden) { + this.unreadCount = 0; + document.title = this.channel.title + ' | Misskey' + } + }; + + this.watch = () => { + this.$root.$data.os.api('channels/watch', { + channelId: this.id + }).then(() => { + this.channel.isWatching = true; + this.update(); + }, e => { + alert('error'); + }); + }; + + this.unwatch = () => { + this.$root.$data.os.api('channels/unwatch', { + channelId: this.id + }).then(() => { + this.channel.isWatching = false; + this.update(); + }, e => { + alert('error'); + }); + }; + </script> +</mk-channel> + +<mk-channel-post> + <header> + <a class="index" @click="reply">{ post.index }:</a> + <a class="name" href={ _URL_ + '/@' + acct }><b>{ post.user.name }</b></a> + <mk-time time={ post.createdAt }/> + <mk-time time={ post.createdAt } mode="detail"/> + <span>ID:<i>{ acct }</i></span> + </header> + <div> + <a v-if="post.reply">>>{ post.reply.index }</a> + { post.text } + <div class="media" v-if="post.media"> + <template each={ file in post.media }> + <a href={ file.url } target="_blank"> + <img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/> + </a> + </template> + </div> + </div> + <style lang="stylus" scoped> + :scope + display block + margin 0 + padding 0 + + > header + position -webkit-sticky + position sticky + z-index 1 + top 0 + background rgba(239, 239, 239, 0.9) + + > .index + margin-right 0.25em + color #000 + + > .name + margin-right 0.5em + color #008000 + + > mk-time + margin-right 0.5em + + &:first-of-type + display none + + @media (max-width 600px) + > mk-time + &:first-of-type + display initial + + &:last-of-type + display none + + > div + padding 0 0 1em 2em + + > .media + > a + display inline-block + + > img + max-width 100% + vertical-align bottom + + </style> + <script lang="typescript"> + import getAcct from '../../../../common/user/get-acct'; + + this.post = this.opts.post; + this.form = this.opts.form; + this.acct = getAcct(this.post.user); + + this.reply = () => { + this.form.update({ + reply: this.post + }); + }; + </script> +</mk-channel-post> + +<mk-channel-form> + <p v-if="reply"><b>>>{ reply.index }</b> ({ reply.user.name }): <a @click="clearReply">[x]</a></p> + <textarea ref="text" disabled={ wait } oninput={ update } onkeydown={ onkeydown } onpaste={ onpaste } placeholder="%i18n:ch.tags.mk-channel-form.textarea%"></textarea> + <div class="actions"> + <button @click="selectFile">%fa:upload%%i18n:ch.tags.mk-channel-form.upload%</button> + <button @click="drive">%fa:cloud%%i18n:ch.tags.mk-channel-form.drive%</button> + <button :class="{ wait: wait }" ref="submit" disabled={ wait || (refs.text.value.length == 0) } @click="post"> + <template v-if="!wait">%fa:paper-plane%</template>{ wait ? '%i18n:ch.tags.mk-channel-form.posting%' : '%i18n:ch.tags.mk-channel-form.post%' }<mk-ellipsis v-if="wait"/> + </button> + </div> + <mk-uploader ref="uploader"/> + <ol v-if="files"> + <li each={ files }>{ name }</li> + </ol> + <input ref="file" type="file" accept="image/*" multiple="multiple" onchange={ changeFile }/> + <style lang="stylus" scoped> + :scope + display block + + > textarea + width 100% + max-width 100% + min-width 100% + min-height 5em + + > .actions + display flex + + > button + > [data-fa] + margin-right 0.25em + + &:last-child + margin-left auto + + &.wait + cursor wait + + > input[type='file'] + display none + + </style> + <script lang="typescript"> + this.mixin('api'); + + this.channel = this.opts.channel; + this.files = null; + + this.on('mount', () => { + this.$refs.uploader.on('uploaded', file => { + this.update({ + files: [file] + }); + }); + }); + + this.upload = file => { + this.$refs.uploader.upload(file); + }; + + this.clearReply = () => { + this.update({ + reply: null + }); + }; + + this.clear = () => { + this.clearReply(); + this.update({ + files: null + }); + this.$refs.text.value = ''; + }; + + this.post = () => { + this.update({ + wait: true + }); + + const files = this.files && this.files.length > 0 + ? this.files.map(f => f.id) + : undefined; + + this.$root.$data.os.api('posts/create', { + text: this.$refs.text.value == '' ? undefined : this.$refs.text.value, + mediaIds: files, + replyId: this.reply ? this.reply.id : undefined, + channelId: this.channel.id + }).then(data => { + this.clear(); + }).catch(err => { + alert('失敗した'); + }).then(() => { + this.update({ + wait: false + }); + }); + }; + + this.changeFile = () => { + Array.from(this.$refs.file.files).forEach(this.upload); + }; + + this.selectFile = () => { + this.$refs.file.click(); + }; + + this.drive = () => { + window['cb'] = files => { + this.update({ + files: files + }); + }; + + window.open(_URL_ + '/selectdrive?multiple=true', + 'drive_window', + 'height=500,width=800'); + }; + + this.onkeydown = e => { + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); + }; + + this.onpaste = e => { + Array.from(e.clipboardData.items).forEach(item => { + if (item.kind == 'file') { + this.upload(item.getAsFile()); + } + }); + }; + </script> +</mk-channel-form> + +<mk-twitter-button> + <a href="https://twitter.com/share?ref_src=twsrc%5Etfw" class="twitter-share-button" data-show-count="false">Tweet</a> + <script lang="typescript"> + this.on('mount', () => { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://platform.twitter.com/widgets.js'); + script.setAttribute('async', 'async'); + head.appendChild(script); + }); + </script> +</mk-twitter-button> + +<mk-line-button> + <div class="line-it-button" data-lang="ja" data-type="share-a" data-url={ _CH_URL_ } style="display: none;"></div> + <script lang="typescript"> + this.on('mount', () => { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://d.line-scdn.net/r/web/social-plugin/js/thirdparty/loader.min.js'); + script.setAttribute('async', 'async'); + head.appendChild(script); + }); + </script> +</mk-line-button> diff --git a/src/client/app/ch/tags/header.tag b/src/client/app/ch/tags/header.tag new file mode 100644 index 0000000000..901123d63b --- /dev/null +++ b/src/client/app/ch/tags/header.tag @@ -0,0 +1,20 @@ +<mk-header> + <div> + <a href={ _CH_URL_ }>Index</a> | <a href={ _URL_ }>Misskey</a> + </div> + <div> + <a v-if="!$root.$data.os.isSignedIn" href={ _URL_ }>ログイン(新規登録)</a> + <a v-if="$root.$data.os.isSignedIn" href={ _URL_ + '/@' + I.username }>{ I.username }</a> + </div> + <style lang="stylus" scoped> + :scope + display flex + + > div:last-child + margin-left auto + + </style> + <script lang="typescript"> + this.mixin('i'); + </script> +</mk-header> diff --git a/src/client/app/ch/tags/index.tag b/src/client/app/ch/tags/index.tag new file mode 100644 index 0000000000..88df2ec45d --- /dev/null +++ b/src/client/app/ch/tags/index.tag @@ -0,0 +1,37 @@ +<mk-index> + <mk-header/> + <hr> + <button @click="n">%i18n:ch.tags.mk-index.new%</button> + <hr> + <ul v-if="channels"> + <li each={ channels }><a href={ '/' + this.id }>{ this.title }</a></li> + </ul> + <style lang="stylus" scoped> + :scope + display block + + </style> + <script lang="typescript"> + this.mixin('api'); + + this.on('mount', () => { + this.$root.$data.os.api('channels', { + limit: 100 + }).then(channels => { + this.update({ + channels: channels + }); + }); + }); + + this.n = () => { + const title = window.prompt('%i18n:ch.tags.mk-index.channel-title%'); + + this.$root.$data.os.api('channels/create', { + title: title + }).then(channel => { + location.href = '/' + channel.id; + }); + }; + </script> +</mk-index> diff --git a/src/client/app/ch/tags/index.ts b/src/client/app/ch/tags/index.ts new file mode 100644 index 0000000000..12ffdaeb84 --- /dev/null +++ b/src/client/app/ch/tags/index.ts @@ -0,0 +1,3 @@ +require('./index.tag'); +require('./channel.tag'); +require('./header.tag'); diff --git a/src/client/app/common/define-widget.ts b/src/client/app/common/define-widget.ts new file mode 100644 index 0000000000..27db59b5ee --- /dev/null +++ b/src/client/app/common/define-widget.ts @@ -0,0 +1,79 @@ +import Vue from 'vue'; + +export default function<T extends object>(data: { + name: string; + props?: () => T; +}) { + return Vue.extend({ + props: { + widget: { + type: Object + }, + isMobile: { + type: Boolean, + default: false + }, + isCustomizeMode: { + type: Boolean, + default: false + } + }, + computed: { + id(): string { + return this.widget.id; + } + }, + data() { + return { + props: data.props ? data.props() : {} as T, + bakedOldProps: null, + preventSave: false + }; + }, + created() { + if (this.props) { + Object.keys(this.props).forEach(prop => { + if (this.widget.data.hasOwnProperty(prop)) { + this.props[prop] = this.widget.data[prop]; + } + }); + } + + this.bakeProps(); + + this.$watch('props', newProps => { + if (this.preventSave) { + this.preventSave = false; + this.bakeProps(); + return; + } + if (this.bakedOldProps == JSON.stringify(newProps)) return; + + this.bakeProps(); + + if (this.isMobile) { + (this as any).api('i/update_mobile_home', { + id: this.id, + data: newProps + }).then(() => { + (this as any).os.i.account.clientSettings.mobile_home.find(w => w.id == this.id).data = newProps; + }); + } else { + (this as any).api('i/update_home', { + id: this.id, + data: newProps + }).then(() => { + (this as any).os.i.account.clientSettings.home.find(w => w.id == this.id).data = newProps; + }); + } + }, { + deep: true + }); + }, + methods: { + bakeProps() { + this.bakedOldProps = JSON.stringify(this.props); + } + } + }); +} diff --git a/src/client/app/common/mios.ts b/src/client/app/common/mios.ts new file mode 100644 index 0000000000..bcb8b60678 --- /dev/null +++ b/src/client/app/common/mios.ts @@ -0,0 +1,578 @@ +import Vue from 'vue'; +import { EventEmitter } from 'eventemitter3'; +import * as merge from 'object-assign-deep'; +import * as uuid from 'uuid'; + +import { hostname, apiUrl, swPublickey, version, lang, googleMapsApiKey } from '../config'; +import Progress from './scripts/loading'; +import Connection from './scripts/streaming/stream'; +import { HomeStreamManager } from './scripts/streaming/home'; +import { DriveStreamManager } from './scripts/streaming/drive'; +import { ServerStreamManager } from './scripts/streaming/server'; +import { RequestsStreamManager } from './scripts/streaming/requests'; +import { MessagingIndexStreamManager } from './scripts/streaming/messaging-index'; +import { OthelloStreamManager } from './scripts/streaming/othello'; + +import Err from '../common/views/components/connect-failed.vue'; + +//#region api requests +let spinner = null; +let pending = 0; +//#endregion + +export type API = { + chooseDriveFile: (opts: { + title?: string; + currentFolder?: any; + multiple?: boolean; + }) => Promise<any>; + + chooseDriveFolder: (opts: { + title?: string; + currentFolder?: any; + }) => Promise<any>; + + dialog: (opts: { + title: string; + text: string; + actions?: Array<{ + text: string; + id?: string; + }>; + }) => Promise<string>; + + input: (opts: { + title: string; + placeholder?: string; + default?: string; + }) => Promise<string>; + + post: (opts?: { + reply?: any; + repost?: any; + }) => void; + + notify: (message: string) => void; +}; + +/** + * Misskey Operating System + */ +export default class MiOS extends EventEmitter { + /** + * Misskeyの /meta で取得できるメタ情報 + */ + private meta: { + data: { [x: string]: any }; + chachedAt: Date; + }; + + private isMetaFetching = false; + + public app: Vue; + + public new(vm, props) { + const w = new vm({ + parent: this.app, + propsData: props + }).$mount(); + document.body.appendChild(w.$el); + } + + /** + * A signing user + */ + public i: { [x: string]: any }; + + /** + * Whether signed in + */ + public get isSignedIn() { + return this.i != null; + } + + /** + * Whether is debug mode + */ + public get debug() { + return localStorage.getItem('debug') == 'true'; + } + + /** + * Whether enable sounds + */ + public get isEnableSounds() { + return localStorage.getItem('enableSounds') == 'true'; + } + + public apis: API; + + /** + * A connection manager of home stream + */ + public stream: HomeStreamManager; + + /** + * Connection managers + */ + public streams: { + driveStream: DriveStreamManager; + serverStream: ServerStreamManager; + requestsStream: RequestsStreamManager; + messagingIndexStream: MessagingIndexStreamManager; + othelloStream: OthelloStreamManager; + } = { + driveStream: null, + serverStream: null, + requestsStream: null, + messagingIndexStream: null, + othelloStream: null + }; + + /** + * A registration of service worker + */ + private swRegistration: ServiceWorkerRegistration = null; + + /** + * Whether should register ServiceWorker + */ + private shouldRegisterSw: boolean; + + /** + * ウィンドウシステム + */ + public windows = new WindowSystem(); + + /** + * MiOSインスタンスを作成します + * @param shouldRegisterSw ServiceWorkerを登録するかどうか + */ + constructor(shouldRegisterSw = false) { + super(); + + this.shouldRegisterSw = shouldRegisterSw; + + //#region BIND + this.log = this.log.bind(this); + this.logInfo = this.logInfo.bind(this); + this.logWarn = this.logWarn.bind(this); + this.logError = this.logError.bind(this); + this.init = this.init.bind(this); + this.api = this.api.bind(this); + this.getMeta = this.getMeta.bind(this); + this.registerSw = this.registerSw.bind(this); + //#endregion + + if (this.debug) { + (window as any).os = this; + } + } + + private googleMapsIniting = false; + + public getGoogleMaps() { + return new Promise((res, rej) => { + if ((window as any).google && (window as any).google.maps) { + res((window as any).google.maps); + } else { + this.once('init-google-maps', () => { + res((window as any).google.maps); + }); + + //#region load google maps api + if (!this.googleMapsIniting) { + this.googleMapsIniting = true; + (window as any).initGoogleMaps = () => { + this.emit('init-google-maps'); + }; + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', `https://maps.googleapis.com/maps/api/js?key=${googleMapsApiKey}&callback=initGoogleMaps`); + script.setAttribute('async', 'true'); + script.setAttribute('defer', 'true'); + head.appendChild(script); + } + //#endregion + } + }); + } + + public log(...args) { + if (!this.debug) return; + console.log.apply(null, args); + } + + public logInfo(...args) { + if (!this.debug) return; + console.info.apply(null, args); + } + + public logWarn(...args) { + if (!this.debug) return; + console.warn.apply(null, args); + } + + public logError(...args) { + if (!this.debug) return; + console.error.apply(null, args); + } + + public signout() { + localStorage.removeItem('me'); + document.cookie = `i=; domain=.${hostname}; expires=Thu, 01 Jan 1970 00:00:01 GMT;`; + location.href = '/'; + } + + /** + * Initialize MiOS (boot) + * @param callback A function that call when initialized + */ + public async init(callback) { + //#region Init stream managers + this.streams.serverStream = new ServerStreamManager(this); + this.streams.requestsStream = new RequestsStreamManager(this); + + this.once('signedin', () => { + // Init home stream manager + this.stream = new HomeStreamManager(this, this.i); + + // Init other stream manager + this.streams.driveStream = new DriveStreamManager(this, this.i); + this.streams.messagingIndexStream = new MessagingIndexStreamManager(this, this.i); + this.streams.othelloStream = new OthelloStreamManager(this, this.i); + }); + //#endregion + + // ユーザーをフェッチしてコールバックする + const fetchme = (token, cb) => { + let me = null; + + // Return when not signed in + if (token == null) { + return done(); + } + + // Fetch user + fetch(`${apiUrl}/i`, { + method: 'POST', + body: JSON.stringify({ + i: token + }) + }) + // When success + .then(res => { + // When failed to authenticate user + if (res.status !== 200) { + return this.signout(); + } + + // Parse response + res.json().then(i => { + me = i; + me.account.token = token; + done(); + }); + }) + // When failure + .catch(() => { + // Render the error screen + document.body.innerHTML = '<div id="err"></div>'; + new Vue({ + render: createEl => createEl(Err) + }).$mount('#err'); + + Progress.done(); + }); + + function done() { + if (cb) cb(me); + } + }; + + // フェッチが完了したとき + const fetched = me => { + if (me) { + // デフォルトの設定をマージ + me.account.clientSettings = Object.assign({ + fetchOnScroll: true, + showMaps: true, + showPostFormOnTopOfTl: false, + gradientWindowHeader: false + }, me.account.clientSettings); + + // ローカルストレージにキャッシュ + localStorage.setItem('me', JSON.stringify(me)); + } + + this.i = me; + + this.emit('signedin'); + + // Finish init + callback(); + + //#region Post + + // Init service worker + if (this.shouldRegisterSw) this.registerSw(); + + //#endregion + }; + + // Get cached account data + const cachedMe = JSON.parse(localStorage.getItem('me')); + + // キャッシュがあったとき + if (cachedMe) { + // とりあえずキャッシュされたデータでお茶を濁して(?)おいて、 + fetched(cachedMe); + + // 後から新鮮なデータをフェッチ + fetchme(cachedMe.account.token, freshData => { + merge(cachedMe, freshData); + }); + } else { + // Get token from cookie + const i = (document.cookie.match(/i=(!\w+)/) || [null, null])[1]; + + fetchme(i, fetched); + } + } + + /** + * Register service worker + */ + private registerSw() { + // Check whether service worker and push manager supported + const isSwSupported = + ('serviceWorker' in navigator) && ('PushManager' in window); + + // Reject when browser not service worker supported + if (!isSwSupported) return; + + // Reject when not signed in to Misskey + if (!this.isSignedIn) return; + + // When service worker activated + navigator.serviceWorker.ready.then(registration => { + this.log('[sw] ready: ', registration); + + this.swRegistration = registration; + + // Options of pushManager.subscribe + // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters + const opts = { + // A boolean indicating that the returned push subscription + // will only be used for messages whose effect is made visible to the user. + userVisibleOnly: true, + + // A public key your push server will use to send + // messages to client apps via a push server. + applicationServerKey: urlBase64ToUint8Array(swPublickey) + }; + + // Subscribe push notification + this.swRegistration.pushManager.subscribe(opts).then(subscription => { + this.log('[sw] Subscribe OK:', subscription); + + function encode(buffer: ArrayBuffer) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); + } + + // Register + this.api('sw/register', { + endpoint: subscription.endpoint, + auth: encode(subscription.getKey('auth')), + publickey: encode(subscription.getKey('p256dh')) + }); + }) + // When subscribe failed + .catch(async (err: Error) => { + this.logError('[sw] Subscribe Error:', err); + + // 通知が許可されていなかったとき + if (err.name == 'NotAllowedError') { + this.logError('[sw] Subscribe failed due to notification not allowed'); + return; + } + + // 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが + // 既に存在していることが原因でエラーになった可能性があるので、 + // そのサブスクリプションを解除しておく + const subscription = await this.swRegistration.pushManager.getSubscription(); + if (subscription) subscription.unsubscribe(); + }); + }); + + // Whether use raw version script + const raw = (localStorage.getItem('useRawScript') == 'true' && this.debug) + || process.env.NODE_ENV != 'production'; + + // The path of service worker script + const sw = `/sw.${version}.${lang}.${raw ? 'raw' : 'min'}.js`; + + // Register service worker + navigator.serviceWorker.register(sw).then(registration => { + // 登録成功 + this.logInfo('[sw] Registration successful with scope: ', registration.scope); + }).catch(err => { + // 登録失敗 :( + this.logError('[sw] Registration failed: ', err); + }); + } + + public requests = []; + + /** + * Misskey APIにリクエストします + * @param endpoint エンドポイント名 + * @param data パラメータ + */ + public api(endpoint: string, data: { [x: string]: any } = {}): Promise<{ [x: string]: any }> { + if (++pending === 1) { + spinner = document.createElement('div'); + spinner.setAttribute('id', 'wait'); + document.body.appendChild(spinner); + } + + // Append a credential + if (this.isSignedIn) (data as any).i = this.i.account.token; + + // TODO + //const viaStream = localStorage.getItem('enableExperimental') == 'true'; + + return new Promise((resolve, reject) => { + /*if (viaStream) { + const stream = this.stream.borrow(); + const id = Math.random().toString(); + stream.once(`api-res:${id}`, res => { + resolve(res); + }); + stream.send({ + type: 'api', + id, + endpoint, + data + }); + } else {*/ + const req = { + id: uuid(), + date: new Date(), + name: endpoint, + data, + res: null, + status: null + }; + + if (this.debug) { + this.requests.push(req); + } + + // Send request + fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: endpoint === 'signin' ? 'include' : 'omit', + cache: 'no-cache' + }).then(async (res) => { + if (--pending === 0) spinner.parentNode.removeChild(spinner); + + const body = res.status === 204 ? null : await res.json(); + + if (this.debug) { + req.status = res.status; + req.res = body; + } + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + /*}*/ + }); + } + + /** + * Misskeyのメタ情報を取得します + * @param force キャッシュを無視するか否か + */ + public getMeta(force = false) { + return new Promise<{ [x: string]: any }>(async (res, rej) => { + if (this.isMetaFetching) { + this.once('_meta_fetched_', () => { + res(this.meta.data); + }); + return; + } + + const expire = 1000 * 60; // 1min + + // forceが有効, meta情報を保持していない or 期限切れ + if (force || this.meta == null || Date.now() - this.meta.chachedAt.getTime() > expire) { + this.isMetaFetching = true; + const meta = await this.api('meta'); + this.meta = { + data: meta, + chachedAt: new Date() + }; + this.isMetaFetching = false; + this.emit('_meta_fetched_'); + res(meta); + } else { + res(this.meta.data); + } + }); + } + + public connections: Connection[] = []; + + public registerStreamConnection(connection: Connection) { + this.connections.push(connection); + } + + public unregisterStreamConnection(connection: Connection) { + this.connections = this.connections.filter(c => c != connection); + } +} + +class WindowSystem extends EventEmitter { + public windows = new Set(); + + public add(window) { + this.windows.add(window); + this.emit('added', window); + } + + public remove(window) { + this.windows.delete(window); + this.emit('removed', window); + } + + public getAll() { + return this.windows; + } +} + +/** + * Convert the URL safe base64 string to a Uint8Array + * @param base64String base64 string + */ +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} diff --git a/src/client/app/common/scripts/check-for-update.ts b/src/client/app/common/scripts/check-for-update.ts new file mode 100644 index 0000000000..81c1eb9812 --- /dev/null +++ b/src/client/app/common/scripts/check-for-update.ts @@ -0,0 +1,33 @@ +import MiOS from '../mios'; +import { version as current } from '../../config'; + +export default async function(mios: MiOS, force = false, silent = false) { + const meta = await mios.getMeta(force); + const newer = meta.version; + + if (newer != current) { + localStorage.setItem('should-refresh', 'true'); + localStorage.setItem('v', newer); + + // Clear cache (serive worker) + try { + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage('clear'); + } + + navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => registration.unregister()); + }); + } catch (e) { + console.error(e); + } + + if (!silent) { + alert('%i18n:common.update-available%'.replace('{newer}', newer).replace('{current}', current)); + } + + return newer; + } else { + return null; + } +} diff --git a/src/client/app/common/scripts/compose-notification.ts b/src/client/app/common/scripts/compose-notification.ts new file mode 100644 index 0000000000..273579cbc6 --- /dev/null +++ b/src/client/app/common/scripts/compose-notification.ts @@ -0,0 +1,67 @@ +import getPostSummary from '../../../../common/get-post-summary'; +import getReactionEmoji from '../../../../common/get-reaction-emoji'; + +type Notification = { + title: string; + body: string; + icon: string; + onclick?: any; +}; + +// TODO: i18n + +export default function(type, data): Notification { + switch (type) { + case 'drive_file_created': + return { + title: 'ファイルがアップロードされました', + body: data.name, + icon: data.url + '?thumbnail&size=64' + }; + + case 'mention': + return { + title: `${data.user.name}さんから:`, + body: getPostSummary(data), + icon: data.user.avatarUrl + '?thumbnail&size=64' + }; + + case 'reply': + return { + title: `${data.user.name}さんから返信:`, + body: getPostSummary(data), + icon: data.user.avatarUrl + '?thumbnail&size=64' + }; + + case 'quote': + return { + title: `${data.user.name}さんが引用:`, + body: getPostSummary(data), + icon: data.user.avatarUrl + '?thumbnail&size=64' + }; + + case 'reaction': + return { + title: `${data.user.name}: ${getReactionEmoji(data.reaction)}:`, + body: getPostSummary(data.post), + icon: data.user.avatarUrl + '?thumbnail&size=64' + }; + + case 'unread_messaging_message': + return { + title: `${data.user.name}さんからメッセージ:`, + body: data.text, // TODO: getMessagingMessageSummary(data), + icon: data.user.avatarUrl + '?thumbnail&size=64' + }; + + case 'othello_invited': + return { + title: '対局への招待があります', + body: `${data.parent.name}さんから`, + icon: data.parent.avatarUrl + '?thumbnail&size=64' + }; + + default: + return null; + } +} diff --git a/src/client/app/common/scripts/contains.ts b/src/client/app/common/scripts/contains.ts new file mode 100644 index 0000000000..a5071b3f25 --- /dev/null +++ b/src/client/app/common/scripts/contains.ts @@ -0,0 +1,8 @@ +export default (parent, child) => { + let node = child.parentNode; + while (node) { + if (node == parent) return true; + node = node.parentNode; + } + return false; +}; diff --git a/src/client/app/common/scripts/copy-to-clipboard.ts b/src/client/app/common/scripts/copy-to-clipboard.ts new file mode 100644 index 0000000000..3d2741f8d7 --- /dev/null +++ b/src/client/app/common/scripts/copy-to-clipboard.ts @@ -0,0 +1,13 @@ +/** + * Clipboardに値をコピー(TODO: 文字列以外も対応) + */ +export default val => { + const form = document.createElement('textarea'); + form.textContent = val; + document.body.appendChild(form); + form.select(); + const result = document.execCommand('copy'); + document.body.removeChild(form); + + return result; +}; diff --git a/src/client/app/common/scripts/date-stringify.ts b/src/client/app/common/scripts/date-stringify.ts new file mode 100644 index 0000000000..e51de8833d --- /dev/null +++ b/src/client/app/common/scripts/date-stringify.ts @@ -0,0 +1,13 @@ +export default date => { + if (typeof date == 'string') date = new Date(date); + return ( + date.getFullYear() + '年' + + (date.getMonth() + 1) + '月' + + date.getDate() + '日' + + ' ' + + date.getHours() + '時' + + date.getMinutes() + '分' + + ' ' + + `(${['日', '月', '火', '水', '木', '金', '土'][date.getDay()]})` + ); +}; diff --git a/src/client/app/common/scripts/fuck-ad-block.ts b/src/client/app/common/scripts/fuck-ad-block.ts new file mode 100644 index 0000000000..9bcf7deeff --- /dev/null +++ b/src/client/app/common/scripts/fuck-ad-block.ts @@ -0,0 +1,21 @@ +require('fuckadblock'); + +declare const fuckAdBlock: any; + +export default (os) => { + function adBlockDetected() { + os.apis.dialog({ + title: '%fa:exclamation-triangle%広告ブロッカーを無効にしてください', + text: '<strong>Misskeyは広告を掲載していません</strong>が、広告をブロックする機能が有効だと一部の機能が利用できなかったり、不具合が発生する場合があります。', + actins: [{ + text: 'OK' + }] + }); + } + + if (fuckAdBlock === undefined) { + adBlockDetected(); + } else { + fuckAdBlock.onDetected(adBlockDetected); + } +}; diff --git a/src/client/app/common/scripts/gcd.ts b/src/client/app/common/scripts/gcd.ts new file mode 100644 index 0000000000..9a19f9da66 --- /dev/null +++ b/src/client/app/common/scripts/gcd.ts @@ -0,0 +1,2 @@ +const gcd = (a, b) => !b ? a : gcd(b, a % b); +export default gcd; diff --git a/src/client/app/common/scripts/get-kao.ts b/src/client/app/common/scripts/get-kao.ts new file mode 100644 index 0000000000..2168c5be88 --- /dev/null +++ b/src/client/app/common/scripts/get-kao.ts @@ -0,0 +1,5 @@ +export default () => [ + '(=^・・^=)', + 'v(‘ω’)v', + '🐡( \'-\' 🐡 )フグパンチ!!!!' +][Math.floor(Math.random() * 3)]; diff --git a/src/client/app/common/scripts/get-median.ts b/src/client/app/common/scripts/get-median.ts new file mode 100644 index 0000000000..91a415d5b2 --- /dev/null +++ b/src/client/app/common/scripts/get-median.ts @@ -0,0 +1,11 @@ +/** + * 中央値を求めます + * @param samples サンプル + */ +export default function(samples) { + if (!samples.length) return 0; + const numbers = samples.slice(0).sort((a, b) => a - b); + const middle = Math.floor(numbers.length / 2); + const isEven = numbers.length % 2 === 0; + return isEven ? (numbers[middle] + numbers[middle - 1]) / 2 : numbers[middle]; +} diff --git a/src/client/app/common/scripts/loading.ts b/src/client/app/common/scripts/loading.ts new file mode 100644 index 0000000000..c48e626648 --- /dev/null +++ b/src/client/app/common/scripts/loading.ts @@ -0,0 +1,21 @@ +const NProgress = require('nprogress'); +NProgress.configure({ + trickleSpeed: 500, + showSpinner: false +}); + +const root = document.getElementsByTagName('html')[0]; + +export default { + start: () => { + root.classList.add('progress'); + NProgress.start(); + }, + done: () => { + root.classList.remove('progress'); + NProgress.done(); + }, + set: val => { + NProgress.set(val); + } +}; diff --git a/src/client/app/common/scripts/parse-search-query.ts b/src/client/app/common/scripts/parse-search-query.ts new file mode 100644 index 0000000000..4f09d2b93f --- /dev/null +++ b/src/client/app/common/scripts/parse-search-query.ts @@ -0,0 +1,53 @@ +export default function(qs: string) { + const q = { + text: '' + }; + + qs.split(' ').forEach(x => { + if (/^([a-z_]+?):(.+?)$/.test(x)) { + const [key, value] = x.split(':'); + switch (key) { + case 'user': + q['includeUserUsernames'] = value.split(','); + break; + case 'exclude_user': + q['excludeUserUsernames'] = value.split(','); + break; + case 'follow': + q['following'] = value == 'null' ? null : value == 'true'; + break; + case 'reply': + q['reply'] = value == 'null' ? null : value == 'true'; + break; + case 'repost': + q['repost'] = value == 'null' ? null : value == 'true'; + break; + case 'media': + q['media'] = value == 'null' ? null : value == 'true'; + break; + case 'poll': + q['poll'] = value == 'null' ? null : value == 'true'; + break; + case 'until': + case 'since': + // YYYY-MM-DD + if (/^[0-9]+\-[0-9]+\-[0-9]+$/) { + const [yyyy, mm, dd] = value.split('-'); + q[`${key}_date`] = (new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, parseInt(dd, 10))).getTime(); + } + break; + default: + q[key] = value; + break; + } + } else { + q.text += x + ' '; + } + }); + + if (q.text) { + q.text = q.text.trim(); + } + + return q; +} diff --git a/src/client/app/common/scripts/streaming/channel.ts b/src/client/app/common/scripts/streaming/channel.ts new file mode 100644 index 0000000000..cab5f4edb4 --- /dev/null +++ b/src/client/app/common/scripts/streaming/channel.ts @@ -0,0 +1,13 @@ +import Stream from './stream'; +import MiOS from '../../mios'; + +/** + * Channel stream connection + */ +export default class Connection extends Stream { + constructor(os: MiOS, channelId) { + super(os, 'channel', { + channel: channelId + }); + } +} diff --git a/src/client/app/common/scripts/streaming/drive.ts b/src/client/app/common/scripts/streaming/drive.ts new file mode 100644 index 0000000000..f11573685e --- /dev/null +++ b/src/client/app/common/scripts/streaming/drive.ts @@ -0,0 +1,34 @@ +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Drive stream connection + */ +export class DriveStream extends Stream { + constructor(os: MiOS, me) { + super(os, 'drive', { + i: me.account.token + }); + } +} + +export class DriveStreamManager extends StreamManager<DriveStream> { + private me; + private os: MiOS; + + constructor(os: MiOS, me) { + super(); + + this.me = me; + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new DriveStream(this.os, this.me); + } + + return this.connection; + } +} diff --git a/src/client/app/common/scripts/streaming/home.ts b/src/client/app/common/scripts/streaming/home.ts new file mode 100644 index 0000000000..c198619400 --- /dev/null +++ b/src/client/app/common/scripts/streaming/home.ts @@ -0,0 +1,57 @@ +import * as merge from 'object-assign-deep'; + +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Home stream connection + */ +export class HomeStream extends Stream { + constructor(os: MiOS, me) { + super(os, '', { + i: me.account.token + }); + + // 最終利用日時を更新するため定期的にaliveメッセージを送信 + setInterval(() => { + this.send({ type: 'alive' }); + me.account.lastUsedAt = new Date(); + }, 1000 * 60); + + // 自分の情報が更新されたとき + this.on('i_updated', i => { + if (os.debug) { + console.log('I updated:', i); + } + merge(me, i); + }); + + // トークンが再生成されたとき + // このままではAPIが利用できないので強制的にサインアウトさせる + this.on('my_token_regenerated', () => { + alert('%i18n:common.my-token-regenerated%'); + os.signout(); + }); + } +} + +export class HomeStreamManager extends StreamManager<HomeStream> { + private me; + private os: MiOS; + + constructor(os: MiOS, me) { + super(); + + this.me = me; + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new HomeStream(this.os, this.me); + } + + return this.connection; + } +} diff --git a/src/client/app/common/scripts/streaming/messaging-index.ts b/src/client/app/common/scripts/streaming/messaging-index.ts new file mode 100644 index 0000000000..24f0ce0c9f --- /dev/null +++ b/src/client/app/common/scripts/streaming/messaging-index.ts @@ -0,0 +1,34 @@ +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Messaging index stream connection + */ +export class MessagingIndexStream extends Stream { + constructor(os: MiOS, me) { + super(os, 'messaging-index', { + i: me.account.token + }); + } +} + +export class MessagingIndexStreamManager extends StreamManager<MessagingIndexStream> { + private me; + private os: MiOS; + + constructor(os: MiOS, me) { + super(); + + this.me = me; + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new MessagingIndexStream(this.os, this.me); + } + + return this.connection; + } +} diff --git a/src/client/app/common/scripts/streaming/messaging.ts b/src/client/app/common/scripts/streaming/messaging.ts new file mode 100644 index 0000000000..4c593deb31 --- /dev/null +++ b/src/client/app/common/scripts/streaming/messaging.ts @@ -0,0 +1,20 @@ +import Stream from './stream'; +import MiOS from '../../mios'; + +/** + * Messaging stream connection + */ +export class MessagingStream extends Stream { + constructor(os: MiOS, me, otherparty) { + super(os, 'messaging', { + i: me.account.token, + otherparty + }); + + (this as any).on('_connected_', () => { + this.send({ + i: me.account.token + }); + }); + } +} diff --git a/src/client/app/common/scripts/streaming/othello-game.ts b/src/client/app/common/scripts/streaming/othello-game.ts new file mode 100644 index 0000000000..f34ef35147 --- /dev/null +++ b/src/client/app/common/scripts/streaming/othello-game.ts @@ -0,0 +1,11 @@ +import Stream from './stream'; +import MiOS from '../../mios'; + +export class OthelloGameStream extends Stream { + constructor(os: MiOS, me, game) { + super(os, 'othello-game', { + i: me ? me.account.token : null, + game: game.id + }); + } +} diff --git a/src/client/app/common/scripts/streaming/othello.ts b/src/client/app/common/scripts/streaming/othello.ts new file mode 100644 index 0000000000..8c6f4b9c3c --- /dev/null +++ b/src/client/app/common/scripts/streaming/othello.ts @@ -0,0 +1,31 @@ +import StreamManager from './stream-manager'; +import Stream from './stream'; +import MiOS from '../../mios'; + +export class OthelloStream extends Stream { + constructor(os: MiOS, me) { + super(os, 'othello', { + i: me.account.token + }); + } +} + +export class OthelloStreamManager extends StreamManager<OthelloStream> { + private me; + private os: MiOS; + + constructor(os: MiOS, me) { + super(); + + this.me = me; + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new OthelloStream(this.os, this.me); + } + + return this.connection; + } +} diff --git a/src/client/app/common/scripts/streaming/requests.ts b/src/client/app/common/scripts/streaming/requests.ts new file mode 100644 index 0000000000..5bec30143f --- /dev/null +++ b/src/client/app/common/scripts/streaming/requests.ts @@ -0,0 +1,30 @@ +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Requests stream connection + */ +export class RequestsStream extends Stream { + constructor(os: MiOS) { + super(os, 'requests'); + } +} + +export class RequestsStreamManager extends StreamManager<RequestsStream> { + private os: MiOS; + + constructor(os: MiOS) { + super(); + + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new RequestsStream(this.os); + } + + return this.connection; + } +} diff --git a/src/client/app/common/scripts/streaming/server.ts b/src/client/app/common/scripts/streaming/server.ts new file mode 100644 index 0000000000..3d35ef4d9d --- /dev/null +++ b/src/client/app/common/scripts/streaming/server.ts @@ -0,0 +1,30 @@ +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Server stream connection + */ +export class ServerStream extends Stream { + constructor(os: MiOS) { + super(os, 'server'); + } +} + +export class ServerStreamManager extends StreamManager<ServerStream> { + private os: MiOS; + + constructor(os: MiOS) { + super(); + + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new ServerStream(this.os); + } + + return this.connection; + } +} diff --git a/src/client/app/common/scripts/streaming/stream-manager.ts b/src/client/app/common/scripts/streaming/stream-manager.ts new file mode 100644 index 0000000000..568b8b0372 --- /dev/null +++ b/src/client/app/common/scripts/streaming/stream-manager.ts @@ -0,0 +1,108 @@ +import { EventEmitter } from 'eventemitter3'; +import * as uuid from 'uuid'; +import Connection from './stream'; + +/** + * ストリーム接続を管理するクラス + * 複数の場所から同じストリームを利用する際、接続をまとめたりする + */ +export default abstract class StreamManager<T extends Connection> extends EventEmitter { + private _connection: T = null; + + private disposeTimerId: any; + + /** + * コネクションを必要としているユーザー + */ + private users = []; + + protected set connection(connection: T) { + this._connection = connection; + + if (this._connection == null) { + this.emit('disconnected'); + } else { + this.emit('connected', this._connection); + + this._connection.on('_connected_', () => { + this.emit('_connected_'); + }); + + this._connection.on('_disconnected_', () => { + this.emit('_disconnected_'); + }); + + this._connection.user = 'Managed'; + } + } + + protected get connection() { + return this._connection; + } + + /** + * コネクションを持っているか否か + */ + public get hasConnection() { + return this._connection != null; + } + + public get state(): string { + if (!this.hasConnection) return 'no-connection'; + return this._connection.state; + } + + /** + * コネクションを要求します + */ + public abstract getConnection(): T; + + /** + * 現在接続しているコネクションを取得します + */ + public borrow() { + return this._connection; + } + + /** + * コネクションを要求するためのユーザーIDを発行します + */ + public use() { + // タイマー解除 + if (this.disposeTimerId) { + clearTimeout(this.disposeTimerId); + this.disposeTimerId = null; + } + + // ユーザーID生成 + const userId = uuid(); + + this.users.push(userId); + + this._connection.user = `Managed (${ this.users.length })`; + + return userId; + } + + /** + * コネクションを利用し終わってもう必要ないことを通知します + * @param userId use で発行したユーザーID + */ + public dispose(userId) { + this.users = this.users.filter(id => id != userId); + + this._connection.user = `Managed (${ this.users.length })`; + + // 誰もコネクションの利用者がいなくなったら + if (this.users.length == 0) { + // また直ぐに再利用される可能性があるので、一定時間待ち、 + // 新たな利用者が現れなければコネクションを切断する + this.disposeTimerId = setTimeout(() => { + this.disposeTimerId = null; + + this.connection.close(); + this.connection = null; + }, 3000); + } + } +} diff --git a/src/client/app/common/scripts/streaming/stream.ts b/src/client/app/common/scripts/streaming/stream.ts new file mode 100644 index 0000000000..3912186ad3 --- /dev/null +++ b/src/client/app/common/scripts/streaming/stream.ts @@ -0,0 +1,137 @@ +import { EventEmitter } from 'eventemitter3'; +import * as uuid from 'uuid'; +import * as ReconnectingWebsocket from 'reconnecting-websocket'; +import { wsUrl } from '../../../config'; +import MiOS from '../../mios'; + +/** + * Misskey stream connection + */ +export default class Connection extends EventEmitter { + public state: string; + private buffer: any[]; + public socket: ReconnectingWebsocket; + public name: string; + public connectedAt: Date; + public user: string = null; + public in: number = 0; + public out: number = 0; + public inout: Array<{ + type: 'in' | 'out', + at: Date, + data: string + }> = []; + public id: string; + public isSuspended = false; + private os: MiOS; + + constructor(os: MiOS, endpoint, params?) { + super(); + + //#region BIND + this.onOpen = this.onOpen.bind(this); + this.onClose = this.onClose.bind(this); + this.onMessage = this.onMessage.bind(this); + this.send = this.send.bind(this); + this.close = this.close.bind(this); + //#endregion + + this.id = uuid(); + this.os = os; + this.name = endpoint; + this.state = 'initializing'; + this.buffer = []; + + const query = params + ? Object.keys(params) + .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) + .join('&') + : null; + + this.socket = new ReconnectingWebsocket(`${wsUrl}/${endpoint}${query ? '?' + query : ''}`); + this.socket.addEventListener('open', this.onOpen); + this.socket.addEventListener('close', this.onClose); + this.socket.addEventListener('message', this.onMessage); + + // Register this connection for debugging + this.os.registerStreamConnection(this); + } + + /** + * Callback of when open connection + */ + private onOpen() { + this.state = 'connected'; + this.emit('_connected_'); + + this.connectedAt = new Date(); + + // バッファーを処理 + const _buffer = [].concat(this.buffer); // Shallow copy + this.buffer = []; // Clear buffer + _buffer.forEach(data => { + this.send(data); // Resend each buffered messages + + if (this.os.debug) { + this.out++; + this.inout.push({ type: 'out', at: new Date(), data }); + } + }); + } + + /** + * Callback of when close connection + */ + private onClose() { + this.state = 'reconnecting'; + this.emit('_disconnected_'); + } + + /** + * Callback of when received a message from connection + */ + private onMessage(message) { + if (this.isSuspended) return; + + if (this.os.debug) { + this.in++; + this.inout.push({ type: 'in', at: new Date(), data: message.data }); + } + + try { + const msg = JSON.parse(message.data); + if (msg.type) this.emit(msg.type, msg.body); + } catch (e) { + // noop + } + } + + /** + * Send a message to connection + */ + public send(data) { + if (this.isSuspended) return; + + // まだ接続が確立されていなかったらバッファリングして次に接続した時に送信する + if (this.state != 'connected') { + this.buffer.push(data); + return; + } + + if (this.os.debug) { + this.out++; + this.inout.push({ type: 'out', at: new Date(), data }); + } + + this.socket.send(JSON.stringify(data)); + } + + /** + * Close this connection + */ + public close() { + this.os.unregisterStreamConnection(this); + this.socket.removeEventListener('open', this.onOpen); + this.socket.removeEventListener('message', this.onMessage); + } +} diff --git a/src/client/app/common/views/components/autocomplete.vue b/src/client/app/common/views/components/autocomplete.vue new file mode 100644 index 0000000000..79bd2ba023 --- /dev/null +++ b/src/client/app/common/views/components/autocomplete.vue @@ -0,0 +1,306 @@ +<template> +<div class="mk-autocomplete" @contextmenu.prevent="() => {}"> + <ol class="users" ref="suggests" v-if="users.length > 0"> + <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1"> + <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=32`" alt=""/> + <span class="name">{{ user.name }}</span> + <span class="username">@{{ getAcct(user) }}</span> + </li> + </ol> + <ol class="emojis" ref="suggests" v-if="emojis.length > 0"> + <li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1"> + <span class="emoji">{{ emoji.emoji }}</span> + <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span> + <span class="alias" v-if="emoji.alias">({{ emoji.alias }})</span> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as emojilib from 'emojilib'; +import contains from '../../../common/scripts/contains'; +import getAcct from '../../../../../common/user/get-acct'; + +const lib = Object.entries(emojilib.lib).filter((x: any) => { + return x[1].category != 'flags'; +}); +const emjdb = lib.map((x: any) => ({ + emoji: x[1].char, + name: x[0], + alias: null +})); +lib.forEach((x: any) => { + if (x[1].keywords) { + x[1].keywords.forEach(k => { + emjdb.push({ + emoji: x[1].char, + name: k, + alias: x[0] + }); + }); + } +}); +emjdb.sort((a, b) => a.name.length - b.name.length); + +export default Vue.extend({ + props: ['type', 'q', 'textarea', 'complete', 'close', 'x', 'y'], + data() { + return { + fetching: true, + users: [], + emojis: [], + select: -1, + emojilib + } + }, + computed: { + items(): HTMLCollection { + return (this.$refs.suggests as Element).children; + } + }, + updated() { + //#region 位置調整 + const margin = 32; + + if (this.x + this.$el.offsetWidth > window.innerWidth - margin) { + this.$el.style.left = (this.x - this.$el.offsetWidth) + 'px'; + this.$el.style.marginLeft = '-16px'; + } else { + this.$el.style.left = this.x + 'px'; + this.$el.style.marginLeft = '0'; + } + + if (this.y + this.$el.offsetHeight > window.innerHeight - margin) { + this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px'; + this.$el.style.marginTop = '0'; + } else { + this.$el.style.top = this.y + 'px'; + this.$el.style.marginTop = 'calc(1em + 8px)'; + } + //#endregion + }, + mounted() { + this.textarea.addEventListener('keydown', this.onKeydown); + + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + + this.$nextTick(() => { + this.exec(); + + this.$watch('q', () => { + this.$nextTick(() => { + this.exec(); + }); + }); + }); + }, + beforeDestroy() { + this.textarea.removeEventListener('keydown', this.onKeydown); + + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + }, + methods: { + getAcct, + exec() { + this.select = -1; + if (this.$refs.suggests) { + Array.from(this.items).forEach(el => { + el.removeAttribute('data-selected'); + }); + } + + if (this.type == 'user') { + const cache = sessionStorage.getItem(this.q); + if (cache) { + const users = JSON.parse(cache); + this.users = users; + this.fetching = false; + } else { + (this as any).api('users/search_by_username', { + query: this.q, + limit: 30 + }).then(users => { + this.users = users; + this.fetching = false; + + // キャッシュ + sessionStorage.setItem(this.q, JSON.stringify(users)); + }); + } + } else if (this.type == 'emoji') { + const matched = []; + emjdb.some(x => { + if (x.name.indexOf(this.q) == 0 && !x.alias && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == 30; + }); + if (matched.length < 30) { + emjdb.some(x => { + if (x.name.indexOf(this.q) == 0 && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == 30; + }); + } + if (matched.length < 30) { + emjdb.some(x => { + if (x.name.indexOf(this.q) > -1 && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == 30; + }); + } + this.emojis = matched; + } + }, + + onMousedown(e) { + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); + }, + + onKeydown(e) { + const cancel = () => { + e.preventDefault(); + e.stopPropagation(); + }; + + switch (e.which) { + case 10: // [ENTER] + case 13: // [ENTER] + if (this.select !== -1) { + cancel(); + (this.items[this.select] as any).click(); + } else { + this.close(); + } + break; + + case 27: // [ESC] + cancel(); + this.close(); + break; + + case 38: // [↑] + if (this.select !== -1) { + cancel(); + this.selectPrev(); + } else { + this.close(); + } + break; + + case 9: // [TAB] + case 40: // [↓] + cancel(); + this.selectNext(); + break; + + default: + e.stopPropagation(); + this.textarea.focus(); + } + }, + + selectNext() { + if (++this.select >= this.items.length) this.select = 0; + this.applySelect(); + }, + + selectPrev() { + if (--this.select < 0) this.select = this.items.length - 1; + this.applySelect(); + }, + + applySelect() { + Array.from(this.items).forEach(el => { + el.removeAttribute('data-selected'); + }); + + this.items[this.select].setAttribute('data-selected', 'true'); + (this.items[this.select] as any).focus(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-autocomplete + position fixed + z-index 65535 + margin-top calc(1em + 8px) + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + transition top 0.1s ease, left 0.1s ease + + > ol + display block + margin 0 + padding 4px 0 + max-height 190px + max-width 500px + overflow auto + list-style none + + > li + display block + padding 4px 12px + white-space nowrap + overflow hidden + font-size 0.9em + color rgba(0, 0, 0, 0.8) + cursor default + + &, * + user-select none + + &:hover + &[data-selected='true'] + background $theme-color + + &, * + color #fff !important + + &:active + background darken($theme-color, 10%) + + &, * + color #fff !important + + > .users > li + + .avatar + vertical-align middle + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 100% + + .name + margin 0 8px 0 0 + color rgba(0, 0, 0, 0.8) + + .username + color rgba(0, 0, 0, 0.3) + + > .emojis > li + + .emoji + display inline-block + margin 0 4px 0 0 + width 24px + + .name + color rgba(0, 0, 0, 0.8) + + .alias + margin 0 0 0 8px + color rgba(0, 0, 0, 0.3) + +</style> diff --git a/src/client/app/common/views/components/connect-failed.troubleshooter.vue b/src/client/app/common/views/components/connect-failed.troubleshooter.vue new file mode 100644 index 0000000000..cadbd36ba4 --- /dev/null +++ b/src/client/app/common/views/components/connect-failed.troubleshooter.vue @@ -0,0 +1,137 @@ +<template> +<div class="troubleshooter"> + <h1>%fa:wrench%%i18n:common.tags.mk-error.troubleshooter.title%</h1> + <div> + <p :data-wip="network == null"> + <template v-if="network != null"> + <template v-if="network">%fa:check%</template> + <template v-if="!network">%fa:times%</template> + </template> + {{ network == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-network%' : '%i18n:common.tags.mk-error.troubleshooter.network%' }}<mk-ellipsis v-if="network == null"/> + </p> + <p v-if="network == true" :data-wip="internet == null"> + <template v-if="internet != null"> + <template v-if="internet">%fa:check%</template> + <template v-if="!internet">%fa:times%</template> + </template> + {{ internet == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-internet%' : '%i18n:common.tags.mk-error.troubleshooter.internet%' }}<mk-ellipsis v-if="internet == null"/> + </p> + <p v-if="internet == true" :data-wip="server == null"> + <template v-if="server != null"> + <template v-if="server">%fa:check%</template> + <template v-if="!server">%fa:times%</template> + </template> + {{ server == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-server%' : '%i18n:common.tags.mk-error.troubleshooter.server%' }}<mk-ellipsis v-if="server == null"/> + </p> + </div> + <p v-if="!end">%i18n:common.tags.mk-error.troubleshooter.finding%<mk-ellipsis/></p> + <p v-if="network === false"><b>%fa:exclamation-triangle%%i18n:common.tags.mk-error.troubleshooter.no-network%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-network-desc%</p> + <p v-if="internet === false"><b>%fa:exclamation-triangle%%i18n:common.tags.mk-error.troubleshooter.no-internet%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-internet-desc%</p> + <p v-if="server === false"><b>%fa:exclamation-triangle%%i18n:common.tags.mk-error.troubleshooter.no-server%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-server-desc%</p> + <p v-if="server === true" class="success"><b>%fa:info-circle%%i18n:common.tags.mk-error.troubleshooter.success%</b><br>%i18n:common.tags.mk-error.troubleshooter.success-desc%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { apiUrl } from '../../../config'; + +export default Vue.extend({ + data() { + return { + network: navigator.onLine, + end: false, + internet: null, + server: null + }; + }, + mounted() { + if (!this.network) { + this.end = true; + return; + } + + // Check internet connection + fetch('https://google.com?rand=' + Math.random(), { + mode: 'no-cors' + }).then(() => { + this.internet = true; + + // Check misskey server is available + fetch(`${apiUrl}/meta`).then(() => { + this.end = true; + this.server = true; + }) + .catch(() => { + this.end = true; + this.server = false; + }); + }) + .catch(() => { + this.end = true; + this.internet = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.troubleshooter + width 100% + max-width 500px + text-align left + background #fff + border-radius 8px + border solid 1px #ddd + + > h1 + margin 0 + padding 0.6em 1.2em + font-size 1em + color #444 + border-bottom solid 1px #eee + + > [data-fa] + margin-right 0.25em + + > div + overflow hidden + padding 0.6em 1.2em + + > p + margin 0.5em 0 + font-size 0.9em + color #444 + + &[data-wip] + color #888 + + > [data-fa] + margin-right 0.25em + + &.times + color #e03524 + + &.check + color #84c32f + + > p + margin 0 + padding 0.7em 1.2em + font-size 1em + color #444 + border-top solid 1px #eee + + > b + > [data-fa] + margin-right 0.25em + + &.success + > b + color #39adad + + &:not(.success) + > b + color #ad4339 + +</style> diff --git a/src/client/app/common/views/components/connect-failed.vue b/src/client/app/common/views/components/connect-failed.vue new file mode 100644 index 0000000000..185250dbd8 --- /dev/null +++ b/src/client/app/common/views/components/connect-failed.vue @@ -0,0 +1,106 @@ +<template> +<div class="mk-connect-failed"> + <img src="data:image/jpeg;base64,%base64:/assets/error.jpg%" alt=""/> + <h1>%i18n:common.tags.mk-error.title%</h1> + <p class="text"> + {{ '%i18n:common.tags.mk-error.description%'.substr(0, '%i18n:common.tags.mk-error.description%'.indexOf('{')) }} + <a @click="reload">{{ '%i18n:common.tags.mk-error.description%'.match(/\{(.+?)\}/)[1] }}</a> + {{ '%i18n:common.tags.mk-error.description%'.substr('%i18n:common.tags.mk-error.description%'.indexOf('}') + 1) }} + </p> + <button v-if="!troubleshooting" @click="troubleshooting = true">%i18n:common.tags.mk-error.troubleshoot%</button> + <x-troubleshooter v-if="troubleshooting"/> + <p class="thanks">%i18n:common.tags.mk-error.thanks%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XTroubleshooter from './connect-failed.troubleshooter.vue'; + +export default Vue.extend({ + components: { + XTroubleshooter + }, + data() { + return { + troubleshooting: false + }; + }, + mounted() { + document.title = 'Oops!'; + document.documentElement.style.background = '#f8f8f8'; + }, + methods: { + reload() { + location.reload(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-connect-failed + width 100% + padding 32px 18px + text-align center + + > img + display block + height 200px + margin 0 auto + pointer-events none + user-select none + + > h1 + display block + margin 1.25em auto 0.65em auto + font-size 1.5em + color #555 + + > .text + display block + margin 0 auto + max-width 600px + font-size 1em + color #666 + + > button + display block + margin 1em auto 0 auto + padding 8px 10px + color $theme-color-foreground + background $theme-color + + &:focus + outline solid 3px rgba($theme-color, 0.3) + + &:hover + background lighten($theme-color, 10%) + + &:active + background darken($theme-color, 10%) + + > .troubleshooter + margin 1em auto 0 auto + + > .thanks + display block + margin 2em auto 0 auto + padding 2em 0 0 0 + max-width 600px + font-size 0.9em + font-style oblique + color #aaa + border-top solid 1px #eee + + @media (max-width 500px) + padding 24px 18px + font-size 80% + + > img + height 150px + +</style> + diff --git a/src/client/app/common/views/components/ellipsis.vue b/src/client/app/common/views/components/ellipsis.vue new file mode 100644 index 0000000000..07349902de --- /dev/null +++ b/src/client/app/common/views/components/ellipsis.vue @@ -0,0 +1,26 @@ +<template> + <span class="mk-ellipsis"> + <span>.</span><span>.</span><span>.</span> + </span> +</template> + +<style lang="stylus" scoped> +.mk-ellipsis + > span + animation ellipsis 1.4s infinite ease-in-out both + + &:nth-child(1) + animation-delay 0s + + &:nth-child(2) + animation-delay 0.16s + + &:nth-child(3) + animation-delay 0.32s + + @keyframes ellipsis + 0%, 80%, 100% + opacity 1 + 40% + opacity 0 +</style> diff --git a/src/client/app/common/views/components/file-type-icon.vue b/src/client/app/common/views/components/file-type-icon.vue new file mode 100644 index 0000000000..b7e868d1f7 --- /dev/null +++ b/src/client/app/common/views/components/file-type-icon.vue @@ -0,0 +1,17 @@ +<template> +<span class="mk-file-type-icon"> + <template v-if="kind == 'image'">%fa:file-image%</template> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['type'], + computed: { + kind(): string { + return this.type.split('/')[0]; + } + } +}); +</script> diff --git a/src/client/app/common/views/components/forkit.vue b/src/client/app/common/views/components/forkit.vue new file mode 100644 index 0000000000..6f334b965a --- /dev/null +++ b/src/client/app/common/views/components/forkit.vue @@ -0,0 +1,42 @@ +<template> +<a class="a" href="https://github.com/syuilo/misskey" target="_blank" title="%i18n:common.tags.mk-forkit.open-github-link%" aria-label="%i18n:common.tags.mk-forkit.open-github-link%"> + <svg width="80" height="80" viewBox="0 0 250 250" aria-hidden="aria-hidden"> + <path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path> + <path class="octo-arm" d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor"></path> + <path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor"></path> + </svg> +</a> +</template> + +<style lang="stylus" scoped> +@import '~const.styl' + +.a + display block + position absolute + top 0 + right 0 + + > svg + display block + //fill #151513 + //color #fff + fill $theme-color + color $theme-color-foreground + + .octo-arm + transform-origin 130px 106px + + &:hover + .octo-arm + animation octocat-wave 560ms ease-in-out + + @keyframes octocat-wave + 0%, 100% + transform rotate(0) + 20%, 60% + transform rotate(-25deg) + 40%, 80% + transform rotate(10deg) + +</style> diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts new file mode 100644 index 0000000000..b58ba37ecb --- /dev/null +++ b/src/client/app/common/views/components/index.ts @@ -0,0 +1,51 @@ +import Vue from 'vue'; + +import signin from './signin.vue'; +import signup from './signup.vue'; +import forkit from './forkit.vue'; +import nav from './nav.vue'; +import postHtml from './post-html'; +import poll from './poll.vue'; +import pollEditor from './poll-editor.vue'; +import reactionIcon from './reaction-icon.vue'; +import reactionsViewer from './reactions-viewer.vue'; +import time from './time.vue'; +import timer from './timer.vue'; +import mediaList from './media-list.vue'; +import uploader from './uploader.vue'; +import specialMessage from './special-message.vue'; +import streamIndicator from './stream-indicator.vue'; +import ellipsis from './ellipsis.vue'; +import messaging from './messaging.vue'; +import messagingRoom from './messaging-room.vue'; +import urlPreview from './url-preview.vue'; +import twitterSetting from './twitter-setting.vue'; +import fileTypeIcon from './file-type-icon.vue'; +import Switch from './switch.vue'; +import Othello from './othello.vue'; +import welcomeTimeline from './welcome-timeline.vue'; + +Vue.component('mk-signin', signin); +Vue.component('mk-signup', signup); +Vue.component('mk-forkit', forkit); +Vue.component('mk-nav', nav); +Vue.component('mk-post-html', postHtml); +Vue.component('mk-poll', poll); +Vue.component('mk-poll-editor', pollEditor); +Vue.component('mk-reaction-icon', reactionIcon); +Vue.component('mk-reactions-viewer', reactionsViewer); +Vue.component('mk-time', time); +Vue.component('mk-timer', timer); +Vue.component('mk-media-list', mediaList); +Vue.component('mk-uploader', uploader); +Vue.component('mk-special-message', specialMessage); +Vue.component('mk-stream-indicator', streamIndicator); +Vue.component('mk-ellipsis', ellipsis); +Vue.component('mk-messaging', messaging); +Vue.component('mk-messaging-room', messagingRoom); +Vue.component('mk-url-preview', urlPreview); +Vue.component('mk-twitter-setting', twitterSetting); +Vue.component('mk-file-type-icon', fileTypeIcon); +Vue.component('mk-switch', Switch); +Vue.component('mk-othello', Othello); +Vue.component('mk-welcome-timeline', welcomeTimeline); diff --git a/src/client/app/common/views/components/media-list.vue b/src/client/app/common/views/components/media-list.vue new file mode 100644 index 0000000000..64172ad0b4 --- /dev/null +++ b/src/client/app/common/views/components/media-list.vue @@ -0,0 +1,57 @@ +<template> +<div class="mk-media-list" :data-count="mediaList.length"> + <template v-for="media in mediaList"> + <mk-media-video :video="media" :key="media.id" v-if="media.type.startsWith('video')" :inline-playable="mediaList.length === 1"/> + <mk-media-image :image="media" :key="media.id" v-else /> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['mediaList'], +}); +</script> + +<style lang="stylus" scoped> +.mk-media-list + display grid + grid-gap 4px + height 256px + + @media (max-width 500px) + height 192px + + &[data-count="1"] + grid-template-rows 1fr + &[data-count="2"] + grid-template-columns 1fr 1fr + grid-template-rows 1fr + &[data-count="3"] + grid-template-columns 1fr 0.5fr + grid-template-rows 1fr 1fr + :nth-child(1) + grid-row 1 / 3 + :nth-child(3) + grid-column 2 / 3 + grid-row 2/3 + &[data-count="4"] + grid-template-columns 1fr 1fr + grid-template-rows 1fr 1fr + + :nth-child(1) + grid-column 1 / 2 + grid-row 1 / 2 + :nth-child(2) + grid-column 2 / 3 + grid-row 1 / 2 + :nth-child(3) + grid-column 1 / 2 + grid-row 2 / 3 + :nth-child(4) + grid-column 2 / 3 + grid-row 2 / 3 + +</style> diff --git a/src/client/app/common/views/components/messaging-room.form.vue b/src/client/app/common/views/components/messaging-room.form.vue new file mode 100644 index 0000000000..704f2016d8 --- /dev/null +++ b/src/client/app/common/views/components/messaging-room.form.vue @@ -0,0 +1,305 @@ +<template> +<div class="mk-messaging-form" + @dragover.stop="onDragover" + @drop.stop="onDrop" +> + <textarea + v-model="text" + ref="textarea" + @keypress="onKeypress" + @paste="onPaste" + placeholder="%i18n:common.input-message-here%" + v-autocomplete="'text'" + ></textarea> + <div class="file" @click="file = null" v-if="file">{{ file.name }}</div> + <mk-uploader ref="uploader" @uploaded="onUploaded"/> + <button class="send" @click="send" :disabled="!canSend || sending" title="%i18n:common.send%"> + <template v-if="!sending">%fa:paper-plane%</template><template v-if="sending">%fa:spinner .spin%</template> + </button> + <button class="attach-from-local" @click="chooseFile" title="%i18n:common.tags.mk-messaging-form.attach-from-local%"> + %fa:upload% + </button> + <button class="attach-from-drive" @click="chooseFileFromDrive" title="%i18n:common.tags.mk-messaging-form.attach-from-drive%"> + %fa:R folder-open% + </button> + <input ref="file" type="file" @change="onChangeFile"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as autosize from 'autosize'; + +export default Vue.extend({ + props: ['user'], + data() { + return { + text: null, + file: null, + sending: false + }; + }, + computed: { + draftId(): string { + return this.user.id; + }, + canSend(): boolean { + return (this.text != null && this.text != '') || this.file != null; + }, + room(): any { + return this.$parent; + } + }, + watch: { + text() { + this.saveDraft(); + }, + file() { + this.saveDraft(); + + if (this.room.isBottom()) { + this.room.scrollToBottom(); + } + } + }, + mounted() { + autosize(this.$refs.textarea); + + // 書きかけの投稿を復元 + const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftId]; + if (draft) { + this.text = draft.data.text; + this.file = draft.data.file; + } + }, + methods: { + onPaste(e) { + const data = e.clipboardData; + const items = data.items; + + if (items.length == 1) { + if (items[0].kind == 'file') { + this.upload(items[0].getAsFile()); + } + } else { + if (items[0].kind == 'file') { + alert('メッセージに添付できるのはひとつのファイルのみです'); + } + } + }, + + onDragover(e) { + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + if (isFile || isDriveFile) { + e.preventDefault(); + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } + }, + + onDrop(e): void { + // ファイルだったら + if (e.dataTransfer.files.length == 1) { + e.preventDefault(); + this.upload(e.dataTransfer.files[0]); + return; + } else if (e.dataTransfer.files.length > 1) { + e.preventDefault(); + alert('メッセージに添付できるのはひとつのファイルのみです'); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + this.file = JSON.parse(driveFile); + e.preventDefault(); + } + //#endregion + }, + + onKeypress(e) { + if ((e.which == 10 || e.which == 13) && e.ctrlKey) { + this.send(); + } + }, + + chooseFile() { + (this.$refs.file as any).click(); + }, + + chooseFileFromDrive() { + (this as any).apis.chooseDriveFile({ + multiple: false + }).then(file => { + this.file = file; + }); + }, + + onChangeFile() { + this.upload((this.$refs.file as any).files[0]); + }, + + upload(file) { + (this.$refs.uploader as any).upload(file); + }, + + onUploaded(file) { + this.file = file; + }, + + send() { + this.sending = true; + (this as any).api('messaging/messages/create', { + userId: this.user.id, + text: this.text ? this.text : undefined, + fileId: this.file ? this.file.id : undefined + }).then(message => { + this.clear(); + }).catch(err => { + console.error(err); + }).then(() => { + this.sending = false; + }); + }, + + clear() { + this.text = ''; + this.file = null; + this.deleteDraft(); + }, + + saveDraft() { + const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + data[this.draftId] = { + updatedAt: new Date(), + data: { + text: this.text, + file: this.file + } + } + + localStorage.setItem('message_drafts', JSON.stringify(data)); + }, + + deleteDraft() { + const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + delete data[this.draftId]; + + localStorage.setItem('message_drafts', JSON.stringify(data)); + }, + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-messaging-form + > textarea + cursor auto + display block + width 100% + min-width 100% + max-width 100% + height 64px + margin 0 + padding 8px + resize none + font-size 1em + color #000 + outline none + border none + border-top solid 1px #eee + border-radius 0 + box-shadow none + background transparent + + > .file + padding 8px + color #444 + background #eee + cursor pointer + + > .send + position absolute + bottom 0 + right 0 + margin 0 + padding 10px 14px + font-size 1em + color #aaa + transition color 0.1s ease + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + + .files + display block + margin 0 + padding 0 8px + list-style none + + &:after + content '' + display block + clear both + + > li + display block + float left + margin 4px + padding 0 + width 64px + height 64px + background-color #eee + background-repeat no-repeat + background-position center center + background-size cover + cursor move + + &:hover + > .remove + display block + + > .remove + display none + position absolute + right -6px + top -6px + margin 0 + padding 0 + background transparent + outline none + border none + border-radius 0 + box-shadow none + cursor pointer + + .attach-from-local + .attach-from-drive + margin 0 + padding 10px 14px + font-size 1em + font-weight normal + text-decoration none + color #aaa + transition color 0.1s ease + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + + input[type=file] + display none + +</style> diff --git a/src/client/app/common/views/components/messaging-room.message.vue b/src/client/app/common/views/components/messaging-room.message.vue new file mode 100644 index 0000000000..94f87fd709 --- /dev/null +++ b/src/client/app/common/views/components/messaging-room.message.vue @@ -0,0 +1,263 @@ +<template> +<div class="message" :data-is-me="isMe"> + <router-link class="avatar-anchor" :to="`/@${acct}`" :title="acct" target="_blank"> + <img class="avatar" :src="`${message.user.avatarUrl}?thumbnail&size=80`" alt=""/> + </router-link> + <div class="content"> + <div class="balloon" :data-no-text="message.text == null"> + <p class="read" v-if="isMe && message.isRead">%i18n:common.tags.mk-messaging-message.is-read%</p> + <button class="delete-button" v-if="isMe" title="%i18n:common.delete%"> + <img src="/assets/desktop/messaging/delete.png" alt="Delete"/> + </button> + <div class="content" v-if="!message.isDeleted"> + <mk-post-html class="text" v-if="message.ast" :ast="message.ast" :i="os.i"/> + <div class="file" v-if="message.file"> + <a :href="message.file.url" target="_blank" :title="message.file.name"> + <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/> + <p v-else>{{ message.file.name }}</p> + </a> + </div> + </div> + <div class="content" v-if="message.isDeleted"> + <p class="is-deleted">%i18n:common.tags.mk-messaging-message.deleted%</p> + </div> + </div> + <div></div> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <footer> + <mk-time :time="message.createdAt"/> + <template v-if="message.is_edited">%fa:pencil-alt%</template> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['message'], + computed: { + acct() { + return getAcct(this.message.user); + }, + isMe(): boolean { + return this.message.userId == (this as any).os.i.id; + }, + urls(): string[] { + if (this.message.ast) { + return this.message.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.message + $me-balloon-color = #23A7B6 + + padding 10px 12px 10px 12px + background-color transparent + + > .avatar-anchor + display block + position absolute + top 10px + + > .avatar + display block + min-width 54px + min-height 54px + max-width 54px + max-height 54px + margin 0 + border-radius 8px + transition all 0.1s ease + + > .content + + > .balloon + display block + padding 0 + max-width calc(100% - 16px) + min-height 38px + border-radius 16px + + &:before + content "" + pointer-events none + display block + position absolute + top 12px + + & + * + clear both + + &:hover + > .delete-button + display block + + > .delete-button + display none + position absolute + z-index 1 + top -4px + right -4px + margin 0 + padding 0 + cursor pointer + outline none + border none + border-radius 0 + box-shadow none + background transparent + + > img + vertical-align bottom + width 16px + height 16px + cursor pointer + + > .read + user-select none + display block + position absolute + z-index 1 + bottom -4px + left -12px + margin 0 + color rgba(0, 0, 0, 0.5) + font-size 11px + + > .content + + > .is-deleted + display block + margin 0 + padding 0 + overflow hidden + overflow-wrap break-word + font-size 1em + color rgba(0, 0, 0, 0.5) + + > .text + display block + margin 0 + padding 8px 16px + overflow hidden + overflow-wrap break-word + font-size 1em + color rgba(0, 0, 0, 0.8) + + & + .file + > a + border-radius 0 0 16px 16px + + > .file + > a + display block + max-width 100% + max-height 512px + border-radius 16px + overflow hidden + text-decoration none + + &:hover + text-decoration none + + > p + background #ccc + + > * + display block + margin 0 + width 100% + height 100% + + > p + padding 30px + text-align center + color #555 + background #ddd + + > .mk-url-preview + margin 8px 0 + + > footer + display block + margin 2px 0 0 0 + font-size 10px + color rgba(0, 0, 0, 0.4) + + > [data-fa] + margin-left 4px + + &:not([data-is-me]) + > .avatar-anchor + left 12px + + > .content + padding-left 66px + + > .balloon + float left + background #eee + + &[data-no-text] + background transparent + + &:not([data-no-text]):before + left -14px + border-top solid 8px transparent + border-right solid 8px #eee + border-bottom solid 8px transparent + border-left solid 8px transparent + + > footer + text-align left + + &[data-is-me] + > .avatar-anchor + right 12px + + > .content + padding-right 66px + + > .balloon + float right + background $me-balloon-color + + &[data-no-text] + background transparent + + &:not([data-no-text]):before + right -14px + left auto + border-top solid 8px transparent + border-right solid 8px transparent + border-bottom solid 8px transparent + border-left solid 8px $me-balloon-color + + > .content + + > p.is-deleted + color rgba(255, 255, 255, 0.5) + + > .text >>> + &, * + color #fff !important + + > footer + text-align right + + &[data-is-deleted] + > .baloon + opacity 0.5 + +</style> diff --git a/src/client/app/common/views/components/messaging-room.vue b/src/client/app/common/views/components/messaging-room.vue new file mode 100644 index 0000000000..d30c64d74a --- /dev/null +++ b/src/client/app/common/views/components/messaging-room.vue @@ -0,0 +1,377 @@ +<template> +<div class="mk-messaging-room" + @dragover.prevent.stop="onDragover" + @drop.prevent.stop="onDrop" +> + <div class="stream"> + <p class="init" v-if="init">%fa:spinner .spin%%i18n:common.loading%</p> + <p class="empty" v-if="!init && messages.length == 0">%fa:info-circle%%i18n:common.tags.mk-messaging-room.empty%</p> + <p class="no-history" v-if="!init && messages.length > 0 && !existMoreMessages">%fa:flag%%i18n:common.tags.mk-messaging-room.no-history%</p> + <button class="more" :class="{ fetching: fetchingMoreMessages }" v-if="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages"> + <template v-if="fetchingMoreMessages">%fa:spinner .pulse .fw%</template>{{ fetchingMoreMessages ? '%i18n:common.loading%' : '%i18n:common.tags.mk-messaging-room.more%' }} + </button> + <template v-for="(message, i) in _messages"> + <x-message :message="message" :key="message.id"/> + <p class="date" v-if="i != messages.length - 1 && message._date != _messages[i + 1]._date"> + <span>{{ _messages[i + 1]._datetext }}</span> + </p> + </template> + </div> + <footer> + <div ref="notifications" class="notifications"></div> + <x-form :user="user" ref="form"/> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { MessagingStream } from '../../scripts/streaming/messaging'; +import XMessage from './messaging-room.message.vue'; +import XForm from './messaging-room.form.vue'; +import { url } from '../../../config'; + +export default Vue.extend({ + components: { + XMessage, + XForm + }, + + props: ['user', 'isNaked'], + + data() { + return { + init: true, + fetchingMoreMessages: false, + messages: [], + existMoreMessages: false, + connection: null + }; + }, + + computed: { + _messages(): any[] { + return (this.messages as any).map(message => { + const date = new Date(message.createdAt).getDate(); + const month = new Date(message.createdAt).getMonth() + 1; + message._date = date; + message._datetext = `${month}月 ${date}日`; + return message; + }); + }, + + form(): any { + return this.$refs.form; + } + }, + + mounted() { + this.connection = new MessagingStream((this as any).os, (this as any).os.i, this.user.id); + + this.connection.on('message', this.onMessage); + this.connection.on('read', this.onRead); + + document.addEventListener('visibilitychange', this.onVisibilitychange); + + this.fetchMessages().then(() => { + this.init = false; + this.scrollToBottom(); + }); + }, + + beforeDestroy() { + this.connection.off('message', this.onMessage); + this.connection.off('read', this.onRead); + this.connection.close(); + + document.removeEventListener('visibilitychange', this.onVisibilitychange); + }, + + methods: { + onDragover(e) { + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + + if (isFile || isDriveFile) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + }, + + onDrop(e): void { + // ファイルだったら + if (e.dataTransfer.files.length == 1) { + this.form.upload(e.dataTransfer.files[0]); + return; + } else if (e.dataTransfer.files.length > 1) { + alert('メッセージに添付できるのはひとつのファイルのみです'); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.form.file = file; + } + //#endregion + }, + + fetchMessages() { + return new Promise((resolve, reject) => { + const max = this.existMoreMessages ? 20 : 10; + + (this as any).api('messaging/messages', { + userId: this.user.id, + limit: max + 1, + untilId: this.existMoreMessages ? this.messages[0].id : undefined + }).then(messages => { + if (messages.length == max + 1) { + this.existMoreMessages = true; + messages.pop(); + } else { + this.existMoreMessages = false; + } + + this.messages.unshift.apply(this.messages, messages.reverse()); + resolve(); + }); + }); + }, + + fetchMoreMessages() { + this.fetchingMoreMessages = true; + this.fetchMessages().then(() => { + this.fetchingMoreMessages = false; + }); + }, + + onMessage(message) { + // サウンドを再生する + if ((this as any).os.isEnableSounds) { + const sound = new Audio(`${url}/assets/message.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + + const isBottom = this.isBottom(); + + this.messages.push(message); + if (message.userId != (this as any).os.i.id && !document.hidden) { + this.connection.send({ + type: 'read', + id: message.id + }); + } + + if (isBottom) { + // Scroll to bottom + this.$nextTick(() => { + this.scrollToBottom(); + }); + } else if (message.userId != (this as any).os.i.id) { + // Notify + this.notify('%i18n:common.tags.mk-messaging-room.new-message%'); + } + }, + + onRead(ids) { + if (!Array.isArray(ids)) ids = [ids]; + ids.forEach(id => { + if (this.messages.some(x => x.id == id)) { + const exist = this.messages.map(x => x.id).indexOf(id); + this.messages[exist].isRead = true; + } + }); + }, + + isBottom() { + const asobi = 64; + const current = this.isNaked + ? window.scrollY + window.innerHeight + : this.$el.scrollTop + this.$el.offsetHeight; + const max = this.isNaked + ? document.body.offsetHeight + : this.$el.scrollHeight; + return current > (max - asobi); + }, + + scrollToBottom() { + if (this.isNaked) { + window.scroll(0, document.body.offsetHeight); + } else { + this.$el.scrollTop = this.$el.scrollHeight; + } + }, + + notify(message) { + const n = document.createElement('p') as any; + n.innerHTML = '%fa:arrow-circle-down%' + message; + n.onclick = () => { + this.scrollToBottom(); + n.parentNode.removeChild(n); + }; + (this.$refs.notifications as any).appendChild(n); + + setTimeout(() => { + n.style.opacity = 0; + setTimeout(() => n.parentNode.removeChild(n), 1000); + }, 4000); + }, + + onVisibilitychange() { + if (document.hidden) return; + this.messages.forEach(message => { + if (message.userId !== (this as any).os.i.id && !message.isRead) { + this.connection.send({ + type: 'read', + id: message.id + }); + } + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-messaging-room + display flex + flex 1 + flex-direction column + height 100% + + > .stream + width 100% + max-width 600px + margin 0 auto + flex 1 + + > .init + width 100% + margin 0 + padding 16px 8px 8px 8px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + [data-fa] + margin-right 4px + + > .empty + width 100% + margin 0 + padding 16px 8px 8px 8px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + [data-fa] + margin-right 4px + + > .no-history + display block + margin 0 + padding 16px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + [data-fa] + margin-right 4px + + > .more + display block + margin 16px auto + padding 0 12px + line-height 24px + color #fff + background rgba(0, 0, 0, 0.3) + border-radius 12px + + &:hover + background rgba(0, 0, 0, 0.4) + + &:active + background rgba(0, 0, 0, 0.5) + + &.fetching + cursor wait + + > [data-fa] + margin-right 4px + + > .message + // something + + > .date + display block + margin 8px 0 + text-align center + + &:before + content '' + display block + position absolute + height 1px + width 90% + top 16px + left 0 + right 0 + margin 0 auto + background rgba(0, 0, 0, 0.1) + + > span + display inline-block + margin 0 + padding 0 16px + //font-weight bold + line-height 32px + color rgba(0, 0, 0, 0.3) + background #fff + + > footer + position -webkit-sticky + position sticky + z-index 2 + bottom 0 + width 100% + max-width 600px + margin 0 auto + padding 0 + background rgba(255, 255, 255, 0.95) + background-clip content-box + + > .notifications + position absolute + top -48px + width 100% + padding 8px 0 + text-align center + + &:empty + display none + + > p + display inline-block + margin 0 + padding 0 12px 0 28px + cursor pointer + line-height 32px + font-size 12px + color $theme-color-foreground + background $theme-color + border-radius 16px + transition opacity 1s ease + + > [data-fa] + position absolute + top 0 + left 10px + line-height 32px + font-size 16px + +</style> diff --git a/src/client/app/common/views/components/messaging.vue b/src/client/app/common/views/components/messaging.vue new file mode 100644 index 0000000000..8317c3738a --- /dev/null +++ b/src/client/app/common/views/components/messaging.vue @@ -0,0 +1,463 @@ +<template> +<div class="mk-messaging" :data-compact="compact"> + <div class="search" v-if="!compact" :style="{ top: headerTop + 'px' }"> + <div class="form"> + <label for="search-input">%fa:search%</label> + <input v-model="q" type="search" @input="search" @keydown="onSearchKeydown" placeholder="%i18n:common.tags.mk-messaging.search-user%"/> + </div> + <div class="result"> + <ol class="users" v-if="result.length > 0" ref="searchResult"> + <li v-for="(user, i) in result" + @keydown.enter="navigate(user)" + @keydown="onSearchResultKeydown(i)" + @click="navigate(user)" + tabindex="-1" + > + <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=32`" alt=""/> + <span class="name">{{ user.name }}</span> + <span class="username">@{{ getAcct(user) }}</span> + </li> + </ol> + </div> + </div> + <div class="history" v-if="messages.length > 0"> + <template> + <a v-for="message in messages" + class="user" + :href="`/i/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" + :data-is-me="isMe(message)" + :data-is-read="message.isRead" + @click.prevent="navigate(isMe(message) ? message.recipient : message.user)" + :key="message.id" + > + <div> + <img class="avatar" :src="`${isMe(message) ? message.recipient.avatarUrl : message.user.avatarUrl}?thumbnail&size=64`" alt=""/> + <header> + <span class="name">{{ isMe(message) ? message.recipient.name : message.user.name }}</span> + <span class="username">@{{ getAcct(isMe(message) ? message.recipient : message.user) }}</span> + <mk-time :time="message.createdAt"/> + </header> + <div class="body"> + <p class="text"><span class="me" v-if="isMe(message)">%i18n:common.tags.mk-messaging.you%:</span>{{ message.text }}</p> + </div> + </div> + </a> + </template> + </div> + <p class="no-history" v-if="!fetching && messages.length == 0">%i18n:common.tags.mk-messaging.no-history%</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'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: { + compact: { + type: Boolean, + default: false + }, + headerTop: { + type: Number, + default: 0 + } + }, + data() { + return { + fetching: true, + moreFetching: false, + messages: [], + q: null, + result: [], + connection: null, + connectionId: null + }; + }, + mounted() { + this.connection = (this as any).os.streams.messagingIndexStream.getConnection(); + this.connectionId = (this as any).os.streams.messagingIndexStream.use(); + + this.connection.on('message', this.onMessage); + this.connection.on('read', this.onRead); + + (this as any).api('messaging/history').then(messages => { + this.messages = messages; + this.fetching = false; + }); + }, + beforeDestroy() { + this.connection.off('message', this.onMessage); + this.connection.off('read', this.onRead); + (this as any).os.streams.messagingIndexStream.dispose(this.connectionId); + }, + methods: { + getAcct, + isMe(message) { + return message.userId == (this as any).os.i.id; + }, + onMessage(message) { + this.messages = this.messages.filter(m => !( + (m.recipientId == message.recipientId && m.userId == message.userId) || + (m.recipientId == message.userId && m.userId == message.recipientId))); + + this.messages.unshift(message); + }, + onRead(ids) { + ids.forEach(id => { + const found = this.messages.find(m => m.id == id); + if (found) found.isRead = true; + }); + }, + search() { + if (this.q == '') { + this.result = []; + return; + } + (this as any).api('users/search', { + query: this.q, + max: 5 + }).then(users => { + this.result = users; + }); + }, + navigate(user) { + this.$emit('navigate', user); + }, + onSearchKeydown(e) { + switch (e.which) { + case 9: // [TAB] + case 40: // [↓] + e.preventDefault(); + e.stopPropagation(); + (this.$refs.searchResult as any).childNodes[0].focus(); + break; + } + }, + onSearchResultKeydown(i, e) { + const list = this.$refs.searchResult as any; + + const cancel = () => { + e.preventDefault(); + e.stopPropagation(); + }; + + switch (true) { + case e.which == 27: // [ESC] + cancel(); + (this.$refs.search as any).focus(); + break; + + case e.which == 9 && e.shiftKey: // [TAB] + [Shift] + case e.which == 38: // [↑] + cancel(); + (list.childNodes[i].previousElementSibling || list.childNodes[this.result.length - 1]).focus(); + break; + + case e.which == 9: // [TAB] + case e.which == 40: // [↓] + cancel(); + (list.childNodes[i].nextElementSibling || list.childNodes[0]).focus(); + break; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-messaging + + &[data-compact] + font-size 0.8em + + > .history + > a + &:last-child + border-bottom none + + &:not([data-is-me]):not([data-is-read]) + > div + background-image none + border-left solid 4px #3aa2dc + + > div + padding 16px + + > header + > .mk-time + font-size 1em + + > .avatar + width 42px + height 42px + margin 0 12px 0 0 + + > .search + display block + position -webkit-sticky + position sticky + top 0 + left 0 + z-index 1 + width 100% + background #fff + box-shadow 0 0px 2px rgba(0, 0, 0, 0.2) + + > .form + padding 8px + background #f7f7f7 + + > label + display block + position absolute + top 0 + left 8px + z-index 1 + height 100% + width 38px + pointer-events none + + > [data-fa] + display block + position absolute + top 0 + right 0 + bottom 0 + left 0 + width 1em + line-height 56px + margin auto + color #555 + + > input + margin 0 + padding 0 0 0 32px + width 100% + font-size 1em + line-height 38px + color #000 + outline none + border solid 1px #eee + border-radius 5px + box-shadow none + transition color 0.5s ease, border 0.5s ease + + &:hover + border solid 1px #ddd + transition border 0.2s ease + + &:focus + color darken($theme-color, 20%) + border solid 1px $theme-color + transition color 0, border 0 + + > .result + display block + top 0 + left 0 + z-index 2 + width 100% + margin 0 + padding 0 + background #fff + + > .users + margin 0 + padding 0 + list-style none + + > li + display inline-block + z-index 1 + width 100% + padding 8px 32px + vertical-align top + white-space nowrap + overflow hidden + color rgba(0, 0, 0, 0.8) + text-decoration none + transition none + cursor pointer + + &:hover + &:focus + color #fff + background $theme-color + + .name + color #fff + + .username + color #fff + + &:active + color #fff + background darken($theme-color, 10%) + + .name + color #fff + + .username + color #fff + + .avatar + vertical-align middle + min-width 32px + min-height 32px + max-width 32px + max-height 32px + margin 0 8px 0 0 + border-radius 6px + + .name + margin 0 8px 0 0 + /*font-weight bold*/ + font-weight normal + color rgba(0, 0, 0, 0.8) + + .username + font-weight normal + color rgba(0, 0, 0, 0.3) + + > .history + + > a + display block + text-decoration none + background #fff + border-bottom solid 1px #eee + + * + pointer-events none + user-select none + + &:hover + background #fafafa + + > .avatar + filter saturate(200%) + + &:active + background #eee + + &[data-is-read] + &[data-is-me] + opacity 0.8 + + &:not([data-is-me]):not([data-is-read]) + > div + background-image url("/assets/unread.svg") + background-repeat no-repeat + background-position 0 center + + &:after + content "" + display block + clear both + + > div + max-width 500px + margin 0 auto + padding 20px 30px + + &:after + content "" + display block + clear both + + > header + display flex + align-items center + margin-bottom 2px + white-space nowrap + overflow hidden + + > .name + margin 0 + padding 0 + overflow hidden + text-overflow ellipsis + font-size 1em + color rgba(0, 0, 0, 0.9) + font-weight bold + transition all 0.1s ease + + > .username + margin 0 8px + color rgba(0, 0, 0, 0.5) + + > .mk-time + margin 0 0 0 auto + color rgba(0, 0, 0, 0.5) + font-size 80% + + > .avatar + float left + width 54px + height 54px + margin 0 16px 0 0 + border-radius 8px + transition all 0.1s ease + + > .body + + > .text + display block + margin 0 0 0 0 + padding 0 + overflow hidden + overflow-wrap break-word + font-size 1.1em + color rgba(0, 0, 0, 0.8) + + .me + color rgba(0, 0, 0, 0.4) + + > .image + display block + max-width 100% + max-height 512px + + > .no-history + margin 0 + padding 2em 1em + text-align center + color #999 + font-weight 500 + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + + // TODO: element base media query + @media (max-width 400px) + > .search + > .result + > .users + > li + padding 8px 16px + + > .history + > a + &:not([data-is-me]):not([data-is-read]) + > div + background-image none + border-left solid 4px #3aa2dc + + > div + padding 16px + font-size 14px + + > .avatar + margin 0 12px 0 0 + +</style> diff --git a/src/client/app/common/views/components/nav.vue b/src/client/app/common/views/components/nav.vue new file mode 100644 index 0000000000..8ce75d3529 --- /dev/null +++ b/src/client/app/common/views/components/nav.vue @@ -0,0 +1,41 @@ +<template> +<span class="mk-nav"> + <a :href="aboutUrl">%i18n:common.tags.mk-nav-links.about%</a> + <i>・</i> + <a :href="statsUrl">%i18n:common.tags.mk-nav-links.stats%</a> + <i>・</i> + <a :href="statusUrl">%i18n:common.tags.mk-nav-links.status%</a> + <i>・</i> + <a href="http://zawazawa.jp/misskey/">%i18n:common.tags.mk-nav-links.wiki%</a> + <i>・</i> + <a href="https://github.com/syuilo/misskey/blob/master/DONORS.md">%i18n:common.tags.mk-nav-links.donors%</a> + <i>・</i> + <a href="https://github.com/syuilo/misskey">%i18n:common.tags.mk-nav-links.repository%</a> + <i>・</i> + <a :href="devUrl">%i18n:common.tags.mk-nav-links.develop%</a> + <i>・</i> + <a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on %fa:B twitter%</a> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { docsUrl, statsUrl, statusUrl, devUrl, lang } from '../../../config'; + +export default Vue.extend({ + data() { + return { + aboutUrl: `${docsUrl}/${lang}/about`, + statsUrl, + statusUrl, + devUrl + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-nav + a + color inherit +</style> diff --git a/src/client/app/common/views/components/othello.game.vue b/src/client/app/common/views/components/othello.game.vue new file mode 100644 index 0000000000..f08742ad10 --- /dev/null +++ b/src/client/app/common/views/components/othello.game.vue @@ -0,0 +1,324 @@ +<template> +<div class="root"> + <header><b>{{ blackUser.name }}</b>(黒) vs <b>{{ whiteUser.name }}</b>(白)</header> + + <div style="overflow: hidden"> + <p class="turn" v-if="!iAmPlayer && !game.isEnded">{{ turnUser.name }}のターンです<mk-ellipsis/></p> + <p class="turn" v-if="logPos != logs.length">{{ turnUser.name }}のターン</p> + <p class="turn1" v-if="iAmPlayer && !game.isEnded && !isMyTurn">相手のターンです<mk-ellipsis/></p> + <p class="turn2" v-if="iAmPlayer && !game.isEnded && isMyTurn" v-animate-css="{ classes: 'tada', iteration: 'infinite' }">あなたのターンです</p> + <p class="result" v-if="game.isEnded && logPos == logs.length"> + <template v-if="game.winner"><b>{{ game.winner.name }}</b>の勝ち{{ game.settings.isLlotheo ? ' (ロセオ)' : '' }}</template> + <template v-else>引き分け</template> + </p> + </div> + + <div class="board" :style="{ 'grid-template-rows': `repeat(${ game.settings.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.settings.map[0].length }, 1fr)` }"> + <div v-for="(stone, i) in o.board" + :class="{ empty: stone == null, none: o.map[i] == 'null', isEnded: game.isEnded, myTurn: !game.isEnded && isMyTurn, can: turnUser ? o.canPut(turnUser.id == blackUser.id, i) : null, prev: o.prevPos == i }" + @click="set(i)" + :title="'[' + (o.transformPosToXy(i)[0] + 1) + ', ' + (o.transformPosToXy(i)[1] + 1) + '] (' + i + ')'" + > + <img v-if="stone === true" :src="`${blackUser.avatarUrl}?thumbnail&size=128`" alt=""> + <img v-if="stone === false" :src="`${whiteUser.avatarUrl}?thumbnail&size=128`" alt=""> + </div> + </div> + + <p class="status"><b>{{ logPos }}ターン目</b> 黒:{{ o.blackCount }} 白:{{ o.whiteCount }} 合計:{{ o.blackCount + o.whiteCount }}</p> + + <div class="player" v-if="game.isEnded"> + <el-button-group> + <el-button type="primary" @click="logPos = 0" :disabled="logPos == 0">%fa:angle-double-left%</el-button> + <el-button type="primary" @click="logPos--" :disabled="logPos == 0">%fa:angle-left%</el-button> + </el-button-group> + <span>{{ logPos }} / {{ logs.length }}</span> + <el-button-group> + <el-button type="primary" @click="logPos++" :disabled="logPos == logs.length">%fa:angle-right%</el-button> + <el-button type="primary" @click="logPos = logs.length" :disabled="logPos == logs.length">%fa:angle-double-right%</el-button> + </el-button-group> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as CRC32 from 'crc-32'; +import Othello, { Color } from '../../../../../common/othello/core'; +import { url } from '../../../config'; + +export default Vue.extend({ + props: ['initGame', 'connection'], + + data() { + return { + game: null, + o: null as Othello, + logs: [], + logPos: 0, + pollingClock: null + }; + }, + + computed: { + iAmPlayer(): boolean { + if (!(this as any).os.isSignedIn) return false; + return this.game.user1Id == (this as any).os.i.id || this.game.user2Id == (this as any).os.i.id; + }, + myColor(): Color { + if (!this.iAmPlayer) return null; + if (this.game.user1Id == (this as any).os.i.id && this.game.black == 1) return true; + if (this.game.user2Id == (this as any).os.i.id && this.game.black == 2) return true; + return false; + }, + opColor(): Color { + if (!this.iAmPlayer) return null; + return this.myColor === true ? false : true; + }, + blackUser(): any { + return this.game.black == 1 ? this.game.user1 : this.game.user2; + }, + whiteUser(): any { + return this.game.black == 1 ? this.game.user2 : this.game.user1; + }, + turnUser(): any { + if (this.o.turn === true) { + return this.game.black == 1 ? this.game.user1 : this.game.user2; + } else if (this.o.turn === false) { + return this.game.black == 1 ? this.game.user2 : this.game.user1; + } else { + return null; + } + }, + isMyTurn(): boolean { + if (this.turnUser == null) return null; + return this.turnUser.id == (this as any).os.i.id; + } + }, + + watch: { + logPos(v) { + if (!this.game.isEnded) return; + this.o = new Othello(this.game.settings.map, { + isLlotheo: this.game.settings.isLlotheo, + canPutEverywhere: this.game.settings.canPutEverywhere, + loopedBoard: this.game.settings.loopedBoard + }); + this.logs.forEach((log, i) => { + if (i < v) { + this.o.put(log.color, log.pos); + } + }); + this.$forceUpdate(); + } + }, + + created() { + this.game = this.initGame; + + this.o = new Othello(this.game.settings.map, { + isLlotheo: this.game.settings.isLlotheo, + canPutEverywhere: this.game.settings.canPutEverywhere, + loopedBoard: this.game.settings.loopedBoard + }); + + this.game.logs.forEach(log => { + this.o.put(log.color, log.pos); + }); + + this.logs = this.game.logs; + this.logPos = this.logs.length; + + // 通信を取りこぼしてもいいように定期的にポーリングさせる + if (this.game.isStarted && !this.game.isEnded) { + this.pollingClock = setInterval(() => { + const crc32 = CRC32.str(this.logs.map(x => x.pos.toString()).join('')); + this.connection.send({ + type: 'check', + crc32 + }); + }, 3000); + } + }, + + mounted() { + this.connection.on('set', this.onSet); + this.connection.on('rescue', this.onRescue); + }, + + beforeDestroy() { + this.connection.off('set', this.onSet); + this.connection.off('rescue', this.onRescue); + + clearInterval(this.pollingClock); + }, + + methods: { + set(pos) { + if (this.game.isEnded) return; + if (!this.iAmPlayer) return; + if (!this.isMyTurn) return; + if (!this.o.canPut(this.myColor, pos)) return; + + this.o.put(this.myColor, pos); + + // サウンドを再生する + if ((this as any).os.isEnableSounds) { + const sound = new Audio(`${url}/assets/othello-put-me.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + + this.connection.send({ + type: 'set', + pos + }); + + this.checkEnd(); + + this.$forceUpdate(); + }, + + onSet(x) { + this.logs.push(x); + this.logPos++; + this.o.put(x.color, x.pos); + this.checkEnd(); + this.$forceUpdate(); + + // サウンドを再生する + if ((this as any).os.isEnableSounds && x.color != this.myColor) { + const sound = new Audio(`${url}/assets/othello-put-you.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + }, + + checkEnd() { + this.game.isEnded = this.o.isEnded; + if (this.game.isEnded) { + if (this.o.winner === true) { + this.game.winnerId = this.game.black == 1 ? this.game.user1Id : this.game.user2Id; + this.game.winner = this.game.black == 1 ? this.game.user1 : this.game.user2; + } else if (this.o.winner === false) { + this.game.winnerId = this.game.black == 1 ? this.game.user2Id : this.game.user1Id; + this.game.winner = this.game.black == 1 ? this.game.user2 : this.game.user1; + } else { + this.game.winnerId = null; + this.game.winner = null; + } + } + }, + + // 正しいゲーム情報が送られてきたとき + onRescue(game) { + this.game = game; + + this.o = new Othello(this.game.settings.map, { + isLlotheo: this.game.settings.isLlotheo, + canPutEverywhere: this.game.settings.canPutEverywhere, + loopedBoard: this.game.settings.loopedBoard + }); + + this.game.logs.forEach(log => { + this.o.put(log.color, log.pos, true); + }); + + this.logs = this.game.logs; + this.logPos = this.logs.length; + + this.checkEnd(); + this.$forceUpdate(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.root + text-align center + + > header + padding 8px + border-bottom dashed 1px #c4cdd4 + + > .board + display grid + grid-gap 4px + width 350px + height 350px + margin 0 auto + + > div + background transparent + border-radius 6px + overflow hidden + + * + pointer-events none + user-select none + + &.empty + border solid 2px #eee + + &.empty.can + background #eee + + &.empty.myTurn + border-color #ddd + + &.can + background #eee + cursor pointer + + &:hover + border-color darken($theme-color, 10%) + background $theme-color + + &:active + background darken($theme-color, 10%) + + &.prev + box-shadow 0 0 0 4px rgba($theme-color, 0.7) + + &.isEnded + border-color #ddd + + &.none + border-color transparent !important + + > img + display block + width 100% + height 100% + + > .graph + display grid + grid-template-columns repeat(61, 1fr) + width 300px + height 38px + margin 0 auto 16px auto + + > div + &:not(:empty) + background #ccc + + > div:first-child + background #333 + + > div:last-child + background #ccc + + > .status + margin 0 + padding 16px 0 + + > .player + padding-bottom 32px + + > span + display inline-block + margin 0 8px + min-width 70px +</style> diff --git a/src/client/app/common/views/components/othello.gameroom.vue b/src/client/app/common/views/components/othello.gameroom.vue new file mode 100644 index 0000000000..dba9ccd16d --- /dev/null +++ b/src/client/app/common/views/components/othello.gameroom.vue @@ -0,0 +1,42 @@ +<template> +<div> + <x-room v-if="!g.isStarted" :game="g" :connection="connection"/> + <x-game v-else :init-game="g" :connection="connection"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XGame from './othello.game.vue'; +import XRoom from './othello.room.vue'; +import { OthelloGameStream } from '../../scripts/streaming/othello-game'; + +export default Vue.extend({ + components: { + XGame, + XRoom + }, + props: ['game'], + data() { + return { + connection: null, + g: null + }; + }, + created() { + this.g = this.game; + this.connection = new OthelloGameStream((this as any).os, (this as any).os.i, this.game); + this.connection.on('started', this.onStarted); + }, + beforeDestroy() { + this.connection.off('started', this.onStarted); + this.connection.close(); + }, + methods: { + onStarted(game) { + Object.assign(this.g, game); + this.$forceUpdate(); + } + } +}); +</script> diff --git a/src/client/app/common/views/components/othello.room.vue b/src/client/app/common/views/components/othello.room.vue new file mode 100644 index 0000000000..a32be6b74f --- /dev/null +++ b/src/client/app/common/views/components/othello.room.vue @@ -0,0 +1,297 @@ +<template> +<div class="root"> + <header><b>{{ game.user1.name }}</b> vs <b>{{ game.user2.name }}</b></header> + + <div> + <p>ゲームの設定</p> + + <el-card class="map"> + <div slot="header"> + <el-select :class="$style.mapSelect" v-model="mapName" placeholder="マップを選択" @change="onMapChange"> + <el-option label="ランダム" :value="null"/> + <el-option-group v-for="c in mapCategories" :key="c" :label="c"> + <el-option v-for="m in maps" v-if="m.category == c" :key="m.name" :label="m.name" :value="m.name"> + <span style="float: left">{{ m.name }}</span> + <span style="float: right; color: #8492a6; font-size: 13px" v-if="m.author">(by <i>{{ m.author }}</i>)</span> + </el-option> + </el-option-group> + </el-select> + </div> + <div :class="$style.board" v-if="game.settings.map != null" :style="{ 'grid-template-rows': `repeat(${ game.settings.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.settings.map[0].length }, 1fr)` }"> + <div v-for="(x, i) in game.settings.map.join('')" + :data-none="x == ' '" + @click="onPixelClick(i, x)" + > + <template v-if="x == 'b'">%fa:circle%</template> + <template v-if="x == 'w'">%fa:circle R%</template> + </div> + </div> + </el-card> + + <el-card class="bw"> + <div slot="header"> + <span>先手/後手</span> + </div> + <el-radio v-model="game.settings.bw" label="random" @change="updateSettings">ランダム</el-radio> + <el-radio v-model="game.settings.bw" :label="1" @change="updateSettings">{{ game.user1.name }}が黒</el-radio> + <el-radio v-model="game.settings.bw" :label="2" @change="updateSettings">{{ game.user2.name }}が黒</el-radio> + </el-card> + + <el-card class="rules"> + <div slot="header"> + <span>ルール</span> + </div> + <mk-switch v-model="game.settings.isLlotheo" @change="updateSettings" text="石の少ない方が勝ち(ロセオ)"/> + <mk-switch v-model="game.settings.loopedBoard" @change="updateSettings" text="ループマップ"/> + <mk-switch v-model="game.settings.canPutEverywhere" @change="updateSettings" text="どこでも置けるモード"/> + </el-card> + + <el-card class="bot-form" v-if="form"> + <div slot="header"> + <span>Botの設定</span> + </div> + <el-alert v-for="message in messages" + :title="message.text" + :type="message.type" + :key="message.id" + /> + <template v-for="item in form"> + <mk-switch v-if="item.type == 'button'" v-model="item.value" :key="item.id" :text="item.label" @change="onChangeForm($event, item)">{{ item.desc || '' }}</mk-switch> + + <el-card v-if="item.type == 'radio'" :key="item.id"> + <div slot="header"> + <span>{{ item.label }}</span> + </div> + <el-radio v-for="(r, i) in item.items" :key="item.id + ':' + i" v-model="item.value" :label="r.value" @change="onChangeForm($event, item)">{{ r.label }}</el-radio> + </el-card> + + <el-card v-if="item.type == 'textbox'" :key="item.id"> + <div slot="header"> + <span>{{ item.label }}</span> + </div> + <el-input v-model="item.value" @change="onChangeForm($event, item)"/> + </el-card> + </template> + </el-card> + </div> + + <footer> + <p class="status"> + <template v-if="isAccepted && isOpAccepted">ゲームは数秒後に開始されます<mk-ellipsis/></template> + <template v-if="isAccepted && !isOpAccepted">相手の準備が完了するのを待っています<mk-ellipsis/></template> + <template v-if="!isAccepted && isOpAccepted">あなたの準備が完了するのを待っています</template> + <template v-if="!isAccepted && !isOpAccepted">準備中<mk-ellipsis/></template> + </p> + + <div class="actions"> + <el-button @click="exit">キャンセル</el-button> + <el-button type="primary" @click="accept" v-if="!isAccepted">準備完了</el-button> + <el-button type="primary" @click="cancel" v-if="isAccepted">準備続行</el-button> + </div> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as maps from '../../../../../common/othello/maps'; + +export default Vue.extend({ + props: ['game', 'connection'], + + data() { + return { + o: null, + isLlotheo: false, + mapName: maps.eighteight.name, + maps: maps, + form: null, + messages: [] + }; + }, + + computed: { + mapCategories(): string[] { + const categories = Object.entries(maps).map(x => x[1].category); + return categories.filter((item, pos) => categories.indexOf(item) == pos); + }, + isAccepted(): boolean { + if (this.game.user1Id == (this as any).os.i.id && this.game.user1Accepted) return true; + if (this.game.user2Id == (this as any).os.i.id && this.game.user2Accepted) return true; + return false; + }, + isOpAccepted(): boolean { + if (this.game.user1Id != (this as any).os.i.id && this.game.user1Accepted) return true; + if (this.game.user2Id != (this as any).os.i.id && this.game.user2Accepted) return true; + return false; + } + }, + + created() { + this.connection.on('change-accepts', this.onChangeAccepts); + this.connection.on('update-settings', this.onUpdateSettings); + this.connection.on('init-form', this.onInitForm); + this.connection.on('message', this.onMessage); + + if (this.game.user1Id != (this as any).os.i.id && this.game.settings.form1) this.form = this.game.settings.form1; + if (this.game.user2Id != (this as any).os.i.id && this.game.settings.form2) this.form = this.game.settings.form2; + }, + + beforeDestroy() { + this.connection.off('change-accepts', this.onChangeAccepts); + this.connection.off('update-settings', this.onUpdateSettings); + this.connection.off('init-form', this.onInitForm); + this.connection.off('message', this.onMessage); + }, + + methods: { + exit() { + + }, + + accept() { + this.connection.send({ + type: 'accept' + }); + }, + + cancel() { + this.connection.send({ + type: 'cancel-accept' + }); + }, + + onChangeAccepts(accepts) { + this.game.user1Accepted = accepts.user1; + this.game.user2Accepted = accepts.user2; + this.$forceUpdate(); + }, + + updateSettings() { + this.connection.send({ + type: 'update-settings', + settings: this.game.settings + }); + }, + + onUpdateSettings(settings) { + this.game.settings = settings; + if (this.game.settings.map == null) { + this.mapName = null; + } else { + const foundMap = Object.entries(maps).find(x => x[1].data.join('') == this.game.settings.map.join('')); + this.mapName = foundMap ? foundMap[1].name : '-Custom-'; + } + }, + + onInitForm(x) { + if (x.userId == (this as any).os.i.id) return; + this.form = x.form; + }, + + onMessage(x) { + if (x.userId == (this as any).os.i.id) return; + this.messages.unshift(x.message); + }, + + onChangeForm(v, item) { + this.connection.send({ + type: 'update-form', + id: item.id, + value: v + }); + }, + + onMapChange(v) { + if (v == null) { + this.game.settings.map = null; + } else { + this.game.settings.map = Object.entries(maps).find(x => x[1].name == v)[1].data; + } + this.$forceUpdate(); + this.updateSettings(); + }, + + onPixelClick(pos, pixel) { + const x = pos % this.game.settings.map[0].length; + const y = Math.floor(pos / this.game.settings.map[0].length); + const newPixel = + pixel == ' ' ? '-' : + pixel == '-' ? 'b' : + pixel == 'b' ? 'w' : + ' '; + const line = this.game.settings.map[y].split(''); + line[x] = newPixel; + this.$set(this.game.settings.map, y, line.join('')); + this.$forceUpdate(); + this.updateSettings(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.root + text-align center + background #f9f9f9 + + > header + padding 8px + border-bottom dashed 1px #c4cdd4 + + > div + padding 0 16px + + > .map + > .bw + > .rules + > .bot-form + max-width 400px + margin 0 auto 16px auto + + > footer + position sticky + bottom 0 + padding 16px + background rgba(255, 255, 255, 0.9) + border-top solid 1px #c4cdd4 + + > .status + margin 0 0 16px 0 +</style> + +<style lang="stylus" module> +.mapSelect + width 100% + +.board + display grid + grid-gap 4px + width 300px + height 300px + margin 0 auto + + > div + background transparent + border solid 2px #ddd + border-radius 6px + overflow hidden + cursor pointer + + * + pointer-events none + user-select none + width 100% + height 100% + + &[data-none] + border-color transparent + +</style> + +<style lang="stylus"> +.el-alert__content + position initial !important +</style> diff --git a/src/client/app/common/views/components/othello.vue b/src/client/app/common/views/components/othello.vue new file mode 100644 index 0000000000..8f7d9dfd6a --- /dev/null +++ b/src/client/app/common/views/components/othello.vue @@ -0,0 +1,311 @@ +<template> +<div class="mk-othello"> + <div v-if="game"> + <x-gameroom :game="game"/> + </div> + <div class="matching" v-else-if="matching"> + <h1><b>{{ matching.name }}</b>を待っています<mk-ellipsis/></h1> + <div class="cancel"> + <el-button round @click="cancel">キャンセル</el-button> + </div> + </div> + <div class="index" v-else> + <h1>Misskey %fa:circle%thell%fa:circle R%</h1> + <p>他のMisskeyユーザーとオセロで対戦しよう</p> + <div class="play"> + <el-button round>フリーマッチ(準備中)</el-button> + <el-button type="primary" round @click="match">指名</el-button> + <details> + <summary>遊び方</summary> + <div> + <p>オセロは、相手と交互に石をボードに置いてゆき、相手の石を挟んでひっくり返しながら、最終的に残った石が多い方が勝ちというボードゲームです。</p> + <dl> + <dt><b>フリーマッチ</b></dt> + <dd>ランダムなユーザーと対戦するモードです。</dd> + <dt><b>指名</b></dt> + <dd>指定したユーザーと対戦するモードです。</dd> + </dl> + </div> + </details> + </div> + <section v-if="invitations.length > 0"> + <h2>対局の招待があります!:</h2> + <div class="invitation" v-for="i in invitations" tabindex="-1" @click="accept(i)"> + <img :src="`${i.parent.avatarUrl}?thumbnail&size=32`" alt=""> + <span class="name"><b>{{ i.parent.name }}</b></span> + <span class="username">@{{ i.parent.username }}</span> + <mk-time :time="i.createdAt"/> + </div> + </section> + <section v-if="myGames.length > 0"> + <h2>自分の対局</h2> + <a class="game" v-for="g in myGames" tabindex="-1" @click.prevent="go(g)" :href="`/othello/${g.id}`"> + <img :src="`${g.user1.avatarUrl}?thumbnail&size=32`" alt=""> + <img :src="`${g.user2.avatarUrl}?thumbnail&size=32`" alt=""> + <span><b>{{ g.user1.name }}</b> vs <b>{{ g.user2.name }}</b></span> + <span class="state">{{ g.isEnded ? '終了' : '進行中' }}</span> + </a> + </section> + <section v-if="games.length > 0"> + <h2>みんなの対局</h2> + <a class="game" v-for="g in games" tabindex="-1" @click.prevent="go(g)" :href="`/othello/${g.id}`"> + <img :src="`${g.user1.avatarUrl}?thumbnail&size=32`" alt=""> + <img :src="`${g.user2.avatarUrl}?thumbnail&size=32`" alt=""> + <span><b>{{ g.user1.name }}</b> vs <b>{{ g.user2.name }}</b></span> + <span class="state">{{ g.isEnded ? '終了' : '進行中' }}</span> + </a> + </section> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XGameroom from './othello.gameroom.vue'; + +export default Vue.extend({ + components: { + XGameroom + }, + props: ['initGame'], + data() { + return { + game: null, + games: [], + gamesFetching: true, + gamesMoreFetching: false, + myGames: [], + matching: null, + invitations: [], + connection: null, + connectionId: null, + pingClock: null + }; + }, + watch: { + game(g) { + this.$emit('gamed', g); + } + }, + created() { + if (this.initGame) { + this.game = this.initGame; + } + }, + mounted() { + this.connection = (this as any).os.streams.othelloStream.getConnection(); + this.connectionId = (this as any).os.streams.othelloStream.use(); + + this.connection.on('matched', this.onMatched); + this.connection.on('invited', this.onInvited); + + (this as any).api('othello/games', { + my: true + }).then(games => { + this.myGames = games; + }); + + (this as any).api('othello/games').then(games => { + this.games = games; + this.gamesFetching = false; + }); + + (this as any).api('othello/invitations').then(invitations => { + this.invitations = this.invitations.concat(invitations); + }); + + this.pingClock = setInterval(() => { + if (this.matching) { + this.connection.send({ + type: 'ping', + id: this.matching.id + }); + } + }, 3000); + }, + beforeDestroy() { + this.connection.off('matched', this.onMatched); + this.connection.off('invited', this.onInvited); + (this as any).os.streams.othelloStream.dispose(this.connectionId); + + clearInterval(this.pingClock); + }, + methods: { + go(game) { + (this as any).api('othello/games/show', { + gameId: game.id + }).then(game => { + this.matching = null; + this.game = game; + }); + }, + match() { + (this as any).apis.input({ + title: 'ユーザー名を入力してください' + }).then(username => { + (this as any).api('users/show', { + username + }).then(user => { + (this as any).api('othello/match', { + userId: user.id + }).then(res => { + if (res == null) { + this.matching = user; + } else { + this.game = res; + } + }); + }); + }); + }, + cancel() { + this.matching = null; + (this as any).api('othello/match/cancel'); + }, + accept(invitation) { + (this as any).api('othello/match', { + userId: invitation.parent.id + }).then(game => { + if (game) { + this.matching = null; + this.game = game; + } + }); + }, + onMatched(game) { + this.matching = null; + this.game = game; + }, + onInvited(invite) { + this.invitations.unshift(invite); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-othello + color #677f84 + background #fff + + > .matching + > h1 + margin 0 + padding 24px + font-size 20px + text-align center + font-weight normal + + > .cancel + margin 0 auto + padding 24px 0 0 0 + max-width 200px + text-align center + border-top dashed 1px #c4cdd4 + + > .index + > h1 + margin 0 + padding 24px + font-size 24px + text-align center + font-weight normal + color #fff + background linear-gradient(to bottom, #8bca3e, #d6cf31) + + & + p + margin 0 + padding 12px + margin-bottom 12px + text-align center + font-size 14px + border-bottom solid 1px #d3d9dc + + > .play + margin 0 auto + padding 0 16px + max-width 500px + text-align center + + > details + margin 8px 0 + + > div + padding 16px + font-size 14px + text-align left + background #f5f5f5 + border-radius 8px + + > section + margin 0 auto + padding 0 16px 16px 16px + max-width 500px + border-top solid 1px #d3d9dc + + > h2 + margin 0 + padding 16px 0 8px 0 + font-size 16px + font-weight bold + + .invitation + margin 8px 0 + padding 8px + border solid 1px #e1e5e8 + border-radius 6px + cursor pointer + + * + pointer-events none + user-select none + + &:focus + border-color $theme-color + + &:hover + background #f5f5f5 + + &:active + background #eee + + > img + vertical-align bottom + border-radius 100% + + > span + margin 0 8px + line-height 32px + + .game + display block + margin 8px 0 + padding 8px + color #677f84 + border solid 1px #e1e5e8 + border-radius 6px + cursor pointer + + * + pointer-events none + user-select none + + &:focus + border-color $theme-color + + &:hover + background #f5f5f5 + + &:active + background #eee + + > img + vertical-align bottom + border-radius 100% + + > span + margin 0 8px + line-height 32px +</style> diff --git a/src/client/app/common/views/components/poll-editor.vue b/src/client/app/common/views/components/poll-editor.vue new file mode 100644 index 0000000000..47d901d7b1 --- /dev/null +++ b/src/client/app/common/views/components/poll-editor.vue @@ -0,0 +1,142 @@ +<template> +<div class="mk-poll-editor"> + <p class="caution" v-if="choices.length < 2"> + %fa:exclamation-triangle%%i18n:common.tags.mk-poll-editor.no-only-one-choice% + </p> + <ul ref="choices"> + <li v-for="(choice, i) in choices"> + <input :value="choice" @input="onInput(i, $event)" :placeholder="'%i18n:common.tags.mk-poll-editor.choice-n%'.replace('{}', i + 1)"> + <button @click="remove(i)" title="%i18n:common.tags.mk-poll-editor.remove%"> + %fa:times% + </button> + </li> + </ul> + <button class="add" v-if="choices.length < 10" @click="add">%i18n:common.tags.mk-poll-editor.add%</button> + <button class="destroy" @click="destroy" title="%i18n:common.tags.mk-poll-editor.destroy%"> + %fa:times% + </button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + choices: ['', ''] + }; + }, + watch: { + choices() { + this.$emit('updated'); + } + }, + methods: { + onInput(i, e) { + Vue.set(this.choices, i, e.target.value); + }, + + add() { + this.choices.push(''); + this.$nextTick(() => { + (this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus(); + }); + }, + + remove(i) { + this.choices = this.choices.filter((_, _i) => _i != i); + }, + + destroy() { + this.$emit('destroyed'); + }, + + get() { + return { + choices: this.choices.filter(choice => choice != '') + } + }, + + set(data) { + if (data.choices.length == 0) return; + this.choices = data.choices; + if (data.choices.length == 1) this.choices = this.choices.concat(''); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-poll-editor + padding 8px + + > .caution + margin 0 0 8px 0 + font-size 0.8em + color #f00 + + > [data-fa] + margin-right 4px + + > ul + display block + margin 0 + padding 0 + list-style none + + > li + display block + margin 8px 0 + padding 0 + width 100% + + &:first-child + margin-top 0 + + &:last-child + margin-bottom 0 + + > input + padding 6px 8px + width 300px + font-size 14px + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + + &:hover + border-color rgba($theme-color, 0.2) + + &:focus + border-color rgba($theme-color, 0.5) + + > button + padding 4px 8px + color rgba($theme-color, 0.4) + + &:hover + color rgba($theme-color, 0.6) + + &:active + color darken($theme-color, 30%) + + > .add + margin 8px 0 0 0 + vertical-align top + color $theme-color + + > .destroy + position absolute + top 0 + right 0 + padding 4px 8px + color rgba($theme-color, 0.4) + + &:hover + color rgba($theme-color, 0.6) + + &:active + color darken($theme-color, 30%) + +</style> diff --git a/src/client/app/common/views/components/poll.vue b/src/client/app/common/views/components/poll.vue new file mode 100644 index 0000000000..711d89720e --- /dev/null +++ b/src/client/app/common/views/components/poll.vue @@ -0,0 +1,124 @@ +<template> +<div class="mk-poll" :data-is-voted="isVoted"> + <ul> + <li v-for="choice in poll.choices" :key="choice.id" @click="vote(choice.id)" :class="{ voted: choice.voted }" :title="!isVoted ? '%i18n:common.tags.mk-poll.vote-to%'.replace('{}', choice.text) : ''"> + <div class="backdrop" :style="{ 'width': (showResult ? (choice.votes / total * 100) : 0) + '%' }"></div> + <span> + <template v-if="choice.isVoted">%fa:check%</template> + <span>{{ choice.text }}</span> + <span class="votes" v-if="showResult">({{ '%i18n:common.tags.mk-poll.vote-count%'.replace('{}', choice.votes) }})</span> + </span> + </li> + </ul> + <p v-if="total > 0"> + <span>{{ '%i18n:common.tags.mk-poll.total-users%'.replace('{}', total) }}</span> + <span>・</span> + <a v-if="!isVoted" @click="toggleShowResult">{{ showResult ? '%i18n:common.tags.mk-poll.vote%' : '%i18n:common.tags.mk-poll.show-result%' }}</a> + <span v-if="isVoted">%i18n:common.tags.mk-poll.voted%</span> + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['post'], + data() { + return { + showResult: false + }; + }, + computed: { + poll(): any { + return this.post.poll; + }, + total(): number { + return this.poll.choices.reduce((a, b) => a + b.votes, 0); + }, + isVoted(): boolean { + return this.poll.choices.some(c => c.isVoted); + } + }, + created() { + this.showResult = this.isVoted; + }, + methods: { + toggleShowResult() { + this.showResult = !this.showResult; + }, + vote(id) { + if (this.poll.choices.some(c => c.isVoted)) return; + (this as any).api('posts/polls/vote', { + postId: this.post.id, + choice: id + }).then(() => { + this.poll.choices.forEach(c => { + if (c.id == id) { + c.votes++; + Vue.set(c, 'isVoted', true); + } + }); + this.showResult = true; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-poll + + > ul + display block + margin 0 + padding 0 + list-style none + + > li + display block + margin 4px 0 + padding 4px 8px + width 100% + border solid 1px #eee + border-radius 4px + overflow hidden + cursor pointer + + &:hover + background rgba(0, 0, 0, 0.05) + + &:active + background rgba(0, 0, 0, 0.1) + + > .backdrop + position absolute + top 0 + left 0 + height 100% + background $theme-color + transition width 1s ease + + > span + > [data-fa] + margin-right 4px + + > .votes + margin-left 4px + + > p + a + color inherit + + &[data-is-voted] + > ul > li + cursor default + + &:hover + background transparent + + &:active + background transparent + +</style> diff --git a/src/client/app/common/views/components/post-html.ts b/src/client/app/common/views/components/post-html.ts new file mode 100644 index 0000000000..98da86617d --- /dev/null +++ b/src/client/app/common/views/components/post-html.ts @@ -0,0 +1,137 @@ +import Vue from 'vue'; +import * as emojilib from 'emojilib'; +import getAcct from '../../../../../common/user/get-acct'; +import { url } from '../../../config'; +import MkUrl from './url.vue'; + +const flatten = list => list.reduce( + (a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), [] +); + +export default Vue.component('mk-post-html', { + props: { + ast: { + type: Array, + required: true + }, + shouldBreak: { + type: Boolean, + default: true + }, + i: { + type: Object, + default: null + } + }, + render(createElement) { + const els = flatten((this as any).ast.map(token => { + switch (token.type) { + case 'text': + const text = token.content.replace(/(\r\n|\n|\r)/g, '\n'); + + if ((this as any).shouldBreak) { + const x = text.split('\n') + .map(t => t == '' ? [createElement('br')] : [createElement('span', t), createElement('br')]); + x[x.length - 1].pop(); + return x; + } else { + return createElement('span', text.replace(/\n/g, ' ')); + } + + case 'bold': + return createElement('strong', token.bold); + + case 'url': + return createElement(MkUrl, { + props: { + url: token.content, + target: '_blank' + } + }); + + case 'link': + return createElement('a', { + attrs: { + class: 'link', + href: token.url, + target: '_blank', + title: token.url + } + }, token.title); + + case 'mention': + return (createElement as any)('a', { + attrs: { + href: `${url}/@${getAcct(token)}`, + target: '_blank', + dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token) + }, + directives: [{ + name: 'user-preview', + value: token.content + }] + }, token.content); + + case 'hashtag': + return createElement('a', { + attrs: { + href: `${url}/search?q=${token.content}`, + target: '_blank' + } + }, token.content); + + case 'code': + return createElement('pre', [ + createElement('code', { + domProps: { + innerHTML: token.html + } + }) + ]); + + case 'inline-code': + return createElement('code', token.html); + + case 'quote': + const text2 = token.quote.replace(/(\r\n|\n|\r)/g, '\n'); + + if ((this as any).shouldBreak) { + const x = text2.split('\n') + .map(t => [createElement('span', t), createElement('br')]); + x[x.length - 1].pop(); + return createElement('div', { + attrs: { + class: 'quote' + } + }, x); + } else { + return createElement('span', { + attrs: { + class: 'quote' + } + }, text2.replace(/\n/g, ' ')); + } + + case 'emoji': + const emoji = emojilib.lib[token.emoji]; + return createElement('span', emoji ? emoji.char : token.content); + + default: + console.log('unknown ast type:', token.type); + } + })); + + const _els = []; + els.forEach((el, i) => { + if (el.tag == 'br') { + if (els[i - 1].tag != 'div') { + _els.push(el); + } + } else { + _els.push(el); + } + }); + + return createElement('span', _els); + } +}); diff --git a/src/client/app/common/views/components/post-menu.vue b/src/client/app/common/views/components/post-menu.vue new file mode 100644 index 0000000000..35116db7e2 --- /dev/null +++ b/src/client/app/common/views/components/post-menu.vue @@ -0,0 +1,141 @@ +<template> +<div class="mk-post-menu"> + <div class="backdrop" ref="backdrop" @click="close"></div> + <div class="popover" :class="{ compact }" ref="popover"> + <button v-if="post.userId == os.i.id" @click="pin">%i18n:common.tags.mk-post-menu.pin%</button> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['post', 'source', 'compact'], + mounted() { + this.$nextTick(() => { + const popover = this.$refs.popover as any; + + const rect = this.source.getBoundingClientRect(); + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + if (this.compact) { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); + popover.style.left = (x - (width / 2)) + 'px'; + popover.style.top = (y - (height / 2)) + 'px'; + } else { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + this.source.offsetHeight; + popover.style.left = (x - (width / 2)) + 'px'; + popover.style.top = y + 'px'; + } + + anime({ + targets: this.$refs.backdrop, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.$refs.popover, + opacity: 1, + scale: [0.5, 1], + duration: 500 + }); + }); + }, + methods: { + pin() { + (this as any).api('i/pin', { + postId: this.post.id + }).then(() => { + this.$destroy(); + }); + }, + + close() { + (this.$refs.backdrop as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.backdrop, + opacity: 0, + duration: 200, + easing: 'linear' + }); + + (this.$refs.popover as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.popover, + opacity: 0, + scale: 0.5, + duration: 200, + easing: 'easeInBack', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +$border-color = rgba(27, 31, 35, 0.15) + +.mk-post-menu + position initial + + > .backdrop + position fixed + top 0 + left 0 + z-index 10000 + width 100% + height 100% + background rgba(0, 0, 0, 0.1) + opacity 0 + + > .popover + position absolute + z-index 10001 + background #fff + border 1px solid $border-color + border-radius 4px + box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) + transform scale(0.5) + opacity 0 + + $balloon-size = 16px + + &:not(.compact) + margin-top $balloon-size + transform-origin center -($balloon-size) + + &:before + content "" + display block + position absolute + top -($balloon-size * 2) + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size $border-color + + &:after + content "" + display block + position absolute + top -($balloon-size * 2) + 1.5px + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size #fff + + > button + display block + padding 16px + +</style> diff --git a/src/client/app/common/views/components/reaction-icon.vue b/src/client/app/common/views/components/reaction-icon.vue new file mode 100644 index 0000000000..7d24f4f9e9 --- /dev/null +++ b/src/client/app/common/views/components/reaction-icon.vue @@ -0,0 +1,28 @@ +<template> +<span class="mk-reaction-icon"> + <img v-if="reaction == 'like'" src="/assets/reactions/like.png" alt="%i18n:common.reactions.like%"> + <img v-if="reaction == 'love'" src="/assets/reactions/love.png" alt="%i18n:common.reactions.love%"> + <img v-if="reaction == 'laugh'" src="/assets/reactions/laugh.png" alt="%i18n:common.reactions.laugh%"> + <img v-if="reaction == 'hmm'" src="/assets/reactions/hmm.png" alt="%i18n:common.reactions.hmm%"> + <img v-if="reaction == 'surprise'" src="/assets/reactions/surprise.png" alt="%i18n:common.reactions.surprise%"> + <img v-if="reaction == 'congrats'" src="/assets/reactions/congrats.png" alt="%i18n:common.reactions.congrats%"> + <img v-if="reaction == 'angry'" src="/assets/reactions/angry.png" alt="%i18n:common.reactions.angry%"> + <img v-if="reaction == 'confused'" src="/assets/reactions/confused.png" alt="%i18n:common.reactions.confused%"> + <img v-if="reaction == 'pudding'" src="/assets/reactions/pudding.png" alt="%i18n:common.reactions.pudding%"> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['reaction'] +}); +</script> + +<style lang="stylus" scoped> +.mk-reaction-icon + img + vertical-align middle + width 1em + height 1em +</style> diff --git a/src/client/app/common/views/components/reaction-picker.vue b/src/client/app/common/views/components/reaction-picker.vue new file mode 100644 index 0000000000..bcb6b2b965 --- /dev/null +++ b/src/client/app/common/views/components/reaction-picker.vue @@ -0,0 +1,191 @@ +<template> +<div class="mk-reaction-picker"> + <div class="backdrop" ref="backdrop" @click="close"></div> + <div class="popover" :class="{ compact }" ref="popover"> + <p v-if="!compact">{{ title }}</p> + <div> + <button @click="react('like')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="1" title="%i18n:common.reactions.like%"><mk-reaction-icon reaction='like'/></button> + <button @click="react('love')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="2" title="%i18n:common.reactions.love%"><mk-reaction-icon reaction='love'/></button> + <button @click="react('laugh')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="3" title="%i18n:common.reactions.laugh%"><mk-reaction-icon reaction='laugh'/></button> + <button @click="react('hmm')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" title="%i18n:common.reactions.hmm%"><mk-reaction-icon reaction='hmm'/></button> + <button @click="react('surprise')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" title="%i18n:common.reactions.surprise%"><mk-reaction-icon reaction='surprise'/></button> + <button @click="react('congrats')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" title="%i18n:common.reactions.congrats%"><mk-reaction-icon reaction='congrats'/></button> + <button @click="react('angry')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" title="%i18n:common.reactions.angry%"><mk-reaction-icon reaction='angry'/></button> + <button @click="react('confused')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" title="%i18n:common.reactions.confused%"><mk-reaction-icon reaction='confused'/></button> + <button @click="react('pudding')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" title="%i18n:common.reactions.pudding%"><mk-reaction-icon reaction='pudding'/></button> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +const placeholder = '%i18n:common.tags.mk-reaction-picker.choose-reaction%'; + +export default Vue.extend({ + props: ['post', 'source', 'compact', 'cb'], + data() { + return { + title: placeholder + }; + }, + mounted() { + this.$nextTick(() => { + const popover = this.$refs.popover as any; + + const rect = this.source.getBoundingClientRect(); + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + if (this.compact) { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); + popover.style.left = (x - (width / 2)) + 'px'; + popover.style.top = (y - (height / 2)) + 'px'; + } else { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + this.source.offsetHeight; + popover.style.left = (x - (width / 2)) + 'px'; + popover.style.top = y + 'px'; + } + + anime({ + targets: this.$refs.backdrop, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.$refs.popover, + opacity: 1, + scale: [0.5, 1], + duration: 500 + }); + }); + }, + methods: { + react(reaction) { + (this as any).api('posts/reactions/create', { + postId: this.post.id, + reaction: reaction + }).then(() => { + if (this.cb) this.cb(); + this.$destroy(); + }); + }, + onMouseover(e) { + this.title = e.target.title; + }, + onMouseout(e) { + this.title = placeholder; + }, + close() { + (this.$refs.backdrop as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.backdrop, + opacity: 0, + duration: 200, + easing: 'linear' + }); + + (this.$refs.popover as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.popover, + opacity: 0, + scale: 0.5, + duration: 200, + easing: 'easeInBack', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +$border-color = rgba(27, 31, 35, 0.15) + +.mk-reaction-picker + position initial + + > .backdrop + position fixed + top 0 + left 0 + z-index 10000 + width 100% + height 100% + background rgba(0, 0, 0, 0.1) + opacity 0 + + > .popover + position absolute + z-index 10001 + background #fff + border 1px solid $border-color + border-radius 4px + box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) + transform scale(0.5) + opacity 0 + + $balloon-size = 16px + + &:not(.compact) + margin-top $balloon-size + transform-origin center -($balloon-size) + + &:before + content "" + display block + position absolute + top -($balloon-size * 2) + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size $border-color + + &:after + content "" + display block + position absolute + top -($balloon-size * 2) + 1.5px + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size #fff + + > p + display block + margin 0 + padding 8px 10px + font-size 14px + color #586069 + border-bottom solid 1px #e1e4e8 + + > div + padding 4px + width 240px + text-align center + + > button + padding 0 + width 40px + height 40px + font-size 24px + border-radius 2px + + &:hover + background #eee + + &:active + background $theme-color + box-shadow inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15) + +</style> diff --git a/src/client/app/common/views/components/reactions-viewer.vue b/src/client/app/common/views/components/reactions-viewer.vue new file mode 100644 index 0000000000..246451008f --- /dev/null +++ b/src/client/app/common/views/components/reactions-viewer.vue @@ -0,0 +1,49 @@ +<template> +<div class="mk-reactions-viewer"> + <template v-if="reactions"> + <span v-if="reactions.like"><mk-reaction-icon reaction='like'/><span>{{ reactions.like }}</span></span> + <span v-if="reactions.love"><mk-reaction-icon reaction='love'/><span>{{ reactions.love }}</span></span> + <span v-if="reactions.laugh"><mk-reaction-icon reaction='laugh'/><span>{{ reactions.laugh }}</span></span> + <span v-if="reactions.hmm"><mk-reaction-icon reaction='hmm'/><span>{{ reactions.hmm }}</span></span> + <span v-if="reactions.surprise"><mk-reaction-icon reaction='surprise'/><span>{{ reactions.surprise }}</span></span> + <span v-if="reactions.congrats"><mk-reaction-icon reaction='congrats'/><span>{{ reactions.congrats }}</span></span> + <span v-if="reactions.angry"><mk-reaction-icon reaction='angry'/><span>{{ reactions.angry }}</span></span> + <span v-if="reactions.confused"><mk-reaction-icon reaction='confused'/><span>{{ reactions.confused }}</span></span> + <span v-if="reactions.pudding"><mk-reaction-icon reaction='pudding'/><span>{{ reactions.pudding }}</span></span> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['post'], + computed: { + reactions(): number { + return this.post.reactionCounts; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-reactions-viewer + border-top dashed 1px #eee + border-bottom dashed 1px #eee + margin 4px 0 + + &:empty + display none + + > span + margin-right 8px + + > .mk-reaction-icon + font-size 1.4em + + > span + margin-left 4px + font-size 1.2em + color #444 + +</style> diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue new file mode 100644 index 0000000000..17154e6b31 --- /dev/null +++ b/src/client/app/common/views/components/signin.vue @@ -0,0 +1,142 @@ +<template> +<form class="mk-signin" :class="{ signing }" @submit.prevent="onSubmit"> + <label class="user-name"> + <input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" placeholder="%i18n:common.tags.mk-signin.username%" autofocus required @change="onUsernameChange"/>%fa:at% + </label> + <label class="password"> + <input v-model="password" type="password" placeholder="%i18n:common.tags.mk-signin.password%" required/>%fa:lock% + </label> + <label class="token" v-if="user && user.account.twoFactorEnabled"> + <input v-model="token" type="number" placeholder="%i18n:common.tags.mk-signin.token%" required/>%fa:lock% + </label> + <button type="submit" :disabled="signing">{{ signing ? '%i18n:common.tags.mk-signin.signing-in%' : '%i18n:common.tags.mk-signin.signin%' }}</button> + もしくは <a :href="`${apiUrl}/signin/twitter`">Twitterでログイン</a> +</form> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { apiUrl } from '../../../config'; + +export default Vue.extend({ + data() { + return { + signing: false, + user: null, + username: '', + password: '', + token: '', + apiUrl, + }; + }, + 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> +@import '~const.styl' + +.mk-signin + &.signing + &, * + cursor wait !important + + label + display block + margin 12px 0 + + [data-fa] + display block + pointer-events none + position absolute + bottom 0 + top 0 + left 0 + z-index 1 + margin auto + padding 0 16px + height 1em + color #898786 + + input[type=text] + input[type=password] + input[type=number] + user-select text + display inline-block + cursor auto + padding 0 0 0 38px + margin 0 + width 100% + line-height 44px + font-size 1em + color rgba(0, 0, 0, 0.7) + background #fff + outline none + border solid 1px #eee + border-radius 4px + + &:hover + background rgba(255, 255, 255, 0.7) + border-color #ddd + + & + i + color #797776 + + &:focus + background #fff + border-color #ccc + + & + i + color #797776 + + [type=submit] + cursor pointer + padding 16px + margin -6px 0 0 0 + width 100% + font-size 1.2em + color rgba(0, 0, 0, 0.5) + outline none + border none + border-radius 0 + background transparent + transition all .5s ease + + &:hover + color $theme-color + transition all .2s ease + + &:focus + color $theme-color + transition all .2s ease + + &:active + color darken($theme-color, 30%) + transition all .2s ease + + &:disabled + opacity 0.7 + +</style> diff --git a/src/client/app/common/views/components/signup.vue b/src/client/app/common/views/components/signup.vue new file mode 100644 index 0000000000..e77d849ade --- /dev/null +++ b/src/client/app/common/views/components/signup.vue @@ -0,0 +1,287 @@ +<template> +<form class="mk-signup" @submit.prevent="onSubmit" autocomplete="off"> + <label class="username"> + <p class="caption">%fa:at%%i18n:common.tags.mk-signup.username%</p> + <input v-model="username" type="text" pattern="^[a-zA-Z0-9_]{3,20}$" placeholder="a~z、A~Z、0~9、-" autocomplete="off" required @input="onChangeUsername"/> + <p class="profile-page-url-preview" v-if="shouldShowProfileUrl">{{ `${url}/@${username}` }}</p> + <p class="info" v-if="usernameState == 'wait'" style="color:#999">%fa:spinner .pulse .fw%%i18n:common.tags.mk-signup.checking%</p> + <p class="info" v-if="usernameState == 'ok'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.available%</p> + <p class="info" v-if="usernameState == 'unavailable'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.unavailable%</p> + <p class="info" v-if="usernameState == 'error'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.error%</p> + <p class="info" v-if="usernameState == 'invalid-format'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.invalid-format%</p> + <p class="info" v-if="usernameState == 'min-range'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.too-short%</p> + <p class="info" v-if="usernameState == 'max-range'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.too-long%</p> + </label> + <label class="password"> + <p class="caption">%fa:lock%%i18n:common.tags.mk-signup.password%</p> + <input v-model="password" type="password" placeholder="%i18n:common.tags.mk-signup.password-placeholder%" autocomplete="off" required @input="onChangePassword"/> + <div class="meter" v-show="passwordStrength != ''" :data-strength="passwordStrength"> + <div class="value" ref="passwordMetar"></div> + </div> + <p class="info" v-if="passwordStrength == 'low'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.weak-password%</p> + <p class="info" v-if="passwordStrength == 'medium'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.normal-password%</p> + <p class="info" v-if="passwordStrength == 'high'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.strong-password%</p> + </label> + <label class="retype-password"> + <p class="caption">%fa:lock%%i18n:common.tags.mk-signup.password%(%i18n:common.tags.mk-signup.retype%)</p> + <input v-model="retypedPassword" type="password" placeholder="%i18n:common.tags.mk-signup.retype-placeholder%" autocomplete="off" required @input="onChangePasswordRetype"/> + <p class="info" v-if="passwordRetypeState == 'match'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.password-matched%</p> + <p class="info" v-if="passwordRetypeState == 'not-match'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.password-not-matched%</p> + </label> + <label class="recaptcha"> + <p class="caption"><template v-if="recaptchaed">%fa:toggle-on%</template><template v-if="!recaptchaed">%fa:toggle-off%</template>%i18n:common.tags.mk-signup.recaptcha%</p> + <div class="g-recaptcha" data-callback="onRecaptchaed" data-expired-callback="onRecaptchaExpired" :data-sitekey="recaptchaSitekey"></div> + </label> + <label class="agree-tou"> + <input name="agree-tou" type="checkbox" autocomplete="off" required/> + <p><a :href="touUrl" target="_blank">利用規約</a>に同意する</p> + </label> + <button type="submit">%i18n:common.tags.mk-signup.create%</button> +</form> +</template> + +<script lang="ts"> +import Vue from 'vue'; +const getPasswordStrength = require('syuilo-password-strength'); +import { url, docsUrl, lang, recaptchaSitekey } from '../../../config'; + +export default Vue.extend({ + data() { + return { + username: '', + password: '', + retypedPassword: '', + url, + touUrl: `${docsUrl}/${lang}/tou`, + recaptchaSitekey, + recaptchaed: false, + usernameState: null, + passwordStrength: '', + passwordRetypeState: null + } + }, + computed: { + shouldShowProfileUrl(): boolean { + return (this.username != '' && + this.usernameState != 'invalid-format' && + this.usernameState != 'min-range' && + this.usernameState != 'max-range'); + } + }, + methods: { + onChangeUsername() { + if (this.username == '') { + this.usernameState = null; + return; + } + + const err = + !this.username.match(/^[a-zA-Z0-9\-]+$/) ? 'invalid-format' : + this.username.length < 3 ? 'min-range' : + this.username.length > 20 ? 'max-range' : + null; + + if (err) { + this.usernameState = err; + return; + } + + this.usernameState = 'wait'; + + (this as any).api('username/available', { + username: this.username + }).then(result => { + this.usernameState = result.available ? 'ok' : 'unavailable'; + }).catch(err => { + this.usernameState = 'error'; + }); + }, + onChangePassword() { + if (this.password == '') { + this.passwordStrength = ''; + return; + } + + const strength = getPasswordStrength(this.password); + this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; + (this.$refs.passwordMetar as any).style.width = `${strength * 100}%`; + }, + onChangePasswordRetype() { + if (this.retypedPassword == '') { + this.passwordRetypeState = null; + return; + } + + this.passwordRetypeState = this.password == this.retypedPassword ? 'match' : 'not-match'; + }, + onSubmit() { + (this as any).api('signup', { + username: this.username, + password: this.password, + 'g-recaptcha-response': (window as any).grecaptcha.getResponse() + }).then(() => { + (this as any).api('signin', { + username: this.username, + password: this.password + }).then(() => { + location.href = '/'; + }); + }).catch(() => { + alert('%i18n:common.tags.mk-signup.some-error%'); + + (window as any).grecaptcha.reset(); + this.recaptchaed = false; + }); + } + }, + created() { + (window as any).onRecaptchaed = () => { + this.recaptchaed = true; + }; + + (window as any).onRecaptchaExpired = () => { + this.recaptchaed = false; + }; + }, + mounted() { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://www.google.com/recaptcha/api.js'); + head.appendChild(script); + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-signup + min-width 302px + + label + display block + margin 0 0 16px 0 + + > .caption + margin 0 0 4px 0 + color #828888 + font-size 0.95em + + > [data-fa] + margin-right 0.25em + color #96adac + + > .info + display block + margin 4px 0 + font-size 0.8em + + > [data-fa] + margin-right 0.3em + + &.username + .profile-page-url-preview + display block + margin 4px 8px 0 4px + font-size 0.8em + color #888 + + &:empty + display none + + &:not(:empty) + .info + margin-top 0 + + &.password + .meter + display block + margin-top 8px + width 100% + height 8px + + &[data-strength=''] + display none + + &[data-strength='low'] + > .value + background #d73612 + + &[data-strength='medium'] + > .value + background #d7ca12 + + &[data-strength='high'] + > .value + background #61bb22 + + > .value + display block + width 0% + height 100% + background transparent + border-radius 4px + transition all 0.1s ease + + [type=text], [type=password] + user-select text + display inline-block + cursor auto + padding 0 12px + margin 0 + width 100% + line-height 44px + font-size 1em + color #333 !important + background #fff !important + outline none + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + box-shadow 0 0 0 114514px #fff inset + transition all .3s ease + + &:hover + border-color rgba(0, 0, 0, 0.2) + transition all .1s ease + + &:focus + color $theme-color !important + border-color $theme-color + box-shadow 0 0 0 1024px #fff inset, 0 0 0 4px rgba($theme-color, 10%) + transition all 0s ease + + &:disabled + opacity 0.5 + + .agree-tou + padding 4px + border-radius 4px + + &:hover + background #f4f4f4 + + &:active + background #eee + + &, * + cursor pointer + + p + display inline + color #555 + + button + margin 0 + padding 16px + width 100% + font-size 1em + color #fff + background $theme-color + border-radius 3px + + &:hover + background lighten($theme-color, 5%) + + &:active + background darken($theme-color, 5%) + +</style> diff --git a/src/client/app/common/views/components/special-message.vue b/src/client/app/common/views/components/special-message.vue new file mode 100644 index 0000000000..2fd4d6515e --- /dev/null +++ b/src/client/app/common/views/components/special-message.vue @@ -0,0 +1,42 @@ +<template> +<div class="mk-special-message"> + <p v-if="m == 1 && d == 1">%i18n:common.tags.mk-special-message.new-year%</p> + <p v-if="m == 12 && d == 25">%i18n:common.tags.mk-special-message.christmas%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + now: new Date() + }; + }, + computed: { + d(): number { + return this.now.getDate(); + }, + m(): number { + return this.now.getMonth() + 1; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-special-message + &:empty + display none + + > p + margin 0 + padding 4px + text-align center + font-size 14px + font-weight bold + text-transform uppercase + color #fff + background #ff1036 + +</style> diff --git a/src/client/app/common/views/components/stream-indicator.vue b/src/client/app/common/views/components/stream-indicator.vue new file mode 100644 index 0000000000..1f18fa76ed --- /dev/null +++ b/src/client/app/common/views/components/stream-indicator.vue @@ -0,0 +1,86 @@ +<template> +<div class="mk-stream-indicator"> + <p v-if=" stream.state == 'initializing' "> + %fa:spinner .pulse% + <span>%i18n:common.tags.mk-stream-indicator.connecting%<mk-ellipsis/></span> + </p> + <p v-if=" stream.state == 'reconnecting' "> + %fa:spinner .pulse% + <span>%i18n:common.tags.mk-stream-indicator.reconnecting%<mk-ellipsis/></span> + </p> + <p v-if=" stream.state == 'connected' "> + %fa:check% + <span>%i18n:common.tags.mk-stream-indicator.connected%</span> + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + computed: { + stream() { + return (this as any).os.stream; + } + }, + created() { + (this as any).os.stream.on('_connected_', this.onConnected); + (this as any).os.stream.on('_disconnected_', this.onDisconnected); + + this.$nextTick(() => { + if (this.stream.state == 'connected') { + this.$el.style.opacity = '0'; + } + }); + }, + beforeDestroy() { + (this as any).os.stream.off('_connected_', this.onConnected); + (this as any).os.stream.off('_disconnected_', this.onDisconnected); + }, + methods: { + onConnected() { + setTimeout(() => { + anime({ + targets: this.$el, + opacity: 0, + easing: 'linear', + duration: 200 + }); + }, 1000); + }, + onDisconnected() { + anime({ + targets: this.$el, + opacity: 1, + easing: 'linear', + duration: 100 + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-stream-indicator + pointer-events none + position fixed + z-index 16384 + bottom 8px + right 8px + margin 0 + padding 6px 12px + font-size 0.9em + color #fff + background rgba(0, 0, 0, 0.8) + border-radius 4px + + > p + display block + margin 0 + + > [data-fa] + margin-right 0.25em + +</style> diff --git a/src/client/app/common/views/components/switch.vue b/src/client/app/common/views/components/switch.vue new file mode 100644 index 0000000000..19a4adc3de --- /dev/null +++ b/src/client/app/common/views/components/switch.vue @@ -0,0 +1,190 @@ +<template> +<div + class="mk-switch" + :class="{ disabled, checked }" + role="switch" + :aria-checked="checked" + :aria-disabled="disabled" + @click="switchValue" + @mouseover="mouseenter" +> + <input + type="checkbox" + @change="handleChange" + ref="input" + :disabled="disabled" + @keydown.enter="switchValue" + > + <span class="button"> + <span :style="{ transform }"></span> + </span> + <span class="label"> + <span :aria-hidden="!checked">{{ text }}</span> + <p :aria-hidden="!checked"> + <slot></slot> + </p> + </span> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + value: { + type: Boolean, + default: false + }, + disabled: { + type: Boolean, + default: false + }, + text: String + },/* + created() { + if (!~[true, false].indexOf(this.value)) { + this.$emit('input', false); + } + },*/ + computed: { + checked(): boolean { + return this.value; + }, + transform(): string { + return this.checked ? 'translate3d(20px, 0, 0)' : ''; + } + }, + watch: { + value() { + (this.$el).style.transition = 'all 0.3s'; + (this.$refs.input as any).checked = this.checked; + } + }, + mounted() { + (this.$refs.input as any).checked = this.checked; + }, + methods: { + mouseenter() { + (this.$el).style.transition = 'all 0s'; + }, + handleChange() { + (this.$el).style.transition = 'all 0.3s'; + this.$emit('input', !this.checked); + this.$emit('change', !this.checked); + this.$nextTick(() => { + // set input's checked property + // in case parent refuses to change component's value + (this.$refs.input as any).checked = this.checked; + }); + }, + switchValue() { + !this.disabled && this.handleChange(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-switch + display flex + margin 12px 0 + cursor pointer + transition all 0.3s + + > * + user-select none + + &.disabled + opacity 0.6 + cursor not-allowed + + &.checked + > .button + background-color $theme-color + border-color $theme-color + + > .label + > span + color $theme-color + + &:hover + > .label + > span + color darken($theme-color, 10%) + + > .button + background darken($theme-color, 10%) + border-color darken($theme-color, 10%) + + &:hover + > .label + > span + color #2e3338 + + > .button + background #ced2da + border-color #ced2da + + > input + position absolute + width 0 + height 0 + opacity 0 + margin 0 + + &:focus + .button + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 14px + + > .button + display inline-block + margin 0 + width 40px + min-width 40px + height 20px + min-height 20px + background #dcdfe6 + border 1px solid #dcdfe6 + outline none + border-radius 10px + transition inherit + + > * + position absolute + top 1px + left 1px + border-radius 100% + transition transform 0.3s + width 16px + height 16px + background-color #fff + + > .label + margin-left 8px + display block + font-size 15px + cursor pointer + transition inherit + + > span + display block + line-height 20px + color #4a535a + transition inherit + + > p + margin 0 + //font-size 90% + color #9daab3 + +</style> diff --git a/src/client/app/common/views/components/time.vue b/src/client/app/common/views/components/time.vue new file mode 100644 index 0000000000..6e0d2b0dcb --- /dev/null +++ b/src/client/app/common/views/components/time.vue @@ -0,0 +1,76 @@ +<template> +<time class="mk-time"> + <span v-if=" mode == 'relative' ">{{ relative }}</span> + <span v-if=" mode == 'absolute' ">{{ absolute }}</span> + <span v-if=" mode == 'detail' ">{{ absolute }} ({{ relative }})</span> +</time> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + time: { + type: [Date, String], + required: true + }, + mode: { + type: String, + default: 'relative' + } + }, + data() { + return { + tickId: null, + now: new Date() + }; + }, + computed: { + _time(): Date { + return typeof this.time == 'string' ? new Date(this.time) : this.time; + }, + absolute(): string { + const time = this._time; + return ( + time.getFullYear() + '年' + + (time.getMonth() + 1) + '月' + + time.getDate() + '日' + + ' ' + + time.getHours() + '時' + + time.getMinutes() + '分'); + }, + relative(): string { + const time = this._time; + const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/; + return ( + ago >= 31536000 ? '%i18n:common.time.years_ago%' .replace('{}', (~~(ago / 31536000)).toString()) : + ago >= 2592000 ? '%i18n:common.time.months_ago%' .replace('{}', (~~(ago / 2592000)).toString()) : + ago >= 604800 ? '%i18n:common.time.weeks_ago%' .replace('{}', (~~(ago / 604800)).toString()) : + ago >= 86400 ? '%i18n:common.time.days_ago%' .replace('{}', (~~(ago / 86400)).toString()) : + ago >= 3600 ? '%i18n:common.time.hours_ago%' .replace('{}', (~~(ago / 3600)).toString()) : + ago >= 60 ? '%i18n:common.time.minutes_ago%'.replace('{}', (~~(ago / 60)).toString()) : + ago >= 10 ? '%i18n:common.time.seconds_ago%'.replace('{}', (~~(ago % 60)).toString()) : + ago >= 0 ? '%i18n:common.time.just_now%' : + ago < 0 ? '%i18n:common.time.future%' : + '%i18n:common.time.unknown%'); + } + }, + created() { + if (this.mode == 'relative' || this.mode == 'detail') { + this.tick(); + this.tickId = setInterval(this.tick, 1000); + } + }, + destroyed() { + if (this.mode === 'relative' || this.mode === 'detail') { + clearInterval(this.tickId); + } + }, + methods: { + tick() { + this.now = new Date(); + } + } +}); +</script> diff --git a/src/client/app/common/views/components/timer.vue b/src/client/app/common/views/components/timer.vue new file mode 100644 index 0000000000..a3c4f01b77 --- /dev/null +++ b/src/client/app/common/views/components/timer.vue @@ -0,0 +1,49 @@ +<template> +<time class="mk-time"> + {{ hh }}:{{ mm }}:{{ ss }} +</time> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + time: { + type: [Date, String], + required: true + } + }, + data() { + return { + tickId: null, + hh: null, + mm: null, + ss: null + }; + }, + computed: { + _time(): Date { + return typeof this.time == 'string' ? new Date(this.time) : this.time; + } + }, + created() { + this.tick(); + this.tickId = setInterval(this.tick, 1000); + }, + destroyed() { + clearInterval(this.tickId); + }, + methods: { + tick() { + const now = new Date().getTime(); + const start = this._time.getTime(); + const ago = Math.floor((now - start) / 1000); + + this.hh = Math.floor(ago / (60 * 60)).toString().padStart(2, '0'); + this.mm = Math.floor(ago / 60).toString().padStart(2, '0'); + this.ss = (ago % 60).toString().padStart(2, '0'); + } + } +}); +</script> diff --git a/src/client/app/common/views/components/twitter-setting.vue b/src/client/app/common/views/components/twitter-setting.vue new file mode 100644 index 0000000000..082d2b435d --- /dev/null +++ b/src/client/app/common/views/components/twitter-setting.vue @@ -0,0 +1,66 @@ +<template> +<div class="mk-twitter-setting"> + <p>%i18n:common.tags.mk-twitter-setting.description%<a :href="`${docsUrl}/link-to-twitter`" target="_blank">%i18n:common.tags.mk-twitter-setting.detail%</a></p> + <p class="account" v-if="os.i.account.twitter" :title="`Twitter ID: ${os.i.account.twitter.userId}`">%i18n:common.tags.mk-twitter-setting.connected-to%: <a :href="`https://twitter.com/${os.i.account.twitter.screenName}`" target="_blank">@{{ os.i.account.twitter.screenName }}</a></p> + <p> + <a :href="`${apiUrl}/connect/twitter`" target="_blank" @click.prevent="connect">{{ os.i.account.twitter ? '%i18n:common.tags.mk-twitter-setting.reconnect%' : '%i18n:common.tags.mk-twitter-setting.connect%' }}</a> + <span v-if="os.i.account.twitter"> or </span> + <a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="os.i.account.twitter" @click.prevent="disconnect">%i18n:common.tags.mk-twitter-setting.disconnect%</a> + </p> + <p class="id" v-if="os.i.account.twitter">Twitter ID: {{ os.i.account.twitter.userId }}</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { apiUrl, docsUrl } from '../../../config'; + +export default Vue.extend({ + data() { + return { + form: null, + apiUrl, + docsUrl + }; + }, + mounted() { + this.$watch('os.i', () => { + if ((this as any).os.i.account.twitter) { + if (this.form) this.form.close(); + } + }, { + deep: true + }); + }, + methods: { + connect() { + this.form = window.open(apiUrl + '/connect/twitter', + 'twitter_connect_window', + 'height=570, width=520'); + }, + + disconnect() { + window.open(apiUrl + '/disconnect/twitter', + 'twitter_disconnect_window', + 'height=570, width=520'); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-twitter-setting + color #4a535a + + .account + border solid 1px #e1e8ed + border-radius 4px + padding 16px + + a + font-weight bold + color inherit + + .id + color #8899a6 +</style> diff --git a/src/client/app/common/views/components/uploader.vue b/src/client/app/common/views/components/uploader.vue new file mode 100644 index 0000000000..c74a1edb41 --- /dev/null +++ b/src/client/app/common/views/components/uploader.vue @@ -0,0 +1,212 @@ +<template> +<div class="mk-uploader"> + <ol v-if="uploads.length > 0"> + <li v-for="ctx in uploads" :key="ctx.id"> + <div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div> + <p class="name">%fa:spinner .pulse%{{ ctx.name }}</p> + <p class="status"> + <span class="initing" v-if="ctx.progress == undefined">%i18n:common.tags.mk-uploader.waiting%<mk-ellipsis/></span> + <span class="kb" v-if="ctx.progress != undefined">{{ String(Math.floor(ctx.progress.value / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progress.max / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> + <span class="percentage" v-if="ctx.progress != undefined">{{ Math.floor((ctx.progress.value / ctx.progress.max) * 100) }}</span> + </p> + <progress v-if="ctx.progress != undefined && ctx.progress.value != ctx.progress.max" :value="ctx.progress.value" :max="ctx.progress.max"></progress> + <div class="progress initing" v-if="ctx.progress == undefined"></div> + <div class="progress waiting" v-if="ctx.progress != undefined && ctx.progress.value == ctx.progress.max"></div> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { apiUrl } from '../../../config'; + +export default Vue.extend({ + data() { + return { + uploads: [] + }; + }, + methods: { + upload(file, folder) { + if (folder && typeof folder == 'object') folder = folder.id; + + const id = Math.random(); + + const ctx = { + id: id, + name: file.name || 'untitled', + progress: undefined, + img: undefined + }; + + this.uploads.push(ctx); + this.$emit('change', this.uploads); + + const reader = new FileReader(); + reader.onload = (e: any) => { + ctx.img = e.target.result; + }; + reader.readAsDataURL(file); + + const data = new FormData(); + data.append('i', (this as any).os.i.account.token); + data.append('file', file); + + if (folder) data.append('folderId', folder); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = (e: any) => { + const driveFile = JSON.parse(e.target.response); + + this.$emit('uploaded', driveFile); + + this.uploads = this.uploads.filter(x => x.id != id); + this.$emit('change', this.uploads); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) { + if (ctx.progress == undefined) ctx.progress = {}; + ctx.progress.max = e.total; + ctx.progress.value = e.loaded; + } + }; + + xhr.send(data); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-uploader + overflow auto + + &:empty + display none + + > ol + display block + margin 0 + padding 0 + list-style none + + > li + display block + margin 8px 0 0 0 + padding 0 + height 36px + box-shadow 0 -1px 0 rgba($theme-color, 0.1) + border-top solid 8px transparent + + &:first-child + margin 0 + box-shadow none + border-top none + + > .img + display block + position absolute + top 0 + left 0 + width 36px + height 36px + background-size cover + background-position center center + + > .name + display block + position absolute + top 0 + left 44px + margin 0 + padding 0 + max-width 256px + font-size 0.8em + color rgba($theme-color, 0.7) + white-space nowrap + text-overflow ellipsis + overflow hidden + + > [data-fa] + margin-right 4px + + > .status + display block + position absolute + top 0 + right 0 + margin 0 + padding 0 + font-size 0.8em + + > .initing + color rgba($theme-color, 0.5) + + > .kb + color rgba($theme-color, 0.5) + + > .percentage + display inline-block + width 48px + text-align right + + color rgba($theme-color, 0.7) + + &:after + content '%' + + > progress + display block + position absolute + bottom 0 + right 0 + margin 0 + width calc(100% - 44px) + height 8px + background transparent + border none + border-radius 4px + overflow hidden + + &::-webkit-progress-value + background $theme-color + + &::-webkit-progress-bar + background rgba($theme-color, 0.1) + + > .progress + display block + position absolute + bottom 0 + right 0 + margin 0 + width calc(100% - 44px) + height 8px + border none + border-radius 4px + background linear-gradient( + 45deg, + lighten($theme-color, 30%) 25%, + $theme-color 25%, + $theme-color 50%, + lighten($theme-color, 30%) 50%, + lighten($theme-color, 30%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation bg 1.5s linear infinite + + &.initing + opacity 0.3 + + @keyframes bg + from {background-position: 0 0;} + to {background-position: -64px 32px;} + +</style> diff --git a/src/client/app/common/views/components/url-preview.vue b/src/client/app/common/views/components/url-preview.vue new file mode 100644 index 0000000000..e91e510550 --- /dev/null +++ b/src/client/app/common/views/components/url-preview.vue @@ -0,0 +1,142 @@ +<template> +<iframe v-if="youtubeId" type="text/html" height="250" + :src="`https://www.youtube.com/embed/${youtubeId}?origin=${misskeyUrl}`" + frameborder="0"/> +<div v-else> + <a class="mk-url-preview" :href="url" target="_blank" :title="url" v-if="!fetching"> + <div class="thumbnail" v-if="thumbnail" :style="`background-image: url(${thumbnail})`"></div> + <article> + <header> + <h1>{{ title }}</h1> + </header> + <p>{{ description }}</p> + <footer> + <img class="icon" v-if="icon" :src="icon"/> + <p>{{ sitename }}</p> + </footer> + </article> + </a> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url as misskeyUrl } from '../../../config'; + +export default Vue.extend({ + props: ['url'], + data() { + return { + fetching: true, + title: null, + description: null, + thumbnail: null, + icon: null, + sitename: null, + youtubeId: null, + misskeyUrl + }; + }, + created() { + const url = new URL(this.url); + + if (url.hostname == 'www.youtube.com') { + this.youtubeId = url.searchParams.get('v'); + } else if (url.hostname == 'youtu.be') { + this.youtubeId = url.pathname; + } else { + fetch('/api:url?url=' + this.url).then(res => { + res.json().then(info => { + this.title = info.title; + this.description = info.description; + this.thumbnail = info.thumbnail; + this.icon = info.icon; + this.sitename = info.sitename; + + this.fetching = false; + }); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +iframe + width 100% + +.mk-url-preview + display block + font-size 16px + border solid 1px #eee + border-radius 4px + overflow hidden + + &:hover + text-decoration none + border-color #ddd + + > article > header > h1 + text-decoration underline + + > .thumbnail + position absolute + width 100px + height 100% + background-position center + background-size cover + + & + article + left 100px + width calc(100% - 100px) + + > article + padding 16px + + > header + margin-bottom 8px + + > h1 + margin 0 + font-size 1em + color #555 + + > p + margin 0 + color #777 + font-size 0.8em + + > footer + margin-top 8px + height 16px + + > img + display inline-block + width 16px + height 16px + margin-right 4px + vertical-align top + + > p + display inline-block + margin 0 + color #666 + font-size 0.8em + line-height 16px + vertical-align top + + @media (max-width 500px) + font-size 8px + border none + + > .thumbnail + width 70px + + & + article + left 70px + width calc(100% - 70px) + + > article + padding 8px + +</style> diff --git a/src/client/app/common/views/components/url.vue b/src/client/app/common/views/components/url.vue new file mode 100644 index 0000000000..14d4fc82f3 --- /dev/null +++ b/src/client/app/common/views/components/url.vue @@ -0,0 +1,66 @@ +<template> +<a class="mk-url" :href="url" :target="target"> + <span class="schema">{{ schema }}//</span> + <span class="hostname">{{ hostname }}</span> + <span class="port" v-if="port != ''">:{{ port }}</span> + <span class="pathname" v-if="pathname != ''">{{ pathname }}</span> + <span class="query">{{ query }}</span> + <span class="hash">{{ hash }}</span> + %fa:external-link-square-alt% +</a> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['url', 'target'], + data() { + return { + schema: null, + hostname: null, + port: null, + pathname: null, + query: null, + hash: null + }; + }, + created() { + const url = new URL(this.url); + + this.schema = url.protocol; + this.hostname = url.hostname; + this.port = url.port; + this.pathname = url.pathname; + this.query = url.search; + this.hash = url.hash; + } +}); +</script> + +<style lang="stylus" scoped> +.mk-url + word-break break-all + + > [data-fa] + padding-left 2px + font-size .9em + font-weight 400 + font-style normal + + > .schema + opacity 0.5 + + > .hostname + font-weight bold + + > .pathname + opacity 0.8 + + > .query + opacity 0.5 + + > .hash + font-style italic + +</style> diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue new file mode 100644 index 0000000000..8f6199732a --- /dev/null +++ b/src/client/app/common/views/components/welcome-timeline.vue @@ -0,0 +1,118 @@ +<template> +<div class="mk-welcome-timeline"> + <div v-for="post in posts"> + <router-link class="avatar-anchor" :to="`/@${getAcct(post.user)}`" v-user-preview="post.user.id"> + <img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=96`" alt="avatar"/> + </router-link> + <div class="body"> + <header> + <router-link class="name" :to="`/@${getAcct(post.user)}`" v-user-preview="post.user.id">{{ post.user.name }}</router-link> + <span class="username">@{{ getAcct(post.user) }}</span> + <div class="info"> + <router-link class="created-at" :to="`/@${getAcct(post.user)}/${post.id}`"> + <mk-time :time="post.createdAt"/> + </router-link> + </div> + </header> + <div class="text"> + <mk-post-html :ast="post.ast"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + data() { + return { + fetching: true, + posts: [] + }; + }, + mounted() { + this.fetch(); + }, + methods: { + getAcct, + fetch(cb?) { + this.fetching = true; + (this as any).api('posts', { + reply: false, + repost: false, + media: false, + poll: false, + bot: false + }).then(posts => { + this.posts = posts; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-welcome-timeline + background #fff + + > div + padding 16px + overflow-wrap break-word + font-size .9em + color #4C4C4C + border-bottom 1px solid rgba(0, 0, 0, 0.05) + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + position -webkit-sticky + position sticky + top 16px + + > img + display block + width 42px + height 42px + border-radius 6px + + > .body + float right + width calc(100% - 42px) + padding-left 12px + + > header + display flex + align-items center + margin-bottom 4px + white-space nowrap + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + font-weight bold + text-overflow ellipsis + color #627079 + + > .username + margin 0 .5em 0 0 + color #ccc + + > .info + margin-left auto + font-size 0.9em + + > .created-at + color #c0c0c0 + +</style> diff --git a/src/client/app/common/views/directives/autocomplete.ts b/src/client/app/common/views/directives/autocomplete.ts new file mode 100644 index 0000000000..94635d301a --- /dev/null +++ b/src/client/app/common/views/directives/autocomplete.ts @@ -0,0 +1,194 @@ +import * as getCaretCoordinates from 'textarea-caret'; +import MkAutocomplete from '../components/autocomplete.vue'; + +export default { + bind(el, binding, vn) { + const self = el._autoCompleteDirective_ = {} as any; + self.x = new Autocomplete(el, vn.context, binding.value); + self.x.attach(); + }, + + unbind(el, binding, vn) { + const self = el._autoCompleteDirective_; + self.x.detach(); + } +}; + +/** + * オートコンプリートを管理するクラス。 + */ +class Autocomplete { + private suggestion: any; + private textarea: any; + private vm: any; + private model: any; + private currentType: string; + + private get text(): string { + return this.vm[this.model]; + } + + private set text(text: string) { + this.vm[this.model] = text; + } + + /** + * 対象のテキストエリアを与えてインスタンスを初期化します。 + */ + constructor(textarea, vm, model) { + //#region BIND + this.onInput = this.onInput.bind(this); + this.complete = this.complete.bind(this); + this.close = this.close.bind(this); + //#endregion + + this.suggestion = null; + this.textarea = textarea; + this.vm = vm; + this.model = model; + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。 + */ + public attach() { + this.textarea.addEventListener('input', this.onInput); + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。 + */ + public detach() { + this.textarea.removeEventListener('input', this.onInput); + this.close(); + } + + /** + * テキスト入力時 + */ + private onInput() { + const caret = this.textarea.selectionStart; + const text = this.text.substr(0, caret); + + const mentionIndex = text.lastIndexOf('@'); + const emojiIndex = text.lastIndexOf(':'); + + let opened = false; + + if (mentionIndex != -1 && mentionIndex > emojiIndex) { + const username = text.substr(mentionIndex + 1); + if (username != '' && username.match(/^[a-zA-Z0-9_]+$/)) { + this.open('user', username); + opened = true; + } + } + + if (emojiIndex != -1 && emojiIndex > mentionIndex) { + const emoji = text.substr(emojiIndex + 1); + if (emoji != '' && emoji.match(/^[\+\-a-z0-9_]+$/)) { + this.open('emoji', emoji); + opened = true; + } + } + + if (!opened) { + this.close(); + } + } + + /** + * サジェストを提示します。 + */ + private open(type, q) { + if (type != this.currentType) { + this.close(); + } + this.currentType = type; + + //#region サジェストを表示すべき位置を計算 + const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart); + + const rect = this.textarea.getBoundingClientRect(); + + const x = rect.left + caretPosition.left - this.textarea.scrollLeft; + const y = rect.top + caretPosition.top - this.textarea.scrollTop; + //#endregion + + if (this.suggestion) { + this.suggestion.x = x; + this.suggestion.y = y; + this.suggestion.q = q; + } else { + // サジェスト要素作成 + this.suggestion = new MkAutocomplete({ + propsData: { + textarea: this.textarea, + complete: this.complete, + close: this.close, + type: type, + q: q, + x, + y + } + }).$mount(); + + // 要素追加 + document.body.appendChild(this.suggestion.$el); + } + } + + /** + * サジェストを閉じます。 + */ + private close() { + if (this.suggestion == null) return; + + this.suggestion.$destroy(); + this.suggestion = null; + + this.textarea.focus(); + } + + /** + * オートコンプリートする + */ + private complete(type, value) { + this.close(); + + const caret = this.textarea.selectionStart; + + if (type == 'user') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('@')); + const after = source.substr(caret); + + // 挿入 + this.text = trimmedBefore + '@' + value.username + ' ' + after; + + // キャレットを戻す + this.vm.$nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.username.length + 2); + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type == 'emoji') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf(':')); + const after = source.substr(caret); + + // 挿入 + this.text = trimmedBefore + value + after; + + // キャレットを戻す + this.vm.$nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + 1; + this.textarea.setSelectionRange(pos, pos); + }); + } + } +} diff --git a/src/client/app/common/views/directives/index.ts b/src/client/app/common/views/directives/index.ts new file mode 100644 index 0000000000..268f07a950 --- /dev/null +++ b/src/client/app/common/views/directives/index.ts @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +import autocomplete from './autocomplete'; + +Vue.directive('autocomplete', autocomplete); diff --git a/src/client/app/common/views/filters/bytes.ts b/src/client/app/common/views/filters/bytes.ts new file mode 100644 index 0000000000..3afb11e9ae --- /dev/null +++ b/src/client/app/common/views/filters/bytes.ts @@ -0,0 +1,8 @@ +import Vue from 'vue'; + +Vue.filter('bytes', (v, digits = 0) => { + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + if (v == 0) return '0Byte'; + const i = Math.floor(Math.log(v) / Math.log(1024)); + return (v / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i]; +}); diff --git a/src/client/app/common/views/filters/index.ts b/src/client/app/common/views/filters/index.ts new file mode 100644 index 0000000000..3a1d1ac235 --- /dev/null +++ b/src/client/app/common/views/filters/index.ts @@ -0,0 +1,2 @@ +require('./bytes'); +require('./number'); diff --git a/src/client/app/common/views/filters/number.ts b/src/client/app/common/views/filters/number.ts new file mode 100644 index 0000000000..d9f48229dd --- /dev/null +++ b/src/client/app/common/views/filters/number.ts @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +Vue.filter('number', (n) => { + return n.toLocaleString(); +}); diff --git a/src/client/app/common/views/widgets/access-log.vue b/src/client/app/common/views/widgets/access-log.vue new file mode 100644 index 0000000000..f7bb17d833 --- /dev/null +++ b/src/client/app/common/views/widgets/access-log.vue @@ -0,0 +1,90 @@ +<template> +<div class="mkw-access-log"> + <mk-widget-container :show-header="props.design == 0"> + <template slot="header">%fa:server%%i18n:desktop.tags.mk-access-log-home-widget.title%</template> + + <div :class="$style.logs" ref="log"> + <p v-for="req in requests"> + <span :class="$style.ip" :style="`color:${ req.fg }; background:${ req.bg }`">{{ req.ip }}</span> + <b>{{ req.method }}</b> + <span>{{ req.path }}</span> + </p> + </div> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import * as seedrandom from 'seedrandom'; + +export default define({ + name: 'broadcast', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + requests: [], + connection: null, + connectionId: null + }; + }, + mounted() { + this.connection = (this as any).os.streams.requestsStream.getConnection(); + this.connectionId = (this as any).os.streams.requestsStream.use(); + this.connection.on('request', this.onRequest); + }, + beforeDestroy() { + this.connection.off('request', this.onRequest); + (this as any).os.streams.requestsStream.dispose(this.connectionId); + }, + methods: { + onRequest(request) { + const random = seedrandom(request.ip); + const r = Math.floor(random() * 255); + const g = Math.floor(random() * 255); + const b = Math.floor(random() * 255); + const luma = (0.2126 * r) + (0.7152 * g) + (0.0722 * b); // SMPTE C, Rec. 709 weightings + request.bg = `rgb(${r}, ${g}, ${b})`; + request.fg = luma >= 165 ? '#000' : '#fff'; + + this.requests.push(request); + if (this.requests.length > 30) this.requests.shift(); + + (this.$refs.log as any).scrollTop = (this.$refs.log as any).scrollHeight; + }, + func() { + if (this.props.design == 1) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" module> +.logs + max-height 250px + overflow auto + + > p + margin 0 + padding 8px + font-size 0.8em + color #555 + + &:nth-child(odd) + background rgba(0, 0, 0, 0.025) + + > b + margin-right 4px + +.ip + margin-right 4px + padding 0 4px + +</style> diff --git a/src/client/app/common/views/widgets/broadcast.vue b/src/client/app/common/views/widgets/broadcast.vue new file mode 100644 index 0000000000..bf41a5fc67 --- /dev/null +++ b/src/client/app/common/views/widgets/broadcast.vue @@ -0,0 +1,161 @@ +<template> +<div class="mkw-broadcast" + :data-found="broadcasts.length != 0" + :data-melt="props.design == 1" + :data-mobile="isMobile" +> + <div class="icon"> + <svg height="32" version="1.1" viewBox="0 0 32 32" width="32"> + <path class="tower" d="M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z"></path> + <path class="wave a" d="M4.66,1.04c-0.508-0.508-1.332-0.508-1.84,0c-1.86,1.92-2.8,4.44-2.8,6.94c0,2.52,0.94,5.04,2.8,6.96 c0.5,0.52,1.32,0.52,1.82,0s0.5-1.36,0-1.88C3.28,11.66,2.6,9.82,2.6,7.98S3.28,4.3,4.64,2.9C5.157,2.391,5.166,1.56,4.66,1.04z"></path> + <path class="wave b" d="M9.58,12.22c0.5-0.5,0.5-1.34,0-1.84C8.94,9.72,8.62,8.86,8.62,8s0.32-1.72,0.96-2.38c0.5-0.52,0.5-1.34,0-1.84 C9.346,3.534,9.02,3.396,8.68,3.4c-0.32,0-0.66,0.12-0.9,0.38C6.64,4.94,6.08,6.48,6.08,8s0.58,3.06,1.7,4.22 C8.28,12.72,9.1,12.72,9.58,12.22z"></path> + <path class="wave c" d="M22.42,3.78c-0.5,0.5-0.5,1.34,0,1.84c0.641,0.66,0.96,1.52,0.96,2.38s-0.319,1.72-0.96,2.38c-0.5,0.52-0.5,1.34,0,1.84 c0.487,0.497,1.285,0.505,1.781,0.018c0.007-0.006,0.013-0.012,0.02-0.018c1.139-1.16,1.699-2.7,1.699-4.22s-0.561-3.06-1.699-4.22 c-0.494-0.497-1.297-0.5-1.794-0.007C22.424,3.775,22.422,3.778,22.42,3.78z"></path> + <path class="wave d" d="M29.18,1.06c-0.479-0.502-1.273-0.522-1.775-0.044c-0.016,0.015-0.029,0.029-0.045,0.044c-0.5,0.52-0.5,1.36,0,1.88 c1.361,1.4,2.041,3.24,2.041,5.08s-0.68,3.66-2.041,5.08c-0.5,0.52-0.5,1.36,0,1.88c0.509,0.508,1.332,0.508,1.841,0 c1.86-1.92,2.8-4.44,2.8-6.96C31.99,5.424,30.98,2.931,29.18,1.06z"></path> + </svg> + </div> + <p class="fetching" v-if="fetching">%i18n:desktop.tags.mk-broadcast-home-widget.fetching%<mk-ellipsis/></p> + <h1 v-if="!fetching">{{ broadcasts.length == 0 ? '%i18n:desktop.tags.mk-broadcast-home-widget.no-broadcasts%' : broadcasts[i].title }}</h1> + <p v-if="!fetching"> + <span v-if="broadcasts.length != 0" v-html="broadcasts[i].text"></span> + <template v-if="broadcasts.length == 0">%i18n:desktop.tags.mk-broadcast-home-widget.have-a-nice-day%</template> + </p> + <a v-if="broadcasts.length > 1" @click="next">%i18n:desktop.tags.mk-broadcast-home-widget.next% >></a> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import { lang } from '../../../config'; + +export default define({ + name: 'broadcast', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + i: 0, + fetching: true, + broadcasts: [] + }; + }, + mounted() { + (this as any).os.getMeta().then(meta => { + let broadcasts = []; + if (meta.broadcasts) { + meta.broadcasts.forEach(broadcast => { + if (broadcast[lang]) { + broadcasts.push(broadcast[lang]); + } + }); + } + this.broadcasts = broadcasts; + this.fetching = false; + }); + }, + methods: { + next() { + if (this.i == this.broadcasts.length - 1) { + this.i = 0; + } else { + this.i++; + } + }, + func() { + if (this.props.design == 1) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-broadcast + padding 10px + border solid 1px #4078c0 + border-radius 6px + + &[data-melt] + border none + + &[data-found] + padding-left 50px + + > .icon + display block + + &:after + content "" + display block + clear both + + > .icon + display none + float left + margin-left -40px + + > svg + fill currentColor + color #4078c0 + + > .wave + opacity 1 + + &.a + animation wave 20s ease-in-out 2.1s infinite + &.b + animation wave 20s ease-in-out 2s infinite + &.c + animation wave 20s ease-in-out 2s infinite + &.d + animation wave 20s ease-in-out 2.1s infinite + + @keyframes wave + 0% + opacity 1 + 1.5% + opacity 0 + 3.5% + opacity 0 + 5% + opacity 1 + 6.5% + opacity 0 + 8.5% + opacity 0 + 10% + opacity 1 + + > h1 + margin 0 + font-size 0.95em + font-weight normal + color #4078c0 + + > p + display block + z-index 1 + margin 0 + font-size 0.7em + color #555 + + &.fetching + text-align center + + a + color #555 + text-decoration underline + + > a + display block + font-size 0.7em + + &[data-mobile] + > p + color #fff + +</style> diff --git a/src/client/app/common/views/widgets/calendar.vue b/src/client/app/common/views/widgets/calendar.vue new file mode 100644 index 0000000000..03f69a7597 --- /dev/null +++ b/src/client/app/common/views/widgets/calendar.vue @@ -0,0 +1,201 @@ +<template> +<div class="mkw-calendar" + :data-melt="props.design == 1" + :data-special="special" + :data-mobile="isMobile" +> + <div class="calendar" :data-is-holiday="isHoliday"> + <p class="month-and-year"> + <span class="year">{{ year }}年</span> + <span class="month">{{ month }}月</span> + </p> + <p class="day">{{ day }}日</p> + <p class="week-day">{{ weekDay }}曜日</p> + </div> + <div class="info"> + <div> + <p>今日:<b>{{ dayP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${dayP}%` }"></div> + </div> + </div> + <div> + <p>今月:<b>{{ monthP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${monthP}%` }"></div> + </div> + </div> + <div> + <p>今年:<b>{{ yearP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${yearP}%` }"></div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'calendar', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + now: new Date(), + year: null, + month: null, + day: null, + weekDay: null, + yearP: null, + dayP: null, + monthP: null, + isHoliday: null, + special: null, + clock: null + }; + }, + created() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + func() { + if (this.isMobile) return; + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + }, + tick() { + const now = new Date(); + const nd = now.getDate(); + const nm = now.getMonth(); + const ny = now.getFullYear(); + + this.year = ny; + this.month = nm + 1; + this.day = nd; + this.weekDay = ['日', '月', '火', '水', '木', '金', '土'][now.getDay()]; + + const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime(); + const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/; + const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime(); + const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime(); + const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime(); + const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime(); + + this.dayP = dayNumer / dayDenom * 100; + this.monthP = monthNumer / monthDenom * 100; + this.yearP = yearNumer / yearDenom * 100; + + this.isHoliday = now.getDay() == 0 || now.getDay() == 6; + + this.special = + nm == 0 && nd == 1 ? 'on-new-years-day' : + false; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mkw-calendar + padding 16px 0 + color #777 + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-special='on-new-years-day'] + border-color #ef95a0 + + &[data-melt] + background transparent + border none + + &[data-mobile] + border none + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + &:after + content "" + display block + clear both + + > .calendar + float left + width 60% + text-align center + + &[data-is-holiday] + > .day + color #ef95a0 + + > p + margin 0 + line-height 18px + font-size 14px + + > span + margin 0 4px + + > .day + margin 10px 0 + line-height 32px + font-size 28px + + > .info + display block + float left + width 40% + padding 0 16px 0 0 + + > div + margin-bottom 8px + + &:last-child + margin-bottom 4px + + > p + margin 0 0 2px 0 + font-size 12px + line-height 18px + color #888 + + > b + margin-left 2px + + > .meter + width 100% + overflow hidden + background #eee + border-radius 8px + + > .val + height 4px + background $theme-color + + &:nth-child(1) + > .meter > .val + background #f7796c + + &:nth-child(2) + > .meter > .val + background #a1de41 + + &:nth-child(3) + > .meter > .val + background #41ddde + +</style> diff --git a/src/client/app/common/views/widgets/donation.vue b/src/client/app/common/views/widgets/donation.vue new file mode 100644 index 0000000000..e218df06e1 --- /dev/null +++ b/src/client/app/common/views/widgets/donation.vue @@ -0,0 +1,58 @@ +<template> +<div class="mkw-donation" :data-mobile="isMobile"> + <article> + <h1>%fa:heart%%i18n:desktop.tags.mk-donation-home-widget.title%</h1> + <p> + {{ '%i18n:desktop.tags.mk-donation-home-widget.text%'.substr(0, '%i18n:desktop.tags.mk-donation-home-widget.text%'.indexOf('{')) }} + <a href="https://syuilo.com">@syuilo</a> + {{ '%i18n:desktop.tags.mk-donation-home-widget.text%'.substr('%i18n:desktop.tags.mk-donation-home-widget.text%'.indexOf('}') + 1) }} + </p> + </article> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'donation' +}); +</script> + +<style lang="stylus" scoped> +.mkw-donation + background #fff + border solid 1px #ead8bb + border-radius 6px + + > article + padding 20px + + > h1 + margin 0 0 5px 0 + font-size 1em + color #888 + + > [data-fa] + margin-right 0.25em + + > p + display block + z-index 1 + margin 0 + font-size 0.8em + color #999 + + &[data-mobile] + border none + background #ead8bb + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + > article + > h1 + color #7b8871 + + > p + color #777d71 + +</style> diff --git a/src/client/app/common/views/widgets/index.ts b/src/client/app/common/views/widgets/index.ts new file mode 100644 index 0000000000..e41030e85a --- /dev/null +++ b/src/client/app/common/views/widgets/index.ts @@ -0,0 +1,25 @@ +import Vue from 'vue'; + +import wAccessLog from './access-log.vue'; +import wVersion from './version.vue'; +import wRss from './rss.vue'; +import wServer from './server.vue'; +import wBroadcast from './broadcast.vue'; +import wCalendar from './calendar.vue'; +import wPhotoStream from './photo-stream.vue'; +import wSlideshow from './slideshow.vue'; +import wTips from './tips.vue'; +import wDonation from './donation.vue'; +import wNav from './nav.vue'; + +Vue.component('mkw-nav', wNav); +Vue.component('mkw-calendar', wCalendar); +Vue.component('mkw-photo-stream', wPhotoStream); +Vue.component('mkw-slideshow', wSlideshow); +Vue.component('mkw-tips', wTips); +Vue.component('mkw-donation', wDonation); +Vue.component('mkw-broadcast', wBroadcast); +Vue.component('mkw-server', wServer); +Vue.component('mkw-rss', wRss); +Vue.component('mkw-version', wVersion); +Vue.component('mkw-access-log', wAccessLog); diff --git a/src/client/app/common/views/widgets/nav.vue b/src/client/app/common/views/widgets/nav.vue new file mode 100644 index 0000000000..7bd5a7832f --- /dev/null +++ b/src/client/app/common/views/widgets/nav.vue @@ -0,0 +1,31 @@ +<template> +<div class="mkw-nav"> + <mk-widget-container> + <div :class="$style.body"> + <mk-nav/> + </div> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'nav' +}); +</script> + +<style lang="stylus" module> +.body + padding 16px + font-size 12px + color #aaa + background #fff + + a + color #999 + + i + color #ccc + +</style> diff --git a/src/client/app/common/views/widgets/photo-stream.vue b/src/client/app/common/views/widgets/photo-stream.vue new file mode 100644 index 0000000000..baafd40662 --- /dev/null +++ b/src/client/app/common/views/widgets/photo-stream.vue @@ -0,0 +1,104 @@ +<template> +<div class="mkw-photo-stream" :class="$style.root" :data-melt="props.design == 2"> + <mk-widget-container :show-header="props.design == 0" :naked="props.design == 2"> + <template slot="header">%fa:camera%%i18n:desktop.tags.mk-photo-stream-home-widget.title%</template> + + <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <div :class="$style.stream" v-if="!fetching && images.length > 0"> + <div v-for="image in images" :class="$style.img" :style="`background-image: url(${image.url}?thumbnail&size=256)`"></div> + </div> + <p :class="$style.empty" v-if="!fetching && images.length == 0">%i18n:desktop.tags.mk-photo-stream-home-widget.no-photos%</p> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'photo-stream', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + images: [], + fetching: true, + connection: null, + connectionId: null + }; + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('drive_file_created', this.onDriveFileCreated); + + (this as any).api('drive/stream', { + type: 'image/*', + limit: 9 + }).then(images => { + this.images = images; + this.fetching = false; + }); + }, + beforeDestroy() { + this.connection.off('drive_file_created', this.onDriveFileCreated); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + onDriveFileCreated(file) { + if (/^image\/.+$/.test(file.type)) { + this.images.unshift(file); + if (this.images.length > 9) this.images.pop(); + } + }, + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" module> +.root[data-melt] + .stream + padding 0 + + .img + border solid 4px transparent + border-radius 8px + +.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 + border solid 2px transparent + border-radius 4px + +.fetching +.empty + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/common/views/widgets/rss.vue b/src/client/app/common/views/widgets/rss.vue new file mode 100644 index 0000000000..4d74b2f7a4 --- /dev/null +++ b/src/client/app/common/views/widgets/rss.vue @@ -0,0 +1,93 @@ +<template> +<div class="mkw-rss" :data-mobile="isMobile"> + <mk-widget-container :show-header="!props.compact"> + <template slot="header">%fa:rss-square%RSS</template> + <button slot="func" title="設定" @click="setting">%fa:cog%</button> + + <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <div :class="$style.feed" v-else> + <a v-for="item in items" :href="item.link" target="_blank">{{ item.title }}</a> + </div> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'rss', + props: () => ({ + compact: false + }) +}).extend({ + data() { + return { + url: 'http://news.yahoo.co.jp/pickup/rss.xml', + items: [], + fetching: true, + clock: null + }; + }, + mounted() { + this.fetch(); + this.clock = setInterval(this.fetch, 60000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + func() { + this.props.compact = !this.props.compact; + }, + fetch() { + fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.url}`, { + cache: 'no-cache' + }).then(res => { + res.json().then(feed => { + this.items = feed.items; + this.fetching = false; + }); + }); + }, + setting() { + alert('not implemented yet'); + } + } +}); +</script> + +<style lang="stylus" module> +.feed + padding 12px 16px + font-size 0.9em + + > a + display block + padding 4px 0 + color #666 + border-bottom dashed 1px #eee + + &:last-child + border-bottom none + +.fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +&[data-mobile] + .feed + padding 0 + font-size 1em + + > a + padding 8px 16px + + &:nth-child(even) + background rgba(0, 0, 0, 0.05) + +</style> diff --git a/src/client/app/common/views/widgets/server.cpu-memory.vue b/src/client/app/common/views/widgets/server.cpu-memory.vue new file mode 100644 index 0000000000..d75a142568 --- /dev/null +++ b/src/client/app/common/views/widgets/server.cpu-memory.vue @@ -0,0 +1,127 @@ +<template> +<div class="cpu-memory"> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" preserveAspectRatio="none"> + <defs> + <linearGradient :id="cpuGradientId" x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop> + <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> + </linearGradient> + <mask :id="cpuMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> + <polygon + :points="cpuPolygonPoints" + fill="#fff" + fill-opacity="0.5"/> + <polyline + :points="cpuPolylinePoints" + fill="none" + stroke="#fff" + stroke-width="1"/> + </mask> + </defs> + <rect + x="-1" y="-1" + :width="viewBoxX + 2" :height="viewBoxY + 2" + :style="`stroke: none; fill: url(#${ cpuGradientId }); mask: url(#${ cpuMaskId })`"/> + <text x="1" y="5">CPU <tspan>{{ cpuP }}%</tspan></text> + </svg> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" preserveAspectRatio="none"> + <defs> + <linearGradient :id="memGradientId" x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop> + <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> + </linearGradient> + <mask :id="memMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> + <polygon + :points="memPolygonPoints" + fill="#fff" + fill-opacity="0.5"/> + <polyline + :points="memPolylinePoints" + fill="none" + stroke="#fff" + stroke-width="1"/> + </mask> + </defs> + <rect + x="-1" y="-1" + :width="viewBoxX + 2" :height="viewBoxY + 2" + :style="`stroke: none; fill: url(#${ memGradientId }); mask: url(#${ memMaskId })`"/> + <text x="1" y="5">MEM <tspan>{{ memP }}%</tspan></text> + </svg> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + props: ['connection'], + data() { + return { + viewBoxX: 50, + viewBoxY: 30, + stats: [], + cpuGradientId: uuid(), + cpuMaskId: uuid(), + memGradientId: uuid(), + memMaskId: uuid(), + cpuPolylinePoints: '', + memPolylinePoints: '', + cpuPolygonPoints: '', + memPolygonPoints: '', + cpuP: '', + memP: '' + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + stats.mem.used = stats.mem.total - stats.mem.free; + this.stats.push(stats); + if (this.stats.length > 50) this.stats.shift(); + + this.cpuPolylinePoints = this.stats.map((s, i) => `${this.viewBoxX - ((this.stats.length - 1) - i)},${(1 - s.cpu_usage) * this.viewBoxY}`).join(' '); + this.memPolylinePoints = this.stats.map((s, i) => `${this.viewBoxX - ((this.stats.length - 1) - i)},${(1 - (s.mem.used / s.mem.total)) * this.viewBoxY}`).join(' '); + + this.cpuPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${ this.viewBoxY } ${ this.cpuPolylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; + this.memPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${ this.viewBoxY } ${ this.memPolylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; + + this.cpuP = (stats.cpu_usage * 100).toFixed(0); + this.memP = (stats.mem.used / stats.mem.total * 100).toFixed(0); + } + } +}); +</script> + +<style lang="stylus" scoped> +.cpu-memory + > svg + display block + padding 10px + width 50% + float left + + &:first-child + padding-right 5px + + &:last-child + padding-left 5px + + > text + font-size 5px + fill rgba(0, 0, 0, 0.55) + + > tspan + opacity 0.5 + + &:after + content "" + display block + clear both +</style> diff --git a/src/client/app/common/views/widgets/server.cpu.vue b/src/client/app/common/views/widgets/server.cpu.vue new file mode 100644 index 0000000000..596c856da8 --- /dev/null +++ b/src/client/app/common/views/widgets/server.cpu.vue @@ -0,0 +1,68 @@ +<template> +<div class="cpu"> + <x-pie class="pie" :value="usage"/> + <div> + <p>%fa:microchip%CPU</p> + <p>{{ meta.cpu.cores }} Cores</p> + <p>{{ meta.cpu.model }}</p> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPie from './server.pie.vue'; + +export default Vue.extend({ + components: { + XPie + }, + props: ['connection', 'meta'], + data() { + return { + usage: 0 + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + this.usage = stats.cpu_usage; + } + } +}); +</script> + +<style lang="stylus" scoped> +.cpu + > .pie + padding 10px + height 100px + float left + + > div + float left + width calc(100% - 100px) + padding 10px 10px 10px 0 + + > p + margin 0 + font-size 12px + color #505050 + + &:first-child + font-weight bold + + > [data-fa] + margin-right 4px + + &:after + content "" + display block + clear both + +</style> diff --git a/src/client/app/common/views/widgets/server.disk.vue b/src/client/app/common/views/widgets/server.disk.vue new file mode 100644 index 0000000000..2af1982a96 --- /dev/null +++ b/src/client/app/common/views/widgets/server.disk.vue @@ -0,0 +1,76 @@ +<template> +<div class="disk"> + <x-pie class="pie" :value="usage"/> + <div> + <p>%fa:R hdd%Storage</p> + <p>Total: {{ total | bytes(1) }}</p> + <p>Available: {{ available | bytes(1) }}</p> + <p>Used: {{ used | bytes(1) }}</p> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPie from './server.pie.vue'; + +export default Vue.extend({ + components: { + XPie + }, + props: ['connection'], + data() { + return { + usage: 0, + total: 0, + used: 0, + available: 0 + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + stats.disk.used = stats.disk.total - stats.disk.free; + this.usage = stats.disk.used / stats.disk.total; + this.total = stats.disk.total; + this.used = stats.disk.used; + this.available = stats.disk.available; + } + } +}); +</script> + +<style lang="stylus" scoped> +.disk + > .pie + padding 10px + height 100px + float left + + > div + float left + width calc(100% - 100px) + padding 10px 10px 10px 0 + + > p + margin 0 + font-size 12px + color #505050 + + &:first-child + font-weight bold + + > [data-fa] + margin-right 4px + + &:after + content "" + display block + clear both + +</style> diff --git a/src/client/app/common/views/widgets/server.info.vue b/src/client/app/common/views/widgets/server.info.vue new file mode 100644 index 0000000000..d243629506 --- /dev/null +++ b/src/client/app/common/views/widgets/server.info.vue @@ -0,0 +1,25 @@ +<template> +<div class="info"> + <p>Maintainer: <b><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></b></p> + <p>Machine: {{ meta.machine }}</p> + <p>Node: {{ meta.node }}</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['meta'] +}); +</script> + +<style lang="stylus" scoped> +.info + padding 10px 14px + + > p + margin 0 + font-size 12px + color #505050 +</style> diff --git a/src/client/app/common/views/widgets/server.memory.vue b/src/client/app/common/views/widgets/server.memory.vue new file mode 100644 index 0000000000..834a62671d --- /dev/null +++ b/src/client/app/common/views/widgets/server.memory.vue @@ -0,0 +1,76 @@ +<template> +<div class="memory"> + <x-pie class="pie" :value="usage"/> + <div> + <p>%fa:flask%Memory</p> + <p>Total: {{ total | bytes(1) }}</p> + <p>Used: {{ used | bytes(1) }}</p> + <p>Free: {{ free | bytes(1) }}</p> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPie from './server.pie.vue'; + +export default Vue.extend({ + components: { + XPie + }, + props: ['connection'], + data() { + return { + usage: 0, + total: 0, + used: 0, + free: 0 + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + stats.mem.used = stats.mem.total - stats.mem.free; + this.usage = stats.mem.used / stats.mem.total; + this.total = stats.mem.total; + this.used = stats.mem.used; + this.free = stats.mem.free; + } + } +}); +</script> + +<style lang="stylus" scoped> +.memory + > .pie + padding 10px + height 100px + float left + + > div + float left + width calc(100% - 100px) + padding 10px 10px 10px 0 + + > p + margin 0 + font-size 12px + color #505050 + + &:first-child + font-weight bold + + > [data-fa] + margin-right 4px + + &:after + content "" + display block + clear both + +</style> diff --git a/src/client/app/common/views/widgets/server.pie.vue b/src/client/app/common/views/widgets/server.pie.vue new file mode 100644 index 0000000000..ce2cff1d00 --- /dev/null +++ b/src/client/app/common/views/widgets/server.pie.vue @@ -0,0 +1,61 @@ +<template> +<svg viewBox="0 0 1 1" preserveAspectRatio="none"> + <circle + :r="r" + cx="50%" cy="50%" + fill="none" + stroke-width="0.1" + stroke="rgba(0, 0, 0, 0.05)"/> + <circle + :r="r" + cx="50%" cy="50%" + :stroke-dasharray="Math.PI * (r * 2)" + :stroke-dashoffset="strokeDashoffset" + fill="none" + stroke-width="0.1" + :stroke="color"/> + <text x="50%" y="50%" dy="0.05" text-anchor="middle">{{ (value * 100).toFixed(0) }}%</text> +</svg> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + type: Number, + required: true + } + }, + data() { + return { + r: 0.4 + }; + }, + computed: { + color(): string { + return `hsl(${180 - (this.value * 180)}, 80%, 70%)`; + }, + strokeDashoffset(): number { + return (1 - this.value) * (Math.PI * (this.r * 2)); + } + } +}); +</script> + +<style lang="stylus" scoped> +svg + display block + height 100% + + > circle + transform-origin center + transform rotate(-90deg) + transition stroke-dashoffset 0.5s ease + + > text + font-size 0.15px + fill rgba(0, 0, 0, 0.6) + +</style> diff --git a/src/client/app/common/views/widgets/server.uptimes.vue b/src/client/app/common/views/widgets/server.uptimes.vue new file mode 100644 index 0000000000..06713d83ce --- /dev/null +++ b/src/client/app/common/views/widgets/server.uptimes.vue @@ -0,0 +1,46 @@ +<template> +<div class="uptimes"> + <p>Uptimes</p> + <p>Process: {{ process ? process.toFixed(0) : '---' }}s</p> + <p>OS: {{ os ? os.toFixed(0) : '---' }}s</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['connection'], + data() { + return { + process: 0, + os: 0 + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + this.process = stats.process_uptime; + this.os = stats.os_uptime; + } + } +}); +</script> + +<style lang="stylus" scoped> +.uptimes + padding 10px 14px + + > p + margin 0 + font-size 12px + color #505050 + + &:first-child + font-weight bold +</style> diff --git a/src/client/app/common/views/widgets/server.vue b/src/client/app/common/views/widgets/server.vue new file mode 100644 index 0000000000..3d5248998f --- /dev/null +++ b/src/client/app/common/views/widgets/server.vue @@ -0,0 +1,93 @@ +<template> +<div class="mkw-server"> + <mk-widget-container :show-header="props.design == 0" :naked="props.design == 2"> + <template slot="header">%fa:server%%i18n:desktop.tags.mk-server-home-widget.title%</template> + <button slot="func" @click="toggle" title="%i18n:desktop.tags.mk-server-home-widget.toggle%">%fa:sort%</button> + + <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <template v-if="!fetching"> + <x-cpu-memory v-show="props.view == 0" :connection="connection"/> + <x-cpu v-show="props.view == 1" :connection="connection" :meta="meta"/> + <x-memory v-show="props.view == 2" :connection="connection"/> + <x-disk v-show="props.view == 3" :connection="connection"/> + <x-uptimes v-show="props.view == 4" :connection="connection"/> + <x-info v-show="props.view == 5" :connection="connection" :meta="meta"/> + </template> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import XCpuMemory from './server.cpu-memory.vue'; +import XCpu from './server.cpu.vue'; +import XMemory from './server.memory.vue'; +import XDisk from './server.disk.vue'; +import XUptimes from './server.uptimes.vue'; +import XInfo from './server.info.vue'; + +export default define({ + name: 'server', + props: () => ({ + design: 0, + view: 0 + }) +}).extend({ + components: { + XCpuMemory, + XCpu, + XMemory, + XDisk, + XUptimes, + XInfo + }, + data() { + return { + fetching: true, + meta: null, + connection: null, + connectionId: null + }; + }, + mounted() { + (this as any).os.getMeta().then(meta => { + this.meta = meta; + this.fetching = false; + }); + + this.connection = (this as any).os.streams.serverStream.getConnection(); + this.connectionId = (this as any).os.streams.serverStream.use(); + }, + beforeDestroy() { + (this as any).os.streams.serverStream.dispose(this.connectionId); + }, + methods: { + toggle() { + if (this.props.view == 5) { + this.props.view = 0; + } else { + this.props.view++; + } + }, + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" module> +.fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/common/views/widgets/slideshow.vue b/src/client/app/common/views/widgets/slideshow.vue new file mode 100644 index 0000000000..ad32299f37 --- /dev/null +++ b/src/client/app/common/views/widgets/slideshow.vue @@ -0,0 +1,159 @@ +<template> +<div class="mkw-slideshow" :data-mobile="isMobile"> + <div @click="choose"> + <p v-if="props.folder === undefined"> + <template v-if="isCustomizeMode">フォルダを指定するには、カスタマイズモードを終了してください</template> + <template v-else>クリックしてフォルダを指定してください</template> + </p> + <p v-if="props.folder !== undefined && images.length == 0 && !fetching">このフォルダには画像がありません</p> + <div ref="slideA" class="slide a"></div> + <div ref="slideB" class="slide b"></div> + </div> +</div> +</template> + +<script lang="ts"> +import * as anime from 'animejs'; +import define from '../../../common/define-widget'; +export default define({ + name: 'slideshow', + props: () => ({ + folder: undefined, + size: 0 + }) +}).extend({ + data() { + return { + images: [], + fetching: true, + clock: null + }; + }, + mounted() { + this.$nextTick(() => { + this.applySize(); + }); + + if (this.props.folder !== undefined) { + this.fetch(); + } + + this.clock = setInterval(this.change, 10000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + func() { + this.resize(); + }, + applySize() { + let h; + + if (this.props.size == 1) { + h = 250; + } else { + h = 170; + } + + this.$el.style.height = `${h}px`; + }, + resize() { + if (this.props.size == 1) { + this.props.size = 0; + } else { + this.props.size++; + } + + this.applySize(); + }, + change() { + if (this.images.length == 0) return; + + const index = Math.floor(Math.random() * this.images.length); + const img = `url(${ this.images[index].url }?thumbnail&size=1024)`; + + (this.$refs.slideB as any).style.backgroundImage = img; + + anime({ + targets: this.$refs.slideB, + opacity: 1, + duration: 1000, + easing: 'linear', + complete: () => { + // 既にこのウィジェットがunmountされていたら要素がない + if ((this.$refs.slideA as any) == null) return; + + (this.$refs.slideA as any).style.backgroundImage = img; + anime({ + targets: this.$refs.slideB, + opacity: 0, + duration: 0 + }); + } + }); + }, + fetch() { + this.fetching = true; + + (this as any).api('drive/files', { + folderId: this.props.folder, + type: 'image/*', + limit: 100 + }).then(images => { + this.images = images; + this.fetching = false; + (this.$refs.slideA as any).style.backgroundImage = ''; + (this.$refs.slideB as any).style.backgroundImage = ''; + this.change(); + }); + }, + choose() { + (this as any).apis.chooseDriveFolder().then(folder => { + this.props.folder = folder ? folder.id : null; + this.fetch(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-slideshow + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-mobile] + border none + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + > div + width 100% + height 100% + cursor pointer + + > p + display block + margin 1em + text-align center + color #888 + + > * + pointer-events none + + > .slide + position absolute + top 0 + left 0 + width 100% + height 100% + background-size cover + background-position center + + &.b + opacity 0 + +</style> diff --git a/src/client/app/common/views/widgets/tips.vue b/src/client/app/common/views/widgets/tips.vue new file mode 100644 index 0000000000..bdecc068e1 --- /dev/null +++ b/src/client/app/common/views/widgets/tips.vue @@ -0,0 +1,108 @@ +<template> +<div class="mkw-tips"> + <p ref="tip">%fa:R lightbulb%<span v-html="tip"></span></p> +</div> +</template> + +<script lang="ts"> +import * as anime from 'animejs'; +import define from '../../../common/define-widget'; + +const tips = [ + '<kbd>t</kbd>でタイムラインにフォーカスできます', + '<kbd>p</kbd>または<kbd>n</kbd>で投稿フォームを開きます', + '投稿フォームにはファイルをドラッグ&ドロップできます', + '投稿フォームにクリップボードにある画像データをペーストできます', + 'ドライブにファイルをドラッグ&ドロップしてアップロードできます', + 'ドライブでファイルをドラッグしてフォルダ移動できます', + 'ドライブでフォルダをドラッグしてフォルダ移動できます', + 'ホームは設定からカスタマイズできます', + 'MisskeyはMIT Licenseです', + 'タイムマシンウィジェットを利用すると、簡単に過去のタイムラインに遡れます', + '投稿の ... をクリックして、投稿をユーザーページにピン留めできます', + 'ドライブの容量は(デフォルトで)1GBです', + '投稿に添付したファイルは全てドライブに保存されます', + 'ホームのカスタマイズ中、ウィジェットを右クリックしてデザインを変更できます', + 'タイムライン上部にもウィジェットを設置できます', + '投稿をダブルクリックすると詳細が見れます', + '「**」でテキストを囲むと**強調表示**されます', + 'チャンネルウィジェットを利用すると、よく利用するチャンネルを素早く確認できます', + 'いくつかのウィンドウはブラウザの外に切り離すことができます', + 'カレンダーウィジェットのパーセンテージは、経過の割合を示しています', + 'APIを利用してbotの開発なども行えます', + 'MisskeyはLINEを通じてでも利用できます', + 'まゆかわいいよまゆ', + 'Misskeyは2014年にサービスを開始しました', + '対応ブラウザではMisskeyを開いていなくても通知を受け取れます' +] + +export default define({ + name: 'tips' +}).extend({ + data() { + return { + tip: null, + clock: null + }; + }, + mounted() { + this.$nextTick(() => { + this.set(); + }); + + this.clock = setInterval(this.change, 20000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + set() { + this.tip = tips[Math.floor(Math.random() * tips.length)]; + }, + change() { + anime({ + targets: this.$refs.tip, + opacity: 0, + duration: 500, + easing: 'linear', + complete: this.set + }); + + setTimeout(() => { + anime({ + targets: this.$refs.tip, + opacity: 1, + duration: 500, + easing: 'linear' + }); + }, 500); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-tips + overflow visible !important + + > p + display block + margin 0 + padding 0 12px + text-align center + font-size 0.7em + color #999 + + > [data-fa] + margin-right 4px + + kbd + display inline + padding 0 6px + margin 0 2px + font-size 1em + font-family inherit + border solid 1px #999 + border-radius 2px + +</style> diff --git a/src/client/app/common/views/widgets/version.vue b/src/client/app/common/views/widgets/version.vue new file mode 100644 index 0000000000..30b632b396 --- /dev/null +++ b/src/client/app/common/views/widgets/version.vue @@ -0,0 +1,29 @@ +<template> +<p>ver {{ version }} ({{ codename }})</p> +</template> + +<script lang="ts"> +import { version, codename } from '../../../config'; +import define from '../../../common/define-widget'; +export default define({ + name: 'version' +}).extend({ + data() { + return { + version, + codename + }; + } +}); +</script> + +<style lang="stylus" scoped> +p + display block + margin 0 + padding 0 12px + text-align center + font-size 0.7em + color #aaa + +</style> diff --git a/src/client/app/config.ts b/src/client/app/config.ts new file mode 100644 index 0000000000..522d7ff056 --- /dev/null +++ b/src/client/app/config.ts @@ -0,0 +1,37 @@ +declare const _HOST_: string; +declare const _HOSTNAME_: string; +declare const _URL_: string; +declare const _API_URL_: string; +declare const _WS_URL_: string; +declare const _DOCS_URL_: string; +declare const _STATS_URL_: string; +declare const _STATUS_URL_: string; +declare const _DEV_URL_: string; +declare const _LANG_: string; +declare const _RECAPTCHA_SITEKEY_: string; +declare const _SW_PUBLICKEY_: string; +declare const _THEME_COLOR_: string; +declare const _COPYRIGHT_: string; +declare const _VERSION_: string; +declare const _CODENAME_: string; +declare const _LICENSE_: string; +declare const _GOOGLE_MAPS_API_KEY_: string; + +export const host = _HOST_; +export const hostname = _HOSTNAME_; +export const url = _URL_; +export const apiUrl = _API_URL_; +export const wsUrl = _WS_URL_; +export const docsUrl = _DOCS_URL_; +export const statsUrl = _STATS_URL_; +export const statusUrl = _STATUS_URL_; +export const devUrl = _DEV_URL_; +export const lang = _LANG_; +export const recaptchaSitekey = _RECAPTCHA_SITEKEY_; +export const swPublickey = _SW_PUBLICKEY_; +export const themeColor = _THEME_COLOR_; +export const copyright = _COPYRIGHT_; +export const version = _VERSION_; +export const codename = _CODENAME_; +export const license = _LICENSE_; +export const googleMapsApiKey = _GOOGLE_MAPS_API_KEY_; diff --git a/src/client/app/desktop/api/choose-drive-file.ts b/src/client/app/desktop/api/choose-drive-file.ts new file mode 100644 index 0000000000..fbda600e6e --- /dev/null +++ b/src/client/app/desktop/api/choose-drive-file.ts @@ -0,0 +1,30 @@ +import { url } from '../../config'; +import MkChooseFileFromDriveWindow from '../views/components/choose-file-from-drive-window.vue'; + +export default function(opts) { + return new Promise((res, rej) => { + const o = opts || {}; + + if (document.body.clientWidth > 800) { + const w = new MkChooseFileFromDriveWindow({ + propsData: { + title: o.title, + multiple: o.multiple, + initFolder: o.currentFolder + } + }).$mount(); + w.$once('selected', file => { + res(file); + }); + document.body.appendChild(w.$el); + } else { + window['cb'] = file => { + res(file); + }; + + window.open(url + '/selectdrive', + 'choose_drive_window', + 'height=500, width=800'); + } + }); +} diff --git a/src/client/app/desktop/api/choose-drive-folder.ts b/src/client/app/desktop/api/choose-drive-folder.ts new file mode 100644 index 0000000000..9b33a20d9a --- /dev/null +++ b/src/client/app/desktop/api/choose-drive-folder.ts @@ -0,0 +1,17 @@ +import MkChooseFolderFromDriveWindow from '../views/components/choose-folder-from-drive-window.vue'; + +export default function(opts) { + return new Promise((res, rej) => { + const o = opts || {}; + const w = new MkChooseFolderFromDriveWindow({ + 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/desktop/api/contextmenu.ts b/src/client/app/desktop/api/contextmenu.ts new file mode 100644 index 0000000000..b70d7122d3 --- /dev/null +++ b/src/client/app/desktop/api/contextmenu.ts @@ -0,0 +1,16 @@ +import Ctx from '../views/components/context-menu.vue'; + +export default function(e, menu, opts?) { + const o = opts || {}; + const vm = new Ctx({ + propsData: { + menu, + x: e.pageX - window.pageXOffset, + y: e.pageY - window.pageYOffset, + } + }).$mount(); + vm.$once('closed', () => { + if (o.closed) o.closed(); + }); + document.body.appendChild(vm.$el); +} diff --git a/src/client/app/desktop/api/dialog.ts b/src/client/app/desktop/api/dialog.ts new file mode 100644 index 0000000000..07935485b0 --- /dev/null +++ b/src/client/app/desktop/api/dialog.ts @@ -0,0 +1,19 @@ +import Dialog from '../views/components/dialog.vue'; + +export default function(opts) { + return new Promise<string>((res, rej) => { + const o = opts || {}; + const d = new Dialog({ + propsData: { + title: o.title, + text: o.text, + modal: o.modal, + buttons: o.actions + } + }).$mount(); + d.$once('clicked', id => { + res(id); + }); + document.body.appendChild(d.$el); + }); +} diff --git a/src/client/app/desktop/api/input.ts b/src/client/app/desktop/api/input.ts new file mode 100644 index 0000000000..ce26a8112f --- /dev/null +++ b/src/client/app/desktop/api/input.ts @@ -0,0 +1,20 @@ +import InputDialog from '../views/components/input-dialog.vue'; + +export default function(opts) { + return new Promise<string>((res, rej) => { + const o = opts || {}; + const d = new InputDialog({ + propsData: { + title: o.title, + placeholder: o.placeholder, + default: o.default, + type: o.type || 'text', + allowEmpty: o.allowEmpty + } + }).$mount(); + d.$once('done', text => { + res(text); + }); + document.body.appendChild(d.$el); + }); +} diff --git a/src/client/app/desktop/api/notify.ts b/src/client/app/desktop/api/notify.ts new file mode 100644 index 0000000000..1f89f40ce6 --- /dev/null +++ b/src/client/app/desktop/api/notify.ts @@ -0,0 +1,10 @@ +import Notification from '../views/components/ui-notification.vue'; + +export default function(message) { + const vm = new Notification({ + propsData: { + message + } + }).$mount(); + document.body.appendChild(vm.$el); +} diff --git a/src/client/app/desktop/api/post.ts b/src/client/app/desktop/api/post.ts new file mode 100644 index 0000000000..cf49615df3 --- /dev/null +++ b/src/client/app/desktop/api/post.ts @@ -0,0 +1,21 @@ +import PostFormWindow from '../views/components/post-form-window.vue'; +import RepostFormWindow from '../views/components/repost-form-window.vue'; + +export default function(opts) { + const o = opts || {}; + if (o.repost) { + const vm = new RepostFormWindow({ + propsData: { + repost: o.repost + } + }).$mount(); + document.body.appendChild(vm.$el); + } else { + const vm = new PostFormWindow({ + propsData: { + reply: o.reply + } + }).$mount(); + document.body.appendChild(vm.$el); + } +} diff --git a/src/client/app/desktop/api/update-avatar.ts b/src/client/app/desktop/api/update-avatar.ts new file mode 100644 index 0000000000..36a2ffe914 --- /dev/null +++ b/src/client/app/desktop/api/update-avatar.ts @@ -0,0 +1,98 @@ +import OS from '../../common/mios'; +import { apiUrl } from '../../config'; +import CropWindow from '../views/components/crop-window.vue'; +import ProgressDialog from '../views/components/progress-dialog.vue'; + +export default (os: OS) => (cb, file = null) => { + const fileSelected = file => { + + const w = new CropWindow({ + propsData: { + image: file, + title: 'アバターとして表示する部分を選択', + aspectRatio: 1 / 1 + } + }).$mount(); + + w.$once('cropped', blob => { + const data = new FormData(); + data.append('i', os.i.account.token); + data.append('file', blob, file.name + '.cropped.png'); + + os.api('drive/folders/find', { + name: 'アイコン' + }).then(iconFolder => { + if (iconFolder.length === 0) { + os.api('drive/folders/create', { + name: 'アイコン' + }).then(iconFolder => { + upload(data, iconFolder); + }); + } else { + upload(data, iconFolder[0]); + } + }); + }); + + w.$once('skipped', () => { + set(file); + }); + + document.body.appendChild(w.$el); + }; + + const upload = (data, folder) => { + const dialog = new ProgressDialog({ + propsData: { + title: '新しいアバターをアップロードしています' + } + }).$mount(); + document.body.appendChild(dialog.$el); + + if (folder) data.append('folderId', folder.id); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = e => { + const file = JSON.parse((e.target as any).response); + (dialog as any).close(); + set(file); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) (dialog as any).update(e.loaded, e.total); + }; + + xhr.send(data); + }; + + const set = file => { + os.api('i/update', { + avatarId: file.id + }).then(i => { + os.i.avatarId = i.avatarId; + os.i.avatarUrl = i.avatarUrl; + + os.apis.dialog({ + title: '%fa:info-circle%アバターを更新しました', + text: '新しいアバターが反映されるまで時間がかかる場合があります。', + actions: [{ + text: 'わかった' + }] + }); + + if (cb) cb(i); + }); + }; + + if (file) { + fileSelected(file); + } else { + os.apis.chooseDriveFile({ + multiple: false, + title: '%fa:image%アバターにする画像を選択' + }).then(file => { + fileSelected(file); + }); + } +}; diff --git a/src/client/app/desktop/api/update-banner.ts b/src/client/app/desktop/api/update-banner.ts new file mode 100644 index 0000000000..e66dbf016b --- /dev/null +++ b/src/client/app/desktop/api/update-banner.ts @@ -0,0 +1,98 @@ +import OS from '../../common/mios'; +import { apiUrl } from '../../config'; +import CropWindow from '../views/components/crop-window.vue'; +import ProgressDialog from '../views/components/progress-dialog.vue'; + +export default (os: OS) => (cb, file = null) => { + const fileSelected = file => { + + const w = new CropWindow({ + propsData: { + image: file, + title: 'バナーとして表示する部分を選択', + aspectRatio: 16 / 9 + } + }).$mount(); + + w.$once('cropped', blob => { + const data = new FormData(); + data.append('i', os.i.account.token); + data.append('file', blob, file.name + '.cropped.png'); + + os.api('drive/folders/find', { + name: 'バナー' + }).then(bannerFolder => { + if (bannerFolder.length === 0) { + os.api('drive/folders/create', { + name: 'バナー' + }).then(iconFolder => { + upload(data, iconFolder); + }); + } else { + upload(data, bannerFolder[0]); + } + }); + }); + + w.$once('skipped', () => { + set(file); + }); + + document.body.appendChild(w.$el); + }; + + const upload = (data, folder) => { + const dialog = new ProgressDialog({ + propsData: { + title: '新しいバナーをアップロードしています' + } + }).$mount(); + document.body.appendChild(dialog.$el); + + if (folder) data.append('folderId', folder.id); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = e => { + const file = JSON.parse((e.target as any).response); + (dialog as any).close(); + set(file); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) (dialog as any).update(e.loaded, e.total); + }; + + xhr.send(data); + }; + + const set = file => { + os.api('i/update', { + bannerId: file.id + }).then(i => { + os.i.bannerId = i.bannerId; + os.i.bannerUrl = i.bannerUrl; + + os.apis.dialog({ + title: '%fa:info-circle%バナーを更新しました', + text: '新しいバナーが反映されるまで時間がかかる場合があります。', + actions: [{ + text: 'わかった' + }] + }); + + if (cb) cb(i); + }); + }; + + if (file) { + fileSelected(file); + } else { + os.apis.chooseDriveFile({ + multiple: false, + title: '%fa:image%バナーにする画像を選択' + }).then(file => { + fileSelected(file); + }); + } +}; diff --git a/src/client/app/desktop/assets/grid.svg b/src/client/app/desktop/assets/grid.svg new file mode 100644 index 0000000000..d1d72cd8ce --- /dev/null +++ b/src/client/app/desktop/assets/grid.svg @@ -0,0 +1,150 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="32" + height="32" + viewBox="0 0 8.4666665 8.4666669" + version="1.1" + id="svg8" + inkscape:version="0.92.1 r15371" + sodipodi:docname="grid.svg"> + <defs + id="defs2" /> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="22.4" + inkscape:cx="14.687499" + inkscape:cy="14.558219" + inkscape:document-units="px" + inkscape:current-layer="layer1" + showgrid="true" + units="px" + showguides="true" + inkscape:window-width="1920" + inkscape:window-height="1017" + inkscape:window-x="-8" + inkscape:window-y="1072" + inkscape:window-maximized="1"> + <inkscape:grid + type="xygrid" + id="grid3680" + empspacing="8" + empcolor="#ff3fff" + empopacity="0.41176471" /> + </sodipodi:namedview> + <metadata + id="metadata5"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="レイヤー 1" + inkscape:groupmode="layer" + id="layer1" + transform="translate(0,-288.53331)"> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 0,296.99998 v -8.46667 h 8.4666666 l 10e-8,0.26458 H 0.26458333 l 0,8.20209 z" + id="path3684" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333334,292.23748 h 0.2645833 v 0.52916 h 0.5291667 l 0,0.26459 H 4.4979167 v 0.52917 H 4.2333334 v -0.52917 H 3.7041667 l 0,-0.26459 h 0.5291667 z" + id="path4491" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccccccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 3.4395833,292.76664 0,0.26459 H 2.38125 l 0,-0.26459 z" + id="path4493" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 6.3499999,292.76664 10e-8,0.26459 H 5.2916667 l -1e-7,-0.26459 z" + id="path4493-2" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 7.6729167,292.76664 v 0.26459 H 6.6145834 v -0.26459 z" + id="path4493-6" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 2.1166666,292.76664 1e-7,0.26459 H 1.0583334 l -1e-7,-0.26459 z" + id="path4493-1" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333333,291.97289 0.2645834,0 v -1.05833 l -0.2645834,0 z" + id="path4522" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333334,290.64997 0.2645833,1e-5 v -1.05833 l -0.2645833,-1e-5 z" + id="path4522-7" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333334,294.88331 h 0.2645833 v -1.05833 H 4.2333334 Z" + id="path4522-5" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333333,296.20622 h 0.2645833 v -1.05833 H 4.2333333 Z" + id="path4522-74" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333334,289.32706 0.2645834,10e-6 -10e-8,-0.52918 -0.2645834,-10e-6 z" + id="path4522-7-4" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333332,296.99998 h 0.2645835 l 0,-0.52917 H 4.2333333 Z" + id="path4522-7-4-4" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 0.79375,292.76664 -3e-8,0.26459 -0.52916667,0 3e-8,-0.26459 z" + id="path4493-1-7" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 8.4666667,292.76664 v 0.26459 l -0.5291667,0 v -0.26459 z" + id="path4493-1-7-2" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + </g> +</svg> diff --git a/src/client/app/desktop/assets/header-logo-white.svg b/src/client/app/desktop/assets/header-logo-white.svg new file mode 100644 index 0000000000..8082edb30d --- /dev/null +++ b/src/client/app/desktop/assets/header-logo-white.svg @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="256px" height="256px" viewBox="0 0 256 256" enable-background="new 0 0 256 256" xml:space="preserve">
+<circle fill="#FFFFFF" cx="128" cy="153.6" r="19.201"/>
+<circle fill="#FFFFFF" cx="51.2" cy="153.6" r="19.2"/>
+<circle fill="#FFFFFF" cx="204.8" cy="153.6" r="19.2"/>
+<polyline fill="none" stroke="#FFFFFF" stroke-width="16" stroke-linejoin="round" stroke-miterlimit="10" points="51.2,153.6
+ 89.601,102.4 128,153.6 166.4,102.4 204.799,153.6 "/>
+<circle fill="#FFFFFF" cx="89.6" cy="102.4" r="19.2"/>
+<circle fill="#FFFFFF" cx="166.4" cy="102.4" r="19.199"/>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>
diff --git a/src/client/app/desktop/assets/header-logo.svg b/src/client/app/desktop/assets/header-logo.svg new file mode 100644 index 0000000000..3a2207954a --- /dev/null +++ b/src/client/app/desktop/assets/header-logo.svg @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="256px" height="256px" viewBox="0 0 256 256" enable-background="new 0 0 256 256" xml:space="preserve">
+<circle cx="128" cy="153.6" r="19.201"/>
+<circle cx="51.2" cy="153.6" r="19.2"/>
+<circle cx="204.8" cy="153.6" r="19.2"/>
+<polyline fill="none" stroke="#000000" stroke-width="16" stroke-linejoin="round" stroke-miterlimit="10" points="51.2,153.6
+ 89.601,102.4 128,153.6 166.4,102.4 204.799,153.6 "/>
+<circle cx="89.6" cy="102.4" r="19.2"/>
+<circle cx="166.4" cy="102.4" r="19.199"/>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>
diff --git a/src/client/app/desktop/assets/index.jpg b/src/client/app/desktop/assets/index.jpg Binary files differnew file mode 100644 index 0000000000..10c412efe2 --- /dev/null +++ b/src/client/app/desktop/assets/index.jpg diff --git a/src/client/app/desktop/assets/remove.png b/src/client/app/desktop/assets/remove.png Binary files differnew file mode 100644 index 0000000000..8b1f4c06c9 --- /dev/null +++ b/src/client/app/desktop/assets/remove.png diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts new file mode 100644 index 0000000000..b95e168544 --- /dev/null +++ b/src/client/app/desktop/script.ts @@ -0,0 +1,167 @@ +/** + * Desktop Client + */ + +import VueRouter from 'vue-router'; + +// Style +import './style.styl'; +import '../../element.scss'; + +import init from '../init'; +import fuckAdBlock from '../common/scripts/fuck-ad-block'; +import { HomeStreamManager } from '../common/scripts/streaming/home'; +import composeNotification from '../common/scripts/compose-notification'; + +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 updateAvatar from './api/update-avatar'; +import updateBanner from './api/update-banner'; + +import MkIndex from './views/pages/index.vue'; +import MkUser from './views/pages/user/user.vue'; +import MkSelectDrive from './views/pages/selectdrive.vue'; +import MkDrive from './views/pages/drive.vue'; +import MkHomeCustomize from './views/pages/home-customize.vue'; +import MkMessagingRoom from './views/pages/messaging-room.vue'; +import MkPost from './views/pages/post.vue'; +import MkSearch from './views/pages/search.vue'; +import MkOthello from './views/pages/othello.vue'; + +/** + * init + */ +init(async (launch) => { + // Register directives + require('./views/directives'); + + // Register components + require('./views/components'); + require('./views/widgets'); + + // Init router + const router = new VueRouter({ + mode: 'history', + routes: [ + { path: '/', name: 'index', component: MkIndex }, + { path: '/i/customize-home', component: MkHomeCustomize }, + { path: '/i/messaging/:user', component: MkMessagingRoom }, + { path: '/i/drive', component: MkDrive }, + { path: '/i/drive/folder/:folder', 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/:post', component: MkPost } + ] + }); + + // Launch the app + const [, os] = launch(router, os => ({ + chooseDriveFolder, + chooseDriveFile, + dialog, + input, + post, + notify, + updateAvatar: updateAvatar(os), + updateBanner: updateBanner(os) + })); + + /** + * Fuck AD Block + */ + fuckAdBlock(os); + + /** + * Init Notification + */ + if ('Notification' in window) { + // 許可を得ていなかったらリクエスト + if ((Notification as any).permission == 'default') { + await Notification.requestPermission(); + } + + if ((Notification as any).permission == 'granted') { + registerNotifications(os.stream); + } + } +}, true); + +function registerNotifications(stream: HomeStreamManager) { + if (stream == null) return; + + if (stream.hasConnection) { + attach(stream.borrow()); + } + + stream.on('connected', connection => { + attach(connection); + }); + + function attach(connection) { + connection.on('drive_file_created', file => { + const _n = composeNotification('drive_file_created', file); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + setTimeout(n.close.bind(n), 5000); + }); + + connection.on('mention', post => { + const _n = composeNotification('mention', post); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + setTimeout(n.close.bind(n), 6000); + }); + + connection.on('reply', post => { + const _n = composeNotification('reply', post); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + setTimeout(n.close.bind(n), 6000); + }); + + connection.on('quote', post => { + const _n = composeNotification('quote', post); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + setTimeout(n.close.bind(n), 6000); + }); + + connection.on('unread_messaging_message', message => { + const _n = composeNotification('unread_messaging_message', message); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + n.onclick = () => { + n.close(); + /*(riot as any).mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), { + user: message.user + });*/ + }; + setTimeout(n.close.bind(n), 7000); + }); + + connection.on('othello_invited', matching => { + const _n = composeNotification('othello_invited', matching); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + }); + } +} diff --git a/src/client/app/desktop/style.styl b/src/client/app/desktop/style.styl new file mode 100644 index 0000000000..49f71fbde7 --- /dev/null +++ b/src/client/app/desktop/style.styl @@ -0,0 +1,50 @@ +@import "../app" +@import "../reset" + +@import "./ui" + +*::input-placeholder + color #D8CBC5 + +* + &:focus + outline none + + &::scrollbar + width 5px + background transparent + + &:horizontal + height 5px + + &::scrollbar-button + width 0 + height 0 + background rgba(0, 0, 0, 0.2) + + &::scrollbar-piece + background transparent + + &:start + background transparent + + &::scrollbar-thumb + background rgba(0, 0, 0, 0.2) + + &:hover + background rgba(0, 0, 0, 0.4) + + &:active + background $theme-color + + &::scrollbar-corner + background rgba(0, 0, 0, 0.2) + +html + height 100% + background #f7f7f7 + +body + display flex + flex-direction column + min-height 100% diff --git a/src/client/app/desktop/ui.styl b/src/client/app/desktop/ui.styl new file mode 100644 index 0000000000..5a8d1718e2 --- /dev/null +++ b/src/client/app/desktop/ui.styl @@ -0,0 +1,125 @@ +@import "../../const" + +button + font-family sans-serif + + * + pointer-events none + +button.ui +.button.ui + display inline-block + cursor pointer + padding 0 14px + margin 0 + min-width 100px + line-height 38px + font-size 14px + color #888 + text-decoration none + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + border-radius 4px + outline none + + &.block + display block + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.primary + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + +input:not([type]).ui +input[type='text'].ui +input[type='password'].ui +input[type='email'].ui +input[type='date'].ui +input[type='number'].ui +textarea.ui + display block + padding 10px + width 100% + height 40px + font-family sans-serif + font-size 16px + color #55595c + border solid 1px #dadada + border-radius 4px + + &:hover + border-color #b0b0b0 + + &:focus + border-color $theme-color + +textarea.ui + min-width 100% + max-width 100% + min-height 64px + +.ui.info + display block + margin 1em 0 + padding 0 1em + font-size 90% + color rgba(#000, 0.87) + background #f8f8f9 + border solid 1px rgba(34, 36, 38, 0.22) + border-radius 4px + + > p + opacity 0.8 + + > [data-fa]:first-child + margin-right 0.25em + + &.warn + color #573a08 + background #FFFAF3 + border-color #C9BA9B + +.ui.from.group + display block + margin 16px 0 + + > p:first-child + margin 0 0 6px 0 + font-size 90% + font-weight bold + color rgba(#373a3c, 0.9) diff --git a/src/client/app/desktop/views/components/activity.calendar.vue b/src/client/app/desktop/views/components/activity.calendar.vue new file mode 100644 index 0000000000..72233e9aca --- /dev/null +++ b/src/client/app/desktop/views/components/activity.calendar.vue @@ -0,0 +1,66 @@ +<template> +<svg viewBox="0 0 21 7" preserveAspectRatio="none"> + <rect v-for="record in data" class="day" + width="1" height="1" + :x="record.x" :y="record.date.weekday" + rx="1" ry="1" + fill="transparent"> + <title>{{ record.date.year }}/{{ record.date.month }}/{{ record.date.day }}</title> + </rect> + <rect v-for="record in data" class="day" + :width="record.v" :height="record.v" + :x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)" + rx="1" ry="1" + :fill="record.color" + style="pointer-events: none;"/> + <rect class="today" + width="1" height="1" + :x="data[data.length - 1].x" :y="data[data.length - 1].date.weekday" + rx="1" ry="1" + fill="none" + stroke-width="0.1" + stroke="#f73520"/> +</svg> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['data'], + created() { + this.data.forEach(d => d.total = d.posts + d.replies + d.reposts); + const peak = Math.max.apply(null, this.data.map(d => d.total)); + + let x = 0; + this.data.reverse().forEach(d => { + d.x = x; + d.date.weekday = (new Date(d.date.year, d.date.month - 1, d.date.day)).getDay(); + + d.v = peak == 0 ? 0 : d.total / (peak / 2); + if (d.v > 1) d.v = 1; + const ch = d.date.weekday == 0 || d.date.weekday == 6 ? 275 : 170; + const cs = d.v * 100; + const cl = 15 + ((1 - d.v) * 80); + d.color = `hsl(${ch}, ${cs}%, ${cl}%)`; + + if (d.date.weekday == 6) x++; + }); + } +}); +</script> + +<style lang="stylus" scoped> +svg + display block + padding 10px + width 100% + + > rect + transform-origin center + + &.day + &:hover + fill rgba(0, 0, 0, 0.05) + +</style> diff --git a/src/client/app/desktop/views/components/activity.chart.vue b/src/client/app/desktop/views/components/activity.chart.vue new file mode 100644 index 0000000000..5057786ed4 --- /dev/null +++ b/src/client/app/desktop/views/components/activity.chart.vue @@ -0,0 +1,103 @@ +<template> +<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" preserveAspectRatio="none" @mousedown.prevent="onMousedown"> + <title>Black ... Total<br/>Blue ... Posts<br/>Red ... Replies<br/>Green ... Reposts</title> + <polyline + :points="pointsPost" + fill="none" + stroke-width="1" + stroke="#41ddde"/> + <polyline + :points="pointsReply" + fill="none" + stroke-width="1" + stroke="#f7796c"/> + <polyline + :points="pointsRepost" + fill="none" + stroke-width="1" + stroke="#a1de41"/> + <polyline + :points="pointsTotal" + fill="none" + stroke-width="1" + stroke="#555" + stroke-dasharray="2 2"/> +</svg> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +function dragListen(fn) { + window.addEventListener('mousemove', fn); + window.addEventListener('mouseleave', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); +} + +function dragClear(fn) { + window.removeEventListener('mousemove', fn); + window.removeEventListener('mouseleave', dragClear); + window.removeEventListener('mouseup', dragClear); +} + +export default Vue.extend({ + props: ['data'], + data() { + return { + viewBoxX: 140, + viewBoxY: 60, + zoom: 1, + pos: 0, + pointsPost: null, + pointsReply: null, + pointsRepost: null, + pointsTotal: null + }; + }, + created() { + this.data.reverse(); + this.data.forEach(d => d.total = d.posts + d.replies + d.reposts); + this.render(); + }, + methods: { + render() { + const peak = Math.max.apply(null, this.data.map(d => d.total)); + if (peak != 0) { + this.pointsPost = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.posts / peak)) * this.viewBoxY}`).join(' '); + this.pointsReply = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' '); + this.pointsRepost = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.reposts / peak)) * this.viewBoxY}`).join(' '); + this.pointsTotal = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' '); + } + }, + onMousedown(e) { + const clickX = e.clientX; + const clickY = e.clientY; + const baseZoom = this.zoom; + const basePos = this.pos; + + // 動かした時 + dragListen(me => { + let moveLeft = me.clientX - clickX; + let moveTop = me.clientY - clickY; + + this.zoom = baseZoom + (-moveTop / 20); + this.pos = basePos + moveLeft; + if (this.zoom < 1) this.zoom = 1; + if (this.pos > 0) this.pos = 0; + if (this.pos < -(((this.data.length - 1) * this.zoom) - this.viewBoxX)) this.pos = -(((this.data.length - 1) * this.zoom) - this.viewBoxX); + + this.render(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +svg + display block + padding 10px + width 100% + cursor all-scroll + +</style> diff --git a/src/client/app/desktop/views/components/activity.vue b/src/client/app/desktop/views/components/activity.vue new file mode 100644 index 0000000000..480b956ecc --- /dev/null +++ b/src/client/app/desktop/views/components/activity.vue @@ -0,0 +1,116 @@ +<template> +<div class="mk-activity" :data-melt="design == 2"> + <template v-if="design == 0"> + <p class="title">%fa:chart-bar%%i18n:desktop.tags.mk-activity-widget.title%</p> + <button @click="toggle" title="%i18n:desktop.tags.mk-activity-widget.toggle%">%fa:sort%</button> + </template> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <template v-else> + <x-calendar v-show="view == 0" :data="[].concat(activity)"/> + <x-chart v-show="view == 1" :data="[].concat(activity)"/> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XCalendar from './activity.calendar.vue'; +import XChart from './activity.chart.vue'; + +export default Vue.extend({ + components: { + XCalendar, + XChart + }, + props: { + design: { + default: 0 + }, + initView: { + default: 0 + }, + user: { + type: Object, + required: true + } + }, + data() { + return { + fetching: true, + activity: null, + view: this.initView + }; + }, + mounted() { + (this as any).api('aggregation/users/activity', { + userId: this.user.id, + limit: 20 * 7 + }).then(activity => { + this.activity = activity; + this.fetching = false; + }); + }, + methods: { + toggle() { + if (this.view == 1) { + this.view = 0; + this.$emit('viewChanged', this.view); + } else { + this.view++; + this.$emit('viewChanged', this.view); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-activity + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-melt] + background transparent !important + border none !important + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/components/analog-clock.vue b/src/client/app/desktop/views/components/analog-clock.vue new file mode 100644 index 0000000000..81eec81598 --- /dev/null +++ b/src/client/app/desktop/views/components/analog-clock.vue @@ -0,0 +1,108 @@ +<template> +<canvas class="mk-analog-clock" ref="canvas" width="256" height="256"></canvas> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { themeColor } from '../../../config'; + +const Vec2 = function(this: any, x, y) { + this.x = x; + this.y = y; +}; + +export default Vue.extend({ + data() { + return { + clock: null + }; + }, + mounted() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + tick() { + const canv = this.$refs.canvas as any; + + const now = new Date(); + const s = now.getSeconds(); + const m = now.getMinutes(); + const h = now.getHours(); + + const ctx = canv.getContext('2d'); + const canvW = canv.width; + const canvH = canv.height; + ctx.clearRect(0, 0, canvW, canvH); + + { // 背景 + const center = Math.min((canvW / 2), (canvH / 2)); + const lineStart = center * 0.90; + const shortLineEnd = center * 0.87; + const longLineEnd = center * 0.84; + for (let i = 0; i < 60; i++) { + const angle = Math.PI * i / 30; + const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); + ctx.beginPath(); + ctx.lineWidth = 1; + ctx.moveTo((canvW / 2) + uv.x * lineStart, (canvH / 2) + uv.y * lineStart); + if (i % 5 == 0) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; + ctx.lineTo((canvW / 2) + uv.x * longLineEnd, (canvH / 2) + uv.y * longLineEnd); + } else { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.lineTo((canvW / 2) + uv.x * shortLineEnd, (canvH / 2) + uv.y * shortLineEnd); + } + ctx.stroke(); + } + } + + { // 分 + const angle = Math.PI * (m + s / 60) / 30; + const length = Math.min(canvW, canvH) / 2.6; + const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); + ctx.beginPath(); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.moveTo(canvW / 2 - uv.x * length / 5, canvH / 2 - uv.y * length / 5); + ctx.lineTo(canvW / 2 + uv.x * length, canvH / 2 + uv.y * length); + ctx.stroke(); + } + + { // 時 + const angle = Math.PI * (h % 12 + m / 60) / 6; + const length = Math.min(canvW, canvH) / 4; + const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); + ctx.beginPath(); + ctx.strokeStyle = themeColor; + ctx.lineWidth = 2; + ctx.moveTo(canvW / 2 - uv.x * length / 5, canvH / 2 - uv.y * length / 5); + ctx.lineTo(canvW / 2 + uv.x * length, canvH / 2 + uv.y * length); + ctx.stroke(); + } + + { // 秒 + const angle = Math.PI * s / 30; + const length = Math.min(canvW, canvH) / 2.6; + const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); + ctx.beginPath(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.moveTo(canvW / 2 - uv.x * length / 5, canvH / 2 - uv.y * length / 5); + ctx.lineTo(canvW / 2 + uv.x * length, canvH / 2 + uv.y * length); + ctx.stroke(); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-analog-clock + display block + width 256px + height 256px +</style> diff --git a/src/client/app/desktop/views/components/calendar.vue b/src/client/app/desktop/views/components/calendar.vue new file mode 100644 index 0000000000..71aab2e8a5 --- /dev/null +++ b/src/client/app/desktop/views/components/calendar.vue @@ -0,0 +1,252 @@ +<template> +<div class="mk-calendar" :data-melt="design == 4 || design == 5"> + <template v-if="design == 0 || design == 1"> + <button @click="prev" title="%i18n:desktop.tags.mk-calendar-widget.prev%">%fa:chevron-circle-left%</button> + <p class="title">{{ '%i18n:desktop.tags.mk-calendar-widget.title%'.replace('{1}', year).replace('{2}', month) }}</p> + <button @click="next" title="%i18n:desktop.tags.mk-calendar-widget.next%">%fa:chevron-circle-right%</button> + </template> + + <div class="calendar"> + <template v-if="design == 0 || design == 2 || design == 4"> + <div class="weekday" + v-for="(day, i) in Array(7).fill(0)" + :data-today="year == today.getFullYear() && month == today.getMonth() + 1 && today.getDay() == i" + :data-is-donichi="i == 0 || i == 6" + >{{ weekdayText[i] }}</div> + </template> + <div v-for="n in paddingDays"></div> + <div class="day" v-for="(day, i) in days" + :data-today="isToday(i + 1)" + :data-selected="isSelected(i + 1)" + :data-is-out-of-range="isOutOfRange(i + 1)" + :data-is-donichi="isDonichi(i + 1)" + @click="go(i + 1)" + :title="isOutOfRange(i + 1) ? null : '%i18n:desktop.tags.mk-calendar-widget.go%'" + > + <div>{{ i + 1 }}</div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +const eachMonthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +function isLeapYear(year) { + return (year % 400 == 0) ? true : + (year % 100 == 0) ? false : + (year % 4 == 0) ? true : + false; +} + +export default Vue.extend({ + props: { + design: { + default: 0 + }, + start: { + type: Date, + required: false + } + }, + data() { + return { + today: new Date(), + year: new Date().getFullYear(), + month: new Date().getMonth() + 1, + selected: new Date(), + weekdayText: [ + '%i18n:common.weekday-short.sunday%', + '%i18n:common.weekday-short.monday%', + '%i18n:common.weekday-short.tuesday%', + '%i18n:common.weekday-short.wednesday%', + '%i18n:common.weekday-short.thursday%', + '%i18n:common.weekday-short.friday%', + '%i18n:common.weekday-short.satruday%' + ] + }; + }, + computed: { + paddingDays(): number { + const date = new Date(this.year, this.month - 1, 1); + return date.getDay(); + }, + days(): number { + let days = eachMonthDays[this.month - 1]; + + // うるう年なら+1日 + if (this.month == 2 && isLeapYear(this.year)) days++; + + return days; + } + }, + methods: { + isToday(day) { + return this.year == this.today.getFullYear() && this.month == this.today.getMonth() + 1 && day == this.today.getDate(); + }, + + isSelected(day) { + return this.year == this.selected.getFullYear() && this.month == this.selected.getMonth() + 1 && day == this.selected.getDate(); + }, + + isOutOfRange(day) { + const test = (new Date(this.year, this.month - 1, day)).getTime(); + return test > this.today.getTime() || + (this.start ? test < (this.start as any).getTime() : false); + }, + + isDonichi(day) { + const weekday = (new Date(this.year, this.month - 1, day)).getDay(); + return weekday == 0 || weekday == 6; + }, + + prev() { + if (this.month == 1) { + this.year = this.year - 1; + this.month = 12; + } else { + this.month--; + } + }, + + next() { + if (this.month == 12) { + this.year = this.year + 1; + this.month = 1; + } else { + this.month++; + } + }, + + go(day) { + if (this.isOutOfRange(day)) return; + const date = new Date(this.year, this.month - 1, day, 23, 59, 59, 999); + this.selected = date; + this.$emit('chosen', this.selected); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-calendar + color #777 + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-melt] + background transparent !important + border none !important + + > .title + z-index 1 + margin 0 + padding 0 16px + text-align center + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + &:first-of-type + left 0 + + &:last-of-type + right 0 + + > .calendar + display flex + flex-wrap wrap + padding 16px + + * + user-select none + + > div + width calc(100% * (1/7)) + text-align center + line-height 32px + font-size 14px + + &.weekday + color #19a2a9 + + &[data-is-donichi] + color #ef95a0 + + &[data-today] + box-shadow 0 0 0 1px #19a2a9 inset + border-radius 6px + + &[data-is-donichi] + box-shadow 0 0 0 1px #ef95a0 inset + + &.day + cursor pointer + color #777 + + > div + border-radius 6px + + &:hover > div + background rgba(0, 0, 0, 0.025) + + &:active > div + background rgba(0, 0, 0, 0.05) + + &[data-is-donichi] + color #ef95a0 + + &[data-is-out-of-range] + cursor default + color rgba(#777, 0.5) + + &[data-is-donichi] + color rgba(#ef95a0, 0.5) + + &[data-selected] + font-weight bold + + > div + background rgba(0, 0, 0, 0.025) + + &:active > div + background rgba(0, 0, 0, 0.05) + + &[data-today] + > div + color $theme-color-foreground + background $theme-color + + &:hover > div + background lighten($theme-color, 10%) + + &:active > div + background darken($theme-color, 10%) + +</style> diff --git a/src/client/app/desktop/views/components/choose-file-from-drive-window.vue b/src/client/app/desktop/views/components/choose-file-from-drive-window.vue new file mode 100644 index 0000000000..9a1e9c958a --- /dev/null +++ b/src/client/app/desktop/views/components/choose-file-from-drive-window.vue @@ -0,0 +1,180 @@ +<template> +<mk-window ref="window" is-modal width="800px" height="500px" @closed="$destroy"> + <span slot="header"> + <span v-html="title" :class="$style.title"></span> + <span :class="$style.count" v-if="multiple && files.length > 0">({{ files.length }}ファイル選択中)</span> + </span> + + <mk-drive + ref="browser" + :class="$style.browser" + :multiple="multiple" + @selected="onSelected" + @change-selection="onChangeSelection" + /> + <div :class="$style.footer"> + <button :class="$style.upload" title="PCからドライブにファイルをアップロード" @click="upload">%fa:upload%</button> + <button :class="$style.cancel" @click="cancel">キャンセル</button> + <button :class="$style.ok" :disabled="multiple && files.length == 0" @click="ok">決定</button> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + multiple: { + default: false + }, + title: { + default: '%fa:R file%ファイルを選択' + } + }, + data() { + return { + files: [] + }; + }, + methods: { + onSelected(file) { + this.files = [file]; + this.ok(); + }, + onChangeSelection(files) { + this.files = files; + }, + upload() { + (this.$refs.browser as any).selectLocalFile(); + }, + ok() { + this.$emit('selected', this.multiple ? this.files : this.files[0]); + (this.$refs.window as any).close(); + }, + cancel() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +@import '~const.styl' + +.title + > [data-fa] + margin-right 4px + +.count + margin-left 8px + opacity 0.7 + +.browser + height calc(100% - 72px) + +.footer + height 72px + background lighten($theme-color, 95%) + +.upload + display inline-block + position absolute + top 8px + left 16px + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background transparent + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + +.ok +.cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + +.ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + +.cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + +</style> + diff --git a/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue b/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue new file mode 100644 index 0000000000..f99533176d --- /dev/null +++ b/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue @@ -0,0 +1,114 @@ +<template> +<mk-window ref="window" is-modal width="800px" height="500px" @closed="$destroy"> + <span slot="header"> + <span v-html="title" :class="$style.title"></span> + </span> + + <mk-drive + ref="browser" + :class="$style.browser" + :multiple="false" + /> + <div :class="$style.footer"> + <button :class="$style.cancel" @click="cancel">キャンセル</button> + <button :class="$style.ok" @click="ok">決定</button> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + title: { + default: '%fa:R folder%フォルダを選択' + } + }, + methods: { + ok() { + this.$emit('selected', (this.$refs.browser as any).folder); + (this.$refs.window as any).close(); + }, + cancel() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +@import '~const.styl' + +.title + > [data-fa] + margin-right 4px + +.browser + height calc(100% - 72px) + +.footer + height 72px + background lighten($theme-color, 95%) + +.ok +.cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + +.ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + +.cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + +</style> diff --git a/src/client/app/desktop/views/components/context-menu.menu.vue b/src/client/app/desktop/views/components/context-menu.menu.vue new file mode 100644 index 0000000000..6359dbf1b4 --- /dev/null +++ b/src/client/app/desktop/views/components/context-menu.menu.vue @@ -0,0 +1,121 @@ +<template> +<ul class="menu"> + <li v-for="(item, i) in menu" :class="item.type"> + <template v-if="item.type == 'item'"> + <p @click="click(item)"><span :class="$style.icon" v-if="item.icon" v-html="item.icon"></span>{{ item.text }}</p> + </template> + <template v-if="item.type == 'link'"> + <a :href="item.href" :target="item.target" @click="click(item)"><span :class="$style.icon" v-if="item.icon" v-html="item.icon"></span>{{ item.text }}</a> + </template> + <template v-else-if="item.type == 'nest'"> + <p><span :class="$style.icon" v-if="item.icon" v-html="item.icon"></span>{{ item.text }}...<span class="caret">%fa:caret-right%</span></p> + <me-nu :menu="item.menu" @x="click"/> + </template> + </li> +</ul> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + name: 'me-nu', + props: ['menu'], + methods: { + click(item) { + this.$emit('x', item); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.menu + $width = 240px + $item-height = 38px + $padding = 10px + + margin 0 + padding $padding 0 + list-style none + + li + display block + + &.divider + margin-top $padding + padding-top $padding + border-top solid 1px #eee + + &.nest + > p + cursor default + + > .caret + position absolute + top 0 + right 8px + + > * + line-height $item-height + width 28px + text-align center + + &:hover > ul + visibility visible + + &:active + > p, a + background $theme-color + + > p, a + display block + z-index 1 + margin 0 + padding 0 32px 0 38px + line-height $item-height + color #868C8C + text-decoration none + cursor pointer + + &:hover + text-decoration none + + * + pointer-events none + + &:hover + > p, a + text-decoration none + background $theme-color + color $theme-color-foreground + + &:active + > p, a + text-decoration none + background darken($theme-color, 10%) + color $theme-color-foreground + + li > ul + visibility hidden + position absolute + top 0 + left $width + margin-top -($padding) + width $width + background #fff + border-radius 0 4px 4px 4px + box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2) + transition visibility 0s linear 0.2s + +</style> + +<style lang="stylus" module> +.icon + > * + width 28px + margin-left -28px + text-align center +</style> + diff --git a/src/client/app/desktop/views/components/context-menu.vue b/src/client/app/desktop/views/components/context-menu.vue new file mode 100644 index 0000000000..8bd9945840 --- /dev/null +++ b/src/client/app/desktop/views/components/context-menu.vue @@ -0,0 +1,74 @@ +<template> +<div class="context-menu" :style="{ left: `${x}px`, top: `${y}px` }" @contextmenu.prevent="() => {}"> + <x-menu :menu="menu" @x="click"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; +import contains from '../../../common/scripts/contains'; +import XMenu from './context-menu.menu.vue'; + +export default Vue.extend({ + components: { + XMenu + }, + props: ['x', 'y', 'menu'], + mounted() { + this.$nextTick(() => { + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + + this.$el.style.display = 'block'; + + anime({ + targets: this.$el, + opacity: [0, 1], + duration: 100, + easing: 'linear' + }); + }); + }, + methods: { + onMousedown(e) { + e.preventDefault(); + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); + return false; + }, + click(item) { + if (item.onClick) item.onClick(); + this.close(); + }, + close() { + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + + this.$emit('closed'); + this.$destroy(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.context-menu + $width = 240px + $item-height = 38px + $padding = 10px + + display none + position fixed + top 0 + left 0 + z-index 4096 + width $width + font-size 0.8em + background #fff + border-radius 0 4px 4px 4px + box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2) + opacity 0 + +</style> diff --git a/src/client/app/desktop/views/components/crop-window.vue b/src/client/app/desktop/views/components/crop-window.vue new file mode 100644 index 0000000000..eb6a55d959 --- /dev/null +++ b/src/client/app/desktop/views/components/crop-window.vue @@ -0,0 +1,178 @@ +<template> + <mk-window ref="window" is-modal width="800px" :can-close="false"> + <span slot="header">%fa:crop%{{ title }}</span> + <div class="body"> + <vue-cropper ref="cropper" + :src="image.url" + :view-mode="1" + :aspect-ratio="aspectRatio" + :container-style="{ width: '100%', 'max-height': '400px' }" + /> + </div> + <div :class="$style.actions"> + <button :class="$style.skip" @click="skip">クロップをスキップ</button> + <button :class="$style.cancel" @click="cancel">キャンセル</button> + <button :class="$style.ok" @click="ok">決定</button> + </div> + </mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import VueCropper from 'vue-cropperjs'; + +export default Vue.extend({ + components: { + VueCropper + }, + props: { + image: { + type: Object, + required: true + }, + title: { + type: String, + required: true + }, + aspectRatio: { + type: Number, + required: true + } + }, + methods: { + ok() { + (this.$refs.cropper as any).getCroppedCanvas().toBlob(blob => { + this.$emit('cropped', blob); + (this.$refs.window as any).close(); + }); + }, + + skip() { + this.$emit('skipped'); + (this.$refs.window as any).close(); + }, + + cancel() { + this.$emit('canceled'); + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +@import '~const.styl' + +.header + > [data-fa] + margin-right 4px + +.img + width 100% + max-height 400px + +.actions + height 72px + background lighten($theme-color, 95%) + +.ok +.cancel +.skip + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + +.ok +.cancel + width 120px + +.ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + +.cancel +.skip + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + +.cancel + right 148px + +.skip + left 16px + width 150px + +</style> + +<style lang="stylus"> +.cropper-modal { + opacity: 0.8; +} + +.cropper-view-box { + outline-color: $theme-color; +} + +.cropper-line, .cropper-point { + background-color: $theme-color; +} + +.cropper-bg { + animation: cropper-bg 0.5s linear infinite; +} + +@keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } +} +</style> diff --git a/src/client/app/desktop/views/components/dialog.vue b/src/client/app/desktop/views/components/dialog.vue new file mode 100644 index 0000000000..fa17e4a9d2 --- /dev/null +++ b/src/client/app/desktop/views/components/dialog.vue @@ -0,0 +1,170 @@ +<template> +<div class="mk-dialog"> + <div class="bg" ref="bg" @click="onBgClick"></div> + <div class="main" ref="main"> + <header v-html="title" :class="$style.header"></header> + <div class="body" v-html="text"></div> + <div class="buttons"> + <button v-for="button in buttons" @click="click(button)">{{ button.text }}</button> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: { + title: { + type: String, + required: false + }, + text: { + type: String, + required: true + }, + buttons: { + type: Array, + default: () => { + return [{ + text: 'OK' + }]; + } + }, + modal: { + type: Boolean, + default: false + } + }, + mounted() { + this.$nextTick(() => { + (this.$refs.bg as any).style.pointerEvents = 'auto'; + anime({ + targets: this.$refs.bg, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.$refs.main, + opacity: 1, + scale: [1.2, 1], + duration: 300, + easing: [0, 0.5, 0.5, 1] + }); + }); + }, + methods: { + click(button) { + this.$emit('clicked', button.id); + this.close(); + }, + close() { + (this.$refs.bg as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.bg, + opacity: 0, + duration: 300, + easing: 'linear' + }); + + (this.$refs.main as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.main, + opacity: 0, + scale: 0.8, + duration: 300, + easing: [ 0.5, -0.5, 1, 0.5 ], + complete: () => this.$destroy() + }); + }, + onBgClick() { + if (!this.modal) { + this.close(); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-dialog + > .bg + display block + position fixed + z-index 8192 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + opacity 0 + pointer-events none + + > .main + display block + position fixed + z-index 8192 + top 20% + left 0 + right 0 + margin 0 auto 0 auto + padding 32px 42px + width 480px + background #fff + opacity 0 + + > .body + margin 1em 0 + color #888 + + > .buttons + > button + display inline-block + float right + margin 0 + padding 10px 10px + font-size 1.1em + font-weight normal + text-decoration none + color #888 + background transparent + outline none + border none + border-radius 0 + cursor pointer + transition color 0.1s ease + + i + margin 0 0.375em + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + +</style> + +<style lang="stylus" module> +@import '~const.styl' + +.header + margin 1em 0 + color $theme-color + // color #43A4EC + font-weight bold + + &:empty + display none + + > i + margin-right 0.5em + +</style> diff --git a/src/client/app/desktop/views/components/drive-window.vue b/src/client/app/desktop/views/components/drive-window.vue new file mode 100644 index 0000000000..3a072f4794 --- /dev/null +++ b/src/client/app/desktop/views/components/drive-window.vue @@ -0,0 +1,56 @@ +<template> +<mk-window ref="window" @closed="$destroy" width="800px" height="500px" :popout-url="popout"> + <template slot="header"> + <p v-if="usage" :class="$style.info"><b>{{ usage.toFixed(1) }}%</b> %i18n:desktop.tags.mk-drive-browser-window.used%</p> + <span :class="$style.title">%fa:cloud%%i18n:desktop.tags.mk-drive-browser-window.drive%</span> + </template> + <mk-drive :class="$style.browser" multiple :init-folder="folder" ref="browser"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url } from '../../../config'; + +export default Vue.extend({ + props: ['folder'], + data() { + return { + usage: null + }; + }, + mounted() { + (this as any).api('drive').then(info => { + this.usage = info.usage / info.capacity * 100; + }); + }, + methods: { + popout() { + const folder = (this.$refs.browser as any) ? (this.$refs.browser as any).folder : null; + if (folder) { + return `${url}/i/drive/folder/${folder.id}`; + } else { + return `${url}/i/drive`; + } + } + } +}); +</script> + +<style lang="stylus" module> +.title + > [data-fa] + margin-right 4px + +.info + position absolute + top 0 + left 16px + margin 0 + font-size 80% + +.browser + height 100% + +</style> + diff --git a/src/client/app/desktop/views/components/drive.file.vue b/src/client/app/desktop/views/components/drive.file.vue new file mode 100644 index 0000000000..85f8361c9f --- /dev/null +++ b/src/client/app/desktop/views/components/drive.file.vue @@ -0,0 +1,317 @@ +<template> +<div class="root file" + :data-is-selected="isSelected" + :data-is-contextmenu-showing="isContextmenuShowing" + @click="onClick" + draggable="true" + @dragstart="onDragstart" + @dragend="onDragend" + @contextmenu.prevent.stop="onContextmenu" + :title="title" +> + <div class="label" v-if="os.i.avatarId == file.id"><img src="/assets/label.svg"/> + <p>%i18n:desktop.tags.mk-drive-browser-file.avatar%</p> + </div> + <div class="label" v-if="os.i.bannerId == file.id"><img src="/assets/label.svg"/> + <p>%i18n:desktop.tags.mk-drive-browser-file.banner%</p> + </div> + <div class="thumbnail" ref="thumbnail" :style="`background-color: ${ background }`"> + <img :src="`${file.url}?thumbnail&size=128`" alt="" @load="onThumbnailLoaded"/> + </div> + <p class="name"> + <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> + <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; +import contextmenu from '../../api/contextmenu'; +import copyToClipboard from '../../../common/scripts/copy-to-clipboard'; + +export default Vue.extend({ + props: ['file'], + data() { + return { + isContextmenuShowing: false, + isDragging: false + }; + }, + computed: { + browser(): any { + return this.$parent; + }, + isSelected(): boolean { + return this.browser.selectedFiles.some(f => f.id == this.file.id); + }, + title(): string { + return `${this.file.name}\n${this.file.type} ${Vue.filter('bytes')(this.file.datasize)}`; + }, + background(): string { + return this.file.properties.avgColor + ? `rgb(${this.file.properties.avgColor.join(',')})` + : 'transparent'; + } + }, + methods: { + onClick() { + this.browser.chooseFile(this.file); + }, + + onContextmenu(e) { + this.isContextmenuShowing = true; + contextmenu(e, [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.rename%', + icon: '%fa:i-cursor%', + onClick: this.rename + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copy-url%', + icon: '%fa:link%', + onClick: this.copyUrl + }, { + type: 'link', + href: `${this.file.url}?download`, + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.download%', + icon: '%fa:download%', + }, { + type: 'divider', + }, { + type: 'item', + text: '%i18n:common.delete%', + icon: '%fa:R trash-alt%', + onClick: this.deleteFile + }, { + type: 'divider', + }, { + type: 'nest', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.else-files%', + menu: [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.set-as-avatar%', + onClick: this.setAsAvatar + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.set-as-banner%', + onClick: this.setAsBanner + }] + }, { + type: 'nest', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.open-in-app%', + menu: [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.add-app%...', + onClick: this.addApp + }] + }], { + closed: () => { + this.isContextmenuShowing = false; + } + }); + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('mk_drive_file', JSON.stringify(this.file)); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend(e) { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + onThumbnailLoaded() { + if (this.file.properties.avgColor) { + anime({ + targets: this.$refs.thumbnail, + backgroundColor: `rgba(${this.file.properties.avgColor.join(',')}, 0)`, + duration: 100, + easing: 'linear' + }); + } + }, + + rename() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.rename-file%', + placeholder: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.input-new-file-name%', + default: this.file.name, + allowEmpty: false + }).then(name => { + (this as any).api('drive/files/update', { + fileId: this.file.id, + name: name + }) + }); + }, + + copyUrl() { + copyToClipboard(this.file.url); + (this as any).apis.dialog({ + title: '%fa:check%%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copied%', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copied-url-to-clipboard%', + actions: [{ + text: '%i18n:common.ok%' + }] + }); + }, + + setAsAvatar() { + (this as any).apis.updateAvatar(this.file); + }, + + setAsBanner() { + (this as any).apis.updateBanner(this.file); + }, + + addApp() { + alert('not implemented yet'); + }, + + deleteFile() { + alert('not implemented yet'); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.root.file + padding 8px 0 0 0 + height 180px + border-radius 4px + + &, * + cursor pointer + + &:hover + background rgba(0, 0, 0, 0.05) + + > .label + &:before + &:after + background #0b65a5 + + &:active + background rgba(0, 0, 0, 0.1) + + > .label + &:before + &:after + background #0b588c + + &[data-is-selected] + background $theme-color + + &:hover + background lighten($theme-color, 10%) + + &:active + background darken($theme-color, 10%) + + > .label + &:before + &:after + display none + + > .name + color $theme-color-foreground + + &[data-is-contextmenu-showing] + &:after + content "" + pointer-events none + position absolute + top -4px + right -4px + bottom -4px + left -4px + border 2px dashed rgba($theme-color, 0.3) + border-radius 4px + + > .label + position absolute + top 0 + left 0 + pointer-events none + + &:before + content "" + display block + position absolute + z-index 1 + top 0 + left 57px + width 28px + height 8px + background #0c7ac9 + + &:after + content "" + display block + position absolute + z-index 1 + top 57px + left 0 + width 8px + height 28px + background #0c7ac9 + + > img + position absolute + z-index 2 + top 0 + left 0 + + > p + position absolute + z-index 3 + top 19px + left -28px + width 120px + margin 0 + text-align center + line-height 28px + color #fff + transform rotate(-45deg) + + > .thumbnail + width 128px + height 128px + margin auto + + > img + display block + position absolute + top 0 + left 0 + right 0 + bottom 0 + margin auto + max-width 128px + max-height 128px + pointer-events none + + > .name + display block + margin 4px 0 0 0 + font-size 0.8em + text-align center + word-break break-all + color #444 + overflow hidden + + > .ext + opacity 0.5 + +</style> diff --git a/src/client/app/desktop/views/components/drive.folder.vue b/src/client/app/desktop/views/components/drive.folder.vue new file mode 100644 index 0000000000..a926bf47b2 --- /dev/null +++ b/src/client/app/desktop/views/components/drive.folder.vue @@ -0,0 +1,267 @@ +<template> +<div class="root folder" + :data-is-contextmenu-showing="isContextmenuShowing" + :data-draghover="draghover" + @click="onClick" + @mouseover="onMouseover" + @mouseout="onMouseout" + @dragover.prevent.stop="onDragover" + @dragenter.prevent="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + draggable="true" + @dragstart="onDragstart" + @dragend="onDragend" + @contextmenu.prevent.stop="onContextmenu" + :title="title" +> + <p class="name"> + <template v-if="hover">%fa:R folder-open .fw%</template> + <template v-if="!hover">%fa:R folder .fw%</template> + {{ folder.name }} + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import contextmenu from '../../api/contextmenu'; + +export default Vue.extend({ + props: ['folder'], + data() { + return { + hover: false, + draghover: false, + isDragging: false, + isContextmenuShowing: false + }; + }, + computed: { + browser(): any { + return this.$parent; + }, + title(): string { + return this.folder.name; + } + }, + methods: { + onClick() { + this.browser.move(this.folder); + }, + + onContextmenu(e) { + this.isContextmenuShowing = true; + contextmenu(e, [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.move-to-this-folder%', + icon: '%fa:arrow-right%', + onClick: this.go + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.show-in-new-window%', + icon: '%fa:R window-restore%', + onClick: this.newWindow + }, { + type: 'divider', + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.rename%', + icon: '%fa:i-cursor%', + onClick: this.rename + }, { + type: 'divider', + }, { + type: 'item', + text: '%i18n:common.delete%', + icon: '%fa:R trash-alt%', + onClick: this.deleteFolder + }], { + closed: () => { + this.isContextmenuShowing = false; + } + }); + }, + + onMouseover() { + this.hover = true; + }, + + onMouseout() { + this.hover = false + }, + + onDragover(e) { + // 自分自身がドラッグされている場合 + if (this.isDragging) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + }, + + onDragenter() { + if (!this.isDragging) this.draghover = true; + }, + + onDragleave() { + this.draghover = false; + }, + + onDrop(e) { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + Array.from(e.dataTransfer.files).forEach(file => { + this.browser.upload(file, this.folder); + }); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.browser.removeFile(file.id); + (this as any).api('drive/files/update', { + fileId: file.id, + folderId: this.folder.id + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (folder.id == this.folder.id) return; + + this.browser.removeFolder(folder.id); + (this as any).api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder.id + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + (this as any).apis.dialog({ + title: '%fa:exclamation-triangle%%i18n:desktop.tags.mk-drive-browser-folder.unable-to-process%', + text: '%i18n:desktop.tags.mk-drive-browser-folder.circular-reference-detected%', + actions: [{ + text: '%i18n:common.ok%' + }] + }); + break; + default: + alert('%i18n:desktop.tags.mk-drive-browser-folder.unhandled-error% ' + err); + } + }); + } + //#endregion + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('mk_drive_folder', JSON.stringify(this.folder)); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend() { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + go() { + this.browser.move(this.folder.id); + }, + + newWindow() { + this.browser.newWindow(this.folder); + }, + + rename() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.rename-folder%', + placeholder: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.input-new-folder-name%', + default: this.folder.name + }).then(name => { + (this as any).api('drive/folders/update', { + folderId: this.folder.id, + name: name + }); + }); + }, + + deleteFolder() { + alert('not implemented yet'); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.root.folder + padding 8px + height 64px + background lighten($theme-color, 95%) + border-radius 4px + + &, * + cursor pointer + + * + pointer-events none + + &:hover + background lighten($theme-color, 90%) + + &:active + background lighten($theme-color, 85%) + + &[data-is-contextmenu-showing] + &[data-draghover] + &:after + content "" + pointer-events none + position absolute + top -4px + right -4px + bottom -4px + left -4px + border 2px dashed rgba($theme-color, 0.3) + border-radius 4px + + &[data-draghover] + background lighten($theme-color, 90%) + + > .name + margin 0 + font-size 0.9em + color darken($theme-color, 30%) + + > [data-fa] + margin-right 4px + margin-left 2px + text-align left + +</style> diff --git a/src/client/app/desktop/views/components/drive.nav-folder.vue b/src/client/app/desktop/views/components/drive.nav-folder.vue new file mode 100644 index 0000000000..d885a72f7f --- /dev/null +++ b/src/client/app/desktop/views/components/drive.nav-folder.vue @@ -0,0 +1,113 @@ +<template> +<div class="root nav-folder" + :data-draghover="draghover" + @click="onClick" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <template v-if="folder == null">%fa:cloud%</template> + <span>{{ folder == null ? '%i18n:desktop.tags.mk-drive-browser-nav-folder.drive%' : folder.name }}</span> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['folder'], + data() { + return { + hover: false, + draghover: false + }; + }, + computed: { + browser(): any { + return this.$parent; + } + }, + methods: { + onClick() { + this.browser.move(this.folder); + }, + onMouseover() { + this.hover = true; + }, + onMouseout() { + this.hover = false; + }, + onDragover(e) { + // このフォルダがルートかつカレントディレクトリならドロップ禁止 + if (this.folder == null && this.browser.folder == null) { + e.dataTransfer.dropEffect = 'none'; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + + return false; + }, + onDragenter() { + if (this.folder || this.browser.folder) this.draghover = true; + }, + onDragleave() { + if (this.folder || this.browser.folder) this.draghover = false; + }, + onDrop(e) { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + Array.from(e.dataTransfer.files).forEach(file => { + this.browser.upload(file, this.folder); + }); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.browser.removeFile(file.id); + (this as any).api('drive/files/update', { + fileId: file.id, + folderId: this.folder ? this.folder.id : null + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + // 移動先が自分自身ならreject + if (this.folder && folder.id == this.folder.id) return; + this.browser.removeFolder(folder.id); + (this as any).api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder ? this.folder.id : null + }); + } + //#endregion + } + } +}); +</script> + +<style lang="stylus" scoped> +.root.nav-folder + > * + pointer-events none + + &[data-draghover] + background #eee + +</style> diff --git a/src/client/app/desktop/views/components/drive.vue b/src/client/app/desktop/views/components/drive.vue new file mode 100644 index 0000000000..c766dfec12 --- /dev/null +++ b/src/client/app/desktop/views/components/drive.vue @@ -0,0 +1,773 @@ +<template> +<div class="mk-drive"> + <nav> + <div class="path" @contextmenu.prevent.stop="() => {}"> + <x-nav-folder :class="{ current: folder == null }"/> + <template v-for="folder in hierarchyFolders"> + <span class="separator">%fa:angle-right%</span> + <x-nav-folder :folder="folder" :key="folder.id"/> + </template> + <span class="separator" v-if="folder != null">%fa:angle-right%</span> + <span class="folder current" v-if="folder != null">{{ folder.name }}</span> + </div> + <input class="search" type="search" placeholder=" %i18n:desktop.tags.mk-drive-browser.search%"/> + </nav> + <div class="main" :class="{ uploading: uploadings.length > 0, fetching }" + ref="main" + @mousedown="onMousedown" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + @contextmenu.prevent.stop="onContextmenu" + > + <div class="selection" ref="selection"></div> + <div class="contents" ref="contents"> + <div class="folders" ref="foldersContainer" v-if="folders.length > 0"> + <x-folder v-for="folder in folders" :key="folder.id" class="folder" :folder="folder"/> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" v-for="n in 16"></div> + <button v-if="moreFolders">%i18n:desktop.tags.mk-drive-browser.load-more%</button> + </div> + <div class="files" ref="filesContainer" v-if="files.length > 0"> + <x-file v-for="file in files" :key="file.id" class="file" :file="file"/> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" v-for="n in 16"></div> + <button v-if="moreFiles" @click="fetchMoreFiles">%i18n:desktop.tags.mk-drive-browser.load-more%</button> + </div> + <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching"> + <p v-if="draghover">%i18n:desktop.tags.mk-drive-browser.empty-draghover%</p> + <p v-if="!draghover && folder == null"><strong>%i18n:desktop.tags.mk-drive-browser.empty-drive%</strong><br/>%i18n:desktop.tags.mk-drive-browser.empty-drive-description%</p> + <p v-if="!draghover && folder != null">%i18n:desktop.tags.mk-drive-browser.empty-folder%</p> + </div> + </div> + <div class="fetching" v-if="fetching"> + <div class="spinner"> + <div class="dot1"></div> + <div class="dot2"></div> + </div> + </div> + </div> + <div class="dropzone" v-if="draghover"></div> + <mk-uploader ref="uploader" @change="onChangeUploaderUploads" @uploaded="onUploaderUploaded"/> + <input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkDriveWindow from './drive-window.vue'; +import XNavFolder from './drive.nav-folder.vue'; +import XFolder from './drive.folder.vue'; +import XFile from './drive.file.vue'; +import contains from '../../../common/scripts/contains'; +import contextmenu from '../../api/contextmenu'; +import { url } from '../../../config'; + +export default Vue.extend({ + components: { + XNavFolder, + XFolder, + XFile + }, + props: { + initFolder: { + type: Object, + required: false + }, + multiple: { + type: Boolean, + default: false + } + }, + data() { + return { + /** + * 現在の階層(フォルダ) + * * null でルートを表す + */ + folder: null, + + files: [], + folders: [], + moreFiles: false, + moreFolders: false, + hierarchyFolders: [], + selectedFiles: [], + uploadings: [], + connection: null, + connectionId: null, + + /** + * ドロップされようとしているか + */ + draghover: false, + + /** + * 自信の所有するアイテムがドラッグをスタートさせたか + * (自分自身の階層にドロップできないようにするためのフラグ) + */ + isDragSource: false, + + fetching: true + }; + }, + 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.move(this.initFolder); + } else { + this.fetch(); + } + }, + 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: { + onContextmenu(e) { + contextmenu(e, [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-base-contextmenu.create-folder%', + icon: '%fa:R folder%', + onClick: this.createFolder + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-base-contextmenu.upload%', + icon: '%fa:upload%', + onClick: this.selectLocalFile + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-base-contextmenu.url-upload%', + icon: '%fa:cloud-upload-alt%', + onClick: this.urlUpload + }]); + }, + + 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); + } + }, + + onChangeUploaderUploads(uploads) { + this.uploadings = uploads; + }, + + onUploaderUploaded(file) { + this.addFile(file, true); + }, + + onMousedown(e): any { + if (contains(this.$refs.foldersContainer, e.target) || contains(this.$refs.filesContainer, e.target)) return true; + + const main = this.$refs.main as any; + const selection = this.$refs.selection as any; + + const rect = main.getBoundingClientRect(); + + const left = e.pageX + main.scrollLeft - rect.left - window.pageXOffset + const top = e.pageY + main.scrollTop - rect.top - window.pageYOffset + + const move = e => { + selection.style.display = 'block'; + + const cursorX = e.pageX + main.scrollLeft - rect.left - window.pageXOffset; + const cursorY = e.pageY + main.scrollTop - rect.top - window.pageYOffset; + const w = cursorX - left; + const h = cursorY - top; + + if (w > 0) { + selection.style.width = w + 'px'; + selection.style.left = left + 'px'; + } else { + selection.style.width = -w + 'px'; + selection.style.left = cursorX + 'px'; + } + + if (h > 0) { + selection.style.height = h + 'px'; + selection.style.top = top + 'px'; + } else { + selection.style.height = -h + 'px'; + selection.style.top = cursorY + 'px'; + } + }; + + const up = e => { + document.documentElement.removeEventListener('mousemove', move); + document.documentElement.removeEventListener('mouseup', up); + + selection.style.display = 'none'; + }; + + document.documentElement.addEventListener('mousemove', move); + document.documentElement.addEventListener('mouseup', up); + }, + + onDragover(e): any { + // ドラッグ元が自分自身の所有するアイテムだったら + if (this.isDragSource) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + + return false; + }, + + onDragenter(e) { + if (!this.isDragSource) this.draghover = true; + }, + + onDragleave(e) { + this.draghover = false; + }, + + onDrop(e): any { + this.draghover = false; + + // ドロップされてきたものがファイルだったら + if (e.dataTransfer.files.length > 0) { + Array.from(e.dataTransfer.files).forEach(file => { + this.upload(file, this.folder); + }); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + if (this.files.some(f => f.id == file.id)) return; + this.removeFile(file.id); + (this as any).api('drive/files/update', { + fileId: file.id, + folderId: this.folder ? this.folder.id : null + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (this.folder && folder.id == this.folder.id) return false; + if (this.folders.some(f => f.id == folder.id)) return false; + this.removeFolder(folder.id); + (this as any).api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder ? this.folder.id : null + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + (this as any).apis.dialog({ + title: '%fa:exclamation-triangle%%i18n:desktop.tags.mk-drive-browser.unable-to-process%', + text: '%i18n:desktop.tags.mk-drive-browser.circular-reference-detected%', + actions: [{ + text: '%i18n:common.ok%' + }] + }); + break; + default: + alert('%i18n:desktop.tags.mk-drive-browser.unhandled-error% ' + err); + } + }); + } + //#endregion + }, + + selectLocalFile() { + (this.$refs.fileInput as any).click(); + }, + + urlUpload() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-drive-browser.url-upload%', + placeholder: '%i18n:desktop.tags.mk-drive-browser.url-of-file%' + }).then(url => { + (this as any).api('drive/files/upload_from_url', { + url: url, + folderId: this.folder ? this.folder.id : undefined + }); + + (this as any).apis.dialog({ + title: '%fa:check%%i18n:desktop.tags.mk-drive-browser.url-upload-requested%', + text: '%i18n:desktop.tags.mk-drive-browser.may-take-time%', + actions: [{ + text: '%i18n:common.ok%' + }] + }); + }); + }, + + createFolder() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-drive-browser.create-folder%', + placeholder: '%i18n:desktop.tags.mk-drive-browser.folder-name%' + }).then(name => { + (this as any).api('drive/folders/create', { + name: name, + folderId: this.folder ? this.folder.id : undefined + }).then(folder => { + this.addFolder(folder, true); + }); + }); + }, + + onChangeFileInput() { + Array.from((this.$refs.fileInput as any).files).forEach(file => { + this.upload(file, this.folder); + }); + }, + + upload(file, folder) { + if (folder && typeof folder == 'object') folder = folder.id; + (this.$refs.uploader as any).upload(file, folder); + }, + + chooseFile(file) { + const isAlreadySelected = this.selectedFiles.some(f => f.id == file.id); + if (this.multiple) { + if (isAlreadySelected) { + this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id); + } else { + this.selectedFiles.push(file); + } + this.$emit('change-selection', this.selectedFiles); + } else { + if (isAlreadySelected) { + this.$emit('selected', file); + } else { + this.selectedFiles = [file]; + this.$emit('change-selection', [file]); + } + } + }, + + newWindow(folder) { + if (document.body.clientWidth > 800) { + (this as any).os.new(MkDriveWindow, { + folder: folder + }); + } else { + window.open(url + '/i/drive/folder/' + folder.id, + 'drive_window', + 'height=500, width=800'); + } + }, + + move(target) { + if (target == null) { + this.goRoot(); + 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 = []; + + const dive = folder => { + this.hierarchyFolders.unshift(folder); + if (folder.parent) dive(folder.parent); + }; + + if (folder.parent) dive(folder.parent); + + this.$emit('open-folder', folder); + this.fetch(); + }); + }, + + addFolder(folder, unshift = false) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parentId) return; + + if (this.folders.some(f => f.id == folder.id)) { + const exist = this.folders.map(f => f.id).indexOf(folder.id); + Vue.set(this.folders, exist, folder); + return; + } + + if (unshift) { + this.folders.unshift(folder); + } else { + this.folders.push(folder); + } + }, + + addFile(file, unshift = false) { + const current = this.folder ? this.folder.id : null; + if (current != file.folderId) return; + + if (this.files.some(f => f.id == file.id)) { + const exist = this.files.map(f => f.id).indexOf(file.id); + Vue.set(this.files, exist, file); + return; + } + + if (unshift) { + this.files.unshift(file); + } else { + this.files.push(file); + } + }, + + removeFolder(folder) { + if (typeof folder == 'object') folder = folder.id; + this.folders = this.folders.filter(f => f.id != folder); + }, + + removeFile(file) { + if (typeof file == 'object') file = file.id; + this.files = this.files.filter(f => f.id != file); + }, + + appendFile(file) { + this.addFile(file); + }, + + appendFolder(folder) { + this.addFolder(folder); + }, + + prependFile(file) { + this.addFile(file, true); + }, + + prependFolder(folder) { + this.addFolder(folder, true); + }, + + goRoot() { + // 既にrootにいるなら何もしない + if (this.folder == null) return; + + this.folder = null; + this.hierarchyFolders = []; + this.$emit('move-root'); + this.fetch(); + }, + + fetch() { + this.folders = []; + this.files = []; + this.moreFolders = false; + this.moreFiles = false; + this.fetching = true; + + let fetchedFolders = null; + let fetchedFiles = null; + + const foldersMax = 30; + const filesMax = 30; + + // フォルダ一覧取得 + (this 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; + } else { + flag = true; + } + }; + }, + + fetchMoreFiles() { + this.fetching = true; + + const max = 30; + + // ファイル一覧取得 + (this as any).api('drive/files', { + folderId: this.folder ? this.folder.id : null, + limit: max + 1 + }).then(files => { + if (files.length == max + 1) { + this.moreFiles = true; + files.pop(); + } else { + this.moreFiles = false; + } + files.forEach(this.appendFile); + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-drive + + > nav + display block + z-index 2 + width 100% + overflow auto + font-size 0.9em + color #555 + background #fff + //border-bottom 1px solid #dfdfdf + box-shadow 0 1px 0 rgba(0, 0, 0, 0.05) + + &, * + user-select none + + > .path + display inline-block + vertical-align bottom + margin 0 + padding 0 8px + width calc(100% - 200px) + line-height 38px + white-space nowrap + + > * + display inline-block + margin 0 + padding 0 8px + line-height 38px + cursor pointer + + i + margin-right 4px + + * + pointer-events none + + &:hover + text-decoration underline + + &.current + font-weight bold + cursor default + + &:hover + text-decoration none + + &.separator + margin 0 + padding 0 + opacity 0.5 + cursor default + + > [data-fa] + margin 0 + + > .search + display inline-block + vertical-align bottom + user-select text + cursor auto + margin 0 + padding 0 18px + width 200px + font-size 1em + line-height 38px + background transparent + outline none + //border solid 1px #ddd + border none + border-radius 0 + box-shadow none + transition color 0.5s ease, border 0.5s ease + font-family FontAwesome, sans-serif + + &[data-active='true'] + background #fff + + &::-webkit-input-placeholder, + &:-ms-input-placeholder, + &:-moz-placeholder + color $ui-control-foreground-color + + > .main + padding 8px + height calc(100% - 38px) + overflow auto + + &, * + user-select none + + &.fetching + cursor wait !important + + * + pointer-events none + + > .contents + opacity 0.5 + + &.uploading + height calc(100% - 38px - 100px) + + > .selection + display none + position absolute + z-index 128 + top 0 + left 0 + border solid 1px $theme-color + background rgba($theme-color, 0.5) + pointer-events none + + > .contents + + > .folders + > .files + display flex + flex-wrap wrap + + > .folder + > .file + flex-grow 1 + width 144px + margin 4px + + > .padding + flex-grow 1 + pointer-events none + width 144px + 8px // 8px is margin + + > .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-color rgba(0, 0, 0, 0.3) + 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); + } + } + + > .dropzone + position absolute + left 0 + top 38px + width 100% + height calc(100% - 38px) + border dashed 2px rgba($theme-color, 0.5) + pointer-events none + + > .mk-uploader + height 100px + padding 16px + background #fff + + > input + display none + +</style> diff --git a/src/client/app/desktop/views/components/ellipsis-icon.vue b/src/client/app/desktop/views/components/ellipsis-icon.vue new file mode 100644 index 0000000000..c54a7db29d --- /dev/null +++ b/src/client/app/desktop/views/components/ellipsis-icon.vue @@ -0,0 +1,37 @@ +<template> +<div class="mk-ellipsis-icon"> + <div></div><div></div><div></div> +</div> +</template> + +<style lang="stylus" scoped> +.mk-ellipsis-icon + width 70px + margin 0 auto + text-align center + + > div + display inline-block + width 18px + height 18px + background-color rgba(0, 0, 0, 0.3) + border-radius 100% + animation bounce 1.4s infinite ease-in-out both + + &:nth-child(1) + animation-delay 0s + + &:nth-child(2) + margin 0 6px + animation-delay 0.16s + + &:nth-child(3) + animation-delay 0.32s + + @keyframes bounce + 0%, 80%, 100% + transform scale(0) + 40% + transform scale(1) + +</style> diff --git a/src/client/app/desktop/views/components/follow-button.vue b/src/client/app/desktop/views/components/follow-button.vue new file mode 100644 index 0000000000..9eb22b0fb8 --- /dev/null +++ b/src/client/app/desktop/views/components/follow-button.vue @@ -0,0 +1,164 @@ +<template> +<button class="mk-follow-button" + :class="{ wait, follow: !user.isFollowing, unfollow: user.isFollowing, big: size == 'big' }" + @click="onClick" + :disabled="wait" + :title="user.isFollowing ? 'フォロー解除' : 'フォローする'" +> + <template v-if="!wait && user.isFollowing"> + <template v-if="size == 'compact'">%fa:minus%</template> + <template v-if="size == 'big'">%fa:minus%フォロー解除</template> + </template> + <template v-if="!wait && !user.isFollowing"> + <template v-if="size == 'compact'">%fa:plus%</template> + <template v-if="size == 'big'">%fa:plus%フォロー</template> + </template> + <template v-if="wait">%fa:spinner .pulse .fw%</template> +</button> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + user: { + type: Object, + required: true + }, + size: { + type: String, + default: 'compact' + } + }, + 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 + cursor pointer + padding 0 + margin 0 + width 32px + height 32px + font-size 1em + outline none + border-radius 4px + + * + pointer-events none + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &.follow + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.unfollow + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + &.wait + cursor wait !important + opacity 0.7 + + &.big + width 100% + height 38px + line-height 38px + + i + margin-right 8px + +</style> diff --git a/src/client/app/desktop/views/components/followers-window.vue b/src/client/app/desktop/views/components/followers-window.vue new file mode 100644 index 0000000000..623971fa33 --- /dev/null +++ b/src/client/app/desktop/views/components/followers-window.vue @@ -0,0 +1,26 @@ +<template> +<mk-window width="400px" height="550px" @closed="$destroy"> + <span slot="header" :class="$style.header"> + <img :src="`${user.avatarUrl}?thumbnail&size=64`" alt=""/>{{ user.name }}のフォロワー + </span> + <mk-followers :user="user"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'] +}); +</script> + +<style lang="stylus" module> +.header + > img + display inline-block + vertical-align bottom + height calc(100% - 10px) + margin 5px + border-radius 4px + +</style> diff --git a/src/client/app/desktop/views/components/followers.vue b/src/client/app/desktop/views/components/followers.vue new file mode 100644 index 0000000000..a1b98995d8 --- /dev/null +++ b/src/client/app/desktop/views/components/followers.vue @@ -0,0 +1,26 @@ +<template> +<mk-users-list + :fetch="fetch" + :count="user.followersCount" + :you-know-count="user.followersYouKnowCount" +> + フォロワーはいないようです。 +</mk-users-list> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + methods: { + fetch(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/desktop/views/components/following-window.vue b/src/client/app/desktop/views/components/following-window.vue new file mode 100644 index 0000000000..612847b386 --- /dev/null +++ b/src/client/app/desktop/views/components/following-window.vue @@ -0,0 +1,26 @@ +<template> +<mk-window width="400px" height="550px" @closed="$destroy"> + <span slot="header" :class="$style.header"> + <img :src="`${user.avatarUrl}?thumbnail&size=64`" alt=""/>{{ user.name }}のフォロー + </span> + <mk-following :user="user"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'] +}); +</script> + +<style lang="stylus" module> +.header + > img + display inline-block + vertical-align bottom + height calc(100% - 10px) + margin 5px + border-radius 4px + +</style> diff --git a/src/client/app/desktop/views/components/following.vue b/src/client/app/desktop/views/components/following.vue new file mode 100644 index 0000000000..b7aedda84f --- /dev/null +++ b/src/client/app/desktop/views/components/following.vue @@ -0,0 +1,26 @@ +<template> +<mk-users-list + :fetch="fetch" + :count="user.followingCount" + :you-know-count="user.followingYouKnowCount" +> + フォロー中のユーザーはいないようです。 +</mk-users-list> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + methods: { + fetch(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/desktop/views/components/friends-maker.vue b/src/client/app/desktop/views/components/friends-maker.vue new file mode 100644 index 0000000000..fd9914b152 --- /dev/null +++ b/src/client/app/desktop/views/components/friends-maker.vue @@ -0,0 +1,171 @@ +<template> +<div class="mk-friends-maker"> + <p class="title">気になるユーザーをフォロー:</p> + <div class="users" v-if="!fetching && users.length > 0"> + <div class="user" v-for="user in users" :key="user.id"> + <router-link class="avatar-anchor" :to="`/@${getAcct(user)}`"> + <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="user.id"/> + </router-link> + <div class="body"> + <router-link class="name" :to="`/@${getAcct(user)}`" v-user-preview="user.id">{{ user.name }}</router-link> + <p class="username">@{{ getAcct(user) }}</p> + </div> + <mk-follow-button :user="user"/> + </div> + </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="$destroy()" title="閉じる">%fa:times%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + data() { + return { + users: [], + fetching: true, + limit: 6, + page: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + getAcct, + 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(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-friends-maker + padding 24px + + > .title + margin 0 0 12px 0 + font-size 1em + font-weight bold + color #888 + + > .users + &:after + content "" + display block + clear both + + > .user + padding 16px + width 238px + float left + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 42px + height 42px + margin 0 + border-radius 8px + vertical-align bottom + + > .body + float left + width calc(100% - 54px) + + > .name + margin 0 + font-size 16px + line-height 24px + color #555 + + > .username + margin 0 + font-size 15px + line-height 16px + color #ccc + + > .mk-follow-button + position absolute + top 16px + right 16px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + + > .refresh + display block + margin 0 8px 0 0 + text-align right + font-size 0.9em + color #999 + + > .close + cursor pointer + display block + position absolute + top 6px + right 6px + 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 14px + +</style> diff --git a/src/client/app/desktop/views/components/game-window.vue b/src/client/app/desktop/views/components/game-window.vue new file mode 100644 index 0000000000..3c8bf40e12 --- /dev/null +++ b/src/client/app/desktop/views/components/game-window.vue @@ -0,0 +1,37 @@ +<template> +<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:gamepad%オセロ</span> + <mk-othello :class="$style.content" @gamed="g => game = g"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url } from '../../../config'; + +export default Vue.extend({ + data() { + return { + game: null + }; + }, + computed: { + popout(): string { + return this.game + ? `${url}/othello/${this.game.id}` + : `${url}/othello`; + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +.content + height 100% + overflow auto + +</style> diff --git a/src/client/app/desktop/views/components/home.vue b/src/client/app/desktop/views/components/home.vue new file mode 100644 index 0000000000..7145ddce03 --- /dev/null +++ b/src/client/app/desktop/views/components/home.vue @@ -0,0 +1,357 @@ +<template> +<div class="mk-home" :data-customize="customize"> + <div class="customize" v-if="customize"> + <router-link to="/">%fa:check%完了</router-link> + <div> + <div class="adder"> + <p>ウィジェットを追加:</p> + <select v-model="widgetAdderSelected"> + <option value="profile">プロフィール</option> + <option value="calendar">カレンダー</option> + <option value="timemachine">カレンダー(タイムマシン)</option> + <option value="activity">アクティビティ</option> + <option value="rss">RSSリーダー</option> + <option value="trends">トレンド</option> + <option value="photo-stream">フォトストリーム</option> + <option value="slideshow">スライドショー</option> + <option value="version">バージョン</option> + <option value="broadcast">ブロードキャスト</option> + <option value="notifications">通知</option> + <option value="users">おすすめユーザー</option> + <option value="polls">投票</option> + <option value="post-form">投稿フォーム</option> + <option value="messaging">メッセージ</option> + <option value="channel">チャンネル</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> + </div> + <div class="trash"> + <x-draggable v-model="trash" :options="{ group: 'x' }" @add="onTrash"></x-draggable> + <p>ゴミ箱</p> + </div> + </div> + </div> + <div class="main"> + <template v-if="customize"> + <x-draggable v-for="place in ['left', 'right']" + :list="widgets[place]" + :class="place" + :data-place="place" + :options="{ group: 'x', animation: 150 }" + @sort="onWidgetSort" + :key="place" + > + <div v-for="widget in widgets[place]" class="customize-container" :key="widget.id" @contextmenu.stop.prevent="onWidgetContextmenu(widget.id)"> + <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true"/> + </div> + </x-draggable> + <div class="main"> + <a @click="hint">カスタマイズのヒント</a> + <div> + <mk-post-form v-if="os.i.account.clientSettings.showPostFormOnTopOfTl"/> + <mk-timeline ref="tl" @loaded="onTlLoaded"/> + </div> + </div> + </template> + <template v-else> + <div v-for="place in ['left', 'right']" :class="place"> + <component v-for="widget in widgets[place]" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" @chosen="warp"/> + </div> + <div class="main"> + <mk-post-form v-if="os.i.account.clientSettings.showPostFormOnTopOfTl"/> + <mk-timeline ref="tl" @loaded="onTlLoaded" v-if="mode == 'timeline'"/> + <mk-mentions @loaded="onTlLoaded" v-if="mode == 'mentions'"/> + </div> + </template> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as XDraggable from 'vuedraggable'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + components: { + XDraggable + }, + props: { + customize: Boolean, + mode: { + type: String, + default: 'timeline' + } + }, + data() { + return { + connection: null, + connectionId: null, + widgetAdderSelected: null, + trash: [], + widgets: { + left: [], + right: [] + } + }; + }, + computed: { + home: { + get(): any[] { + //#region 互換性のため + (this as any).os.i.account.clientSettings.home.forEach(w => { + if (w.name == 'rss-reader') w.name = 'rss'; + if (w.name == 'user-recommendation') w.name = 'users'; + if (w.name == 'recommended-polls') w.name = 'polls'; + }); + //#endregion + return (this as any).os.i.account.clientSettings.home; + }, + set(value) { + (this as any).os.i.account.clientSettings.home = value; + } + }, + left(): any[] { + return this.home.filter(w => w.place == 'left'); + }, + right(): any[] { + return this.home.filter(w => w.place == 'right'); + } + }, + created() { + this.widgets.left = this.left; + this.widgets.right = this.right; + this.$watch('os.i.account.clientSettings', i => { + this.widgets.left = this.left; + this.widgets.right = this.right; + }, { + deep: true + }); + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('home_updated', this.onHomeUpdated); + }, + beforeDestroy() { + this.connection.off('home_updated', this.onHomeUpdated); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + hint() { + (this as any).apis.dialog({ + title: '%fa:info-circle%カスタマイズのヒント', + text: '<p>ホームのカスタマイズでは、ウィジェットを追加/削除したり、ドラッグ&ドロップして並べ替えたりすることができます。</p>' + + '<p>一部のウィジェットは、<strong><strong>右</strong>クリック</strong>することで表示を変更することができます。</p>' + + '<p>ウィジェットを削除するには、ヘッダーの<strong>「ゴミ箱」</strong>と書かれたエリアにウィジェットをドラッグ&ドロップします。</p>' + + '<p>カスタマイズを終了するには、右上の「完了」をクリックします。</p>', + actions: [{ + text: 'Got it!' + }] + }); + }, + onTlLoaded() { + this.$emit('loaded'); + }, + onHomeUpdated(data) { + if (data.home) { + (this as any).os.i.account.clientSettings.home = data.home; + this.widgets.left = data.home.filter(w => w.place == 'left'); + this.widgets.right = data.home.filter(w => w.place == 'right'); + } else { + const w = (this as any).os.i.account.clientSettings.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.left = (this as any).os.i.account.clientSettings.home.filter(w => w.place == 'left'); + this.widgets.right = (this as any).os.i.account.clientSettings.home.filter(w => w.place == 'right'); + } + } + }, + onWidgetContextmenu(widgetId) { + const w = (this.$refs[widgetId] as any)[0]; + if (w.func) w.func(); + }, + onWidgetSort() { + this.saveHome(); + }, + onTrash(evt) { + this.saveHome(); + }, + addWidget() { + const widget = { + name: this.widgetAdderSelected, + id: uuid(), + place: 'left', + data: {} + }; + + this.widgets.left.unshift(widget); + this.saveHome(); + }, + saveHome() { + const left = this.widgets.left; + const right = this.widgets.right; + this.home = left.concat(right); + left.forEach(w => w.place = 'left'); + right.forEach(w => w.place = 'right'); + (this as any).api('i/update_home', { + home: this.home + }); + }, + warp(date) { + (this.$refs.tl as any).warp(date); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-home + display block + + &[data-customize] + padding-top 48px + background-image url('/assets/desktop/grid.svg') + + > .main > .main + > a + display block + margin-bottom 8px + text-align center + + > div + cursor not-allowed !important + + > * + pointer-events none + + &:not([data-customize]) + > .main > *:empty + display none + + > .customize + position fixed + z-index 1000 + top 0 + left 0 + width 100% + height 48px + background #f7f7f7 + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + > a + display block + position absolute + z-index 1001 + top 0 + right 0 + padding 0 16px + line-height 48px + text-decoration none + color $theme-color-foreground + background $theme-color + transition background 0.1s ease + + &:hover + background lighten($theme-color, 10%) + + &:active + background darken($theme-color, 10%) + transition background 0s ease + + > [data-fa] + margin-right 8px + + > div + display flex + margin 0 auto + max-width 1200px - 32px + + > div + width 50% + + &.adder + > p + display inline + line-height 48px + + &.trash + border-left solid 1px #ddd + + > div + width 100% + height 100% + + > p + position absolute + top 0 + left 0 + width 100% + line-height 48px + margin 0 + text-align center + pointer-events none + + > .main + display flex + justify-content center + margin 0 auto + max-width 1200px + + > * + .customize-container + cursor move + border-radius 6px + + &:hover + box-shadow 0 0 8px rgba(64, 120, 200, 0.3) + + > * + pointer-events none + + > .main + padding 16px + width calc(100% - 275px * 2) + order 2 + + .mk-post-form + margin-bottom 16px + border solid 1px #e5e5e5 + border-radius 4px + + > *:not(.main) + width 275px + padding 16px 0 16px 0 + + > *:not(:last-child) + margin-bottom 16px + + > .left + padding-left 16px + order 1 + + > .right + padding-right 16px + order 3 + + @media (max-width 1100px) + > *:not(.main) + display none + + > .main + float none + width 100% + max-width 700px + margin 0 auto + +</style> diff --git a/src/client/app/desktop/views/components/index.ts b/src/client/app/desktop/views/components/index.ts new file mode 100644 index 0000000000..3798bf6d2d --- /dev/null +++ b/src/client/app/desktop/views/components/index.ts @@ -0,0 +1,61 @@ +import Vue from 'vue'; + +import ui from './ui.vue'; +import uiNotification from './ui-notification.vue'; +import home from './home.vue'; +import timeline from './timeline.vue'; +import posts from './posts.vue'; +import subPostContent from './sub-post-content.vue'; +import window from './window.vue'; +import postFormWindow from './post-form-window.vue'; +import repostFormWindow from './repost-form-window.vue'; +import analogClock from './analog-clock.vue'; +import ellipsisIcon from './ellipsis-icon.vue'; +import mediaImage from './media-image.vue'; +import mediaImageDialog from './media-image-dialog.vue'; +import mediaVideo from './media-video.vue'; +import notifications from './notifications.vue'; +import postForm from './post-form.vue'; +import repostForm from './repost-form.vue'; +import followButton from './follow-button.vue'; +import postPreview from './post-preview.vue'; +import drive from './drive.vue'; +import postDetail from './post-detail.vue'; +import settings from './settings.vue'; +import calendar from './calendar.vue'; +import activity from './activity.vue'; +import friendsMaker from './friends-maker.vue'; +import followers from './followers.vue'; +import following from './following.vue'; +import usersList from './users-list.vue'; +import widgetContainer from './widget-container.vue'; + +Vue.component('mk-ui', ui); +Vue.component('mk-ui-notification', uiNotification); +Vue.component('mk-home', home); +Vue.component('mk-timeline', timeline); +Vue.component('mk-posts', posts); +Vue.component('mk-sub-post-content', subPostContent); +Vue.component('mk-window', window); +Vue.component('mk-post-form-window', postFormWindow); +Vue.component('mk-repost-form-window', repostFormWindow); +Vue.component('mk-analog-clock', analogClock); +Vue.component('mk-ellipsis-icon', ellipsisIcon); +Vue.component('mk-media-image', mediaImage); +Vue.component('mk-media-image-dialog', mediaImageDialog); +Vue.component('mk-media-video', mediaVideo); +Vue.component('mk-notifications', notifications); +Vue.component('mk-post-form', postForm); +Vue.component('mk-repost-form', repostForm); +Vue.component('mk-follow-button', followButton); +Vue.component('mk-post-preview', postPreview); +Vue.component('mk-drive', drive); +Vue.component('mk-post-detail', postDetail); +Vue.component('mk-settings', settings); +Vue.component('mk-calendar', calendar); +Vue.component('mk-activity', activity); +Vue.component('mk-friends-maker', friendsMaker); +Vue.component('mk-followers', followers); +Vue.component('mk-following', following); +Vue.component('mk-users-list', usersList); +Vue.component('mk-widget-container', widgetContainer); diff --git a/src/client/app/desktop/views/components/input-dialog.vue b/src/client/app/desktop/views/components/input-dialog.vue new file mode 100644 index 0000000000..e939fc1903 --- /dev/null +++ b/src/client/app/desktop/views/components/input-dialog.vue @@ -0,0 +1,180 @@ +<template> +<mk-window ref="window" is-modal width="500px" @before-close="beforeClose" @closed="$destroy"> + <span slot="header" :class="$style.header"> + %fa:i-cursor%{{ title }} + </span> + + <div :class="$style.body"> + <input ref="text" v-model="text" :type="type" @keydown="onKeydown" :placeholder="placeholder"/> + </div> + <div :class="$style.actions"> + <button :class="$style.cancel" @click="cancel">キャンセル</button> + <button :class="$style.ok" :disabled="!allowEmpty && text.length == 0" @click="ok">決定</button> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + title: { + type: String + }, + placeholder: { + type: String + }, + default: { + type: String + }, + allowEmpty: { + default: true + }, + type: { + default: 'text' + } + }, + data() { + return { + done: false, + text: '' + }; + }, + mounted() { + if (this.default) this.text = this.default; + this.$nextTick(() => { + (this.$refs.text as any).focus(); + }); + }, + methods: { + ok() { + if (!this.allowEmpty && this.text == '') return; + this.done = true; + (this.$refs.window as any).close(); + }, + cancel() { + this.done = false; + (this.$refs.window as any).close(); + }, + beforeClose() { + if (this.done) { + this.$emit('done', this.text); + } else { + this.$emit('canceled'); + } + }, + onKeydown(e) { + if (e.which == 13) { // Enter + e.preventDefault(); + e.stopPropagation(); + this.ok(); + } + } + } +}); +</script> + + +<style lang="stylus" module> +@import '~const.styl' + +.header + > [data-fa] + margin-right 4px + +.body + padding 16px + + > input + display block + padding 8px + margin 0 + width 100% + max-width 100% + min-width 100% + font-size 1em + color #333 + background #fff + outline none + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + transition border-color .3s ease + + &:hover + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + color $theme-color + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + +.actions + height 72px + background lighten($theme-color, 95%) + +.ok +.cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + +.ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + +.cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + +</style> diff --git a/src/client/app/desktop/views/components/media-image-dialog.vue b/src/client/app/desktop/views/components/media-image-dialog.vue new file mode 100644 index 0000000000..dec140d1c9 --- /dev/null +++ b/src/client/app/desktop/views/components/media-image-dialog.vue @@ -0,0 +1,69 @@ +<template> +<div class="mk-media-image-dialog"> + <div class="bg" @click="close"></div> + <img :src="image.url" :alt="image.name" :title="image.name" @click="close"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['image'], + mounted() { + anime({ + targets: this.$el, + opacity: 1, + duration: 100, + easing: 'linear' + }); + }, + methods: { + close() { + anime({ + targets: this.$el, + opacity: 0, + duration: 100, + easing: 'linear', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-media-image-dialog + display block + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + opacity 0 + + > .bg + display block + position fixed + z-index 1 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + + > img + position fixed + z-index 2 + top 0 + right 0 + bottom 0 + left 0 + max-width 100% + max-height 100% + margin auto + cursor zoom-out + +</style> diff --git a/src/client/app/desktop/views/components/media-image.vue b/src/client/app/desktop/views/components/media-image.vue new file mode 100644 index 0000000000..51309a0578 --- /dev/null +++ b/src/client/app/desktop/views/components/media-image.vue @@ -0,0 +1,63 @@ +<template> +<a class="mk-media-image" + :href="image.url" + @mousemove="onMousemove" + @mouseleave="onMouseleave" + @click.prevent="onClick" + :style="style" + :title="image.name" +></a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkMediaImageDialog from './media-image-dialog.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)` + }; + } + }, + methods: { + onMousemove(e) { + const rect = this.$el.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const xp = mouseX / this.$el.offsetWidth * 100; + const yp = mouseY / this.$el.offsetHeight * 100; + this.$el.style.backgroundPosition = xp + '% ' + yp + '%'; + this.$el.style.backgroundImage = 'url("' + this.image.url + '?thumbnail")'; + }, + + onMouseleave() { + this.$el.style.backgroundPosition = ''; + }, + + onClick() { + (this as any).os.new(MkMediaImageDialog, { + image: this.image + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-media-image + display block + cursor zoom-in + overflow hidden + width 100% + height 100% + background-position center + border-radius 4px + + &:not(:hover) + background-size cover + +</style> diff --git a/src/client/app/desktop/views/components/media-video-dialog.vue b/src/client/app/desktop/views/components/media-video-dialog.vue new file mode 100644 index 0000000000..cbf862cd1c --- /dev/null +++ b/src/client/app/desktop/views/components/media-video-dialog.vue @@ -0,0 +1,70 @@ +<template> +<div class="mk-media-video-dialog"> + <div class="bg" @click="close"></div> + <video :src="video.url" :title="video.name" controls autoplay ref="video"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['video', 'start'], + mounted() { + anime({ + targets: this.$el, + opacity: 1, + duration: 100, + easing: 'linear' + }); + const videoTag = this.$refs.video as HTMLVideoElement + if (this.start) videoTag.currentTime = this.start + }, + methods: { + close() { + anime({ + targets: this.$el, + opacity: 0, + duration: 100, + easing: 'linear', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-media-video-dialog + display block + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + opacity 0 + + > .bg + display block + position fixed + z-index 1 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + + > video + position fixed + z-index 2 + top 0 + right 0 + bottom 0 + left 0 + max-width 80vw + max-height 80vh + margin auto + +</style> diff --git a/src/client/app/desktop/views/components/media-video.vue b/src/client/app/desktop/views/components/media-video.vue new file mode 100644 index 0000000000..4fd955a821 --- /dev/null +++ b/src/client/app/desktop/views/components/media-video.vue @@ -0,0 +1,67 @@ +<template> + <video class="mk-media-video" + :src="video.url" + :title="video.name" + controls + @dblclick.prevent="onClick" + ref="video" + v-if="inlinePlayable" /> + <a class="mk-media-video-thumbnail" + :href="video.url" + :style="imageStyle" + @click.prevent="onClick" + :title="video.name" + v-else> + %fa:R play-circle% + </a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkMediaVideoDialog from './media-video-dialog.vue'; + +export default Vue.extend({ + props: ['video', 'inlinePlayable'], + computed: { + imageStyle(): any { + return { + 'background-image': `url(${this.video.url}?thumbnail&size=512)` + }; + } + }, + methods: { + onClick() { + const videoTag = this.$refs.video as (HTMLVideoElement | null) + var start = 0 + if (videoTag) { + start = videoTag.currentTime + videoTag.pause() + } + (this as any).os.new(MkMediaVideoDialog, { + video: this.video, + start, + }) + } + } +}) +</script> + +<style lang="stylus" scoped> +.mk-media-video + display block + width 100% + height 100% + border-radius 4px +.mk-media-video-thumbnail + display flex + justify-content center + align-items center + font-size 3.5em + + cursor zoom-in + overflow hidden + background-position center + background-size cover + width 100% + height 100% +</style> diff --git a/src/client/app/desktop/views/components/mentions.vue b/src/client/app/desktop/views/components/mentions.vue new file mode 100644 index 0000000000..90a92495b7 --- /dev/null +++ b/src/client/app/desktop/views/components/mentions.vue @@ -0,0 +1,125 @@ +<template> +<div class="mk-mentions"> + <header> + <span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて</span> + <span :data-is-active="mode == 'following'" @click="mode = 'following'">フォロー中</span> + </header> + <div class="fetching" v-if="fetching"> + <mk-ellipsis-icon/> + </div> + <p class="empty" v-if="posts.length == 0 && !fetching"> + %fa:R comments% + <span v-if="mode == 'all'">あなた宛ての投稿はありません。</span> + <span v-if="mode == 'following'">あなたがフォローしているユーザーからの言及はありません。</span> + </p> + <mk-posts :posts="posts" ref="timeline"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + moreFetching: false, + mode: 'all', + posts: [] + }; + }, + watch: { + mode() { + this.fetch(); + } + }, + mounted() { + document.addEventListener('keydown', this.onDocumentKeydown); + window.addEventListener('scroll', this.onScroll); + + this.fetch(() => this.$emit('loaded')); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onDocumentKeydown); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + onDocumentKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 84) { // t + (this.$refs.timeline as any).focus(); + } + } + }, + onScroll() { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 8) this.more(); + }, + fetch(cb?) { + this.fetching = true; + this.posts = []; + (this as any).api('posts/mentions', { + following: this.mode == 'following' + }).then(posts => { + this.posts = posts; + this.fetching = false; + if (cb) cb(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0) return; + this.moreFetching = true; + (this as any).api('posts/mentions', { + following: this.mode == 'following', + untilId: this.posts[this.posts.length - 1].id + }).then(posts => { + this.posts = this.posts.concat(posts); + this.moreFetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-mentions + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > header + padding 8px 16px + border-bottom solid 1px #eee + + > span + margin-right 16px + line-height 27px + font-size 18px + color #555 + + &:not([data-is-active]) + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + > .fetching + padding 64px 0 + + > .empty + display block + 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 + +</style> diff --git a/src/client/app/desktop/views/components/messaging-room-window.vue b/src/client/app/desktop/views/components/messaging-room-window.vue new file mode 100644 index 0000000000..3735267811 --- /dev/null +++ b/src/client/app/desktop/views/components/messaging-room-window.vue @@ -0,0 +1,32 @@ +<template> +<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:comments%メッセージ: {{ user.name }}</span> + <mk-messaging-room :user="user" :class="$style.content"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url } from '../../../config'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + computed: { + popout(): string { + return `${url}/i/messaging/${getAcct(this.user)}`; + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +.content + height 100% + overflow auto + +</style> diff --git a/src/client/app/desktop/views/components/messaging-window.vue b/src/client/app/desktop/views/components/messaging-window.vue new file mode 100644 index 0000000000..ac27465987 --- /dev/null +++ b/src/client/app/desktop/views/components/messaging-window.vue @@ -0,0 +1,32 @@ +<template> +<mk-window ref="window" width="500px" height="560px" @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:comments%メッセージ</span> + <mk-messaging :class="$style.content" @navigate="navigate"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkMessagingRoomWindow from './messaging-room-window.vue'; + +export default Vue.extend({ + methods: { + navigate(user) { + (this as any).os.new(MkMessagingRoomWindow, { + user: user + }); + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +.content + height 100% + overflow auto + +</style> diff --git a/src/client/app/desktop/views/components/notifications.vue b/src/client/app/desktop/views/components/notifications.vue new file mode 100644 index 0000000000..5e6db08c12 --- /dev/null +++ b/src/client/app/desktop/views/components/notifications.vue @@ -0,0 +1,317 @@ +<template> +<div class="mk-notifications"> + <div class="notifications" v-if="notifications.length != 0"> + <template v-for="(notification, i) in _notifications"> + <div class="notification" :class="notification.type" :key="notification.id"> + <mk-time :time="notification.createdAt"/> + <template v-if="notification.type == 'reaction'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id"> + <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p> + <mk-reaction-icon :reaction="notification.reaction"/> + <router-link :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">{{ notification.user.name }}</router-link> + </p> + <router-link class="post-ref" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right% + </router-link> + </div> + </template> + <template v-if="notification.type == 'repost'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId"> + <img class="avatar" :src="`${notification.post.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:retweet% + <router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">{{ notification.post.user.name }}</router-link> + </p> + <router-link class="post-ref" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post.repost) }}%fa:quote-right% + </router-link> + </div> + </template> + <template v-if="notification.type == 'quote'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId"> + <img class="avatar" :src="`${notification.post.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:quote-left% + <router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">{{ notification.post.user.name }}</router-link> + </p> + <router-link class="post-preview" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</router-link> + </div> + </template> + <template v-if="notification.type == 'follow'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id"> + <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:user-plus% + <router-link :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">{{ notification.user.name }}</router-link> + </p> + </div> + </template> + <template v-if="notification.type == 'reply'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId"> + <img class="avatar" :src="`${notification.post.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:reply% + <router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">{{ notification.post.user.name }}</router-link> + </p> + <router-link class="post-preview" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</router-link> + </div> + </template> + <template v-if="notification.type == 'mention'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId"> + <img class="avatar" :src="`${notification.post.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:at% + <router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">{{ notification.post.user.name }}</router-link> + </p> + <a class="post-preview" :href="`/@${getAcct(notification.post.user)}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</a> + </div> + </template> + <template v-if="notification.type == 'poll_vote'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id"> + <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:chart-pie%<a :href="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">{{ notification.user.name }}</a></p> + <router-link class="post-ref" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right% + </router-link> + </div> + </template> + </div> + <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'"> + <span>%fa:angle-up%{{ notification._datetext }}</span> + <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> + </p> + </template> + </div> + <button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications"> + <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:desktop.tags.mk-notifications.more%' }} + </button> + <p class="empty" v-if="notifications.length == 0 && !fetching">ありません!</p> + <p class="loading" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; +import getPostSummary from '../../../../../common/get-post-summary'; + +export default Vue.extend({ + data() { + return { + fetching: true, + fetchingMoreNotifications: false, + notifications: [], + moreNotifications: false, + connection: null, + connectionId: null, + getPostSummary + }; + }, + 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; + }); + }, + beforeDestroy() { + this.connection.off('notification', this.onNotification); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + getAcct, + 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 + > .notifications + > .notification + margin 0 + padding 16px + overflow-wrap break-word + font-size 0.9em + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + &:last-child + border-bottom none + + > .mk-time + display inline + position absolute + top 16px + right 12px + vertical-align top + color rgba(0, 0, 0, 0.6) + font-size small + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + position -webkit-sticky + position sticky + top 16px + + > img + display block + 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, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #555 + + > .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 + + [data-fa] + margin-right 8px + + > .more + display block + width 100% + padding 16px + color #555 + border-top solid 1px rgba(0, 0, 0, 0.05) + + &:hover + background rgba(0, 0, 0, 0.025) + + &:active + background rgba(0, 0, 0, 0.05) + + &.fetching + cursor wait + + > [data-fa] + margin-right 4px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/components/post-detail.sub.vue b/src/client/app/desktop/views/components/post-detail.sub.vue new file mode 100644 index 0000000000..35377e7c24 --- /dev/null +++ b/src/client/app/desktop/views/components/post-detail.sub.vue @@ -0,0 +1,126 @@ +<template> +<div class="sub" :title="title"> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="post.userId"/> + </router-link> + <div class="main"> + <header> + <div class="left"> + <router-link class="name" :to="`/@${acct}`" v-user-preview="post.userId">{{ post.user.name }}</router-link> + <span class="username">@{{ acct }}</span> + </div> + <div class="right"> + <router-link class="time" :to="`/@${acct}/${post.id}`"> + <mk-time :time="post.createdAt"/> + </router-link> + </div> + </header> + <div class="body"> + <mk-post-html v-if="post.ast" :ast="post.ast" :i="os.i" :class="$style.text"/> + <div class="media" v-if="post.media"> + <mk-media-list :media-list="post.media"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + }, + title(): string { + return dateStringify(this.post.createdAt); + } + } +}); +</script> + +<style lang="stylus" scoped> +.sub + margin 0 + padding 20px 32px + background #fdfdfd + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 44px + height 44px + margin 0 + border-radius 4px + vertical-align bottom + + > .main + float left + width calc(100% - 60px) + + > header + margin-bottom 4px + white-space nowrap + + &:after + content "" + display block + clear both + + > .left + float left + + > .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 + + > .right + float right + + > .time + font-size 0.9em + color #c0c0c0 + +</style> + +<style lang="stylus" module> +.text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1em + color #717171 +</style> diff --git a/src/client/app/desktop/views/components/post-detail.vue b/src/client/app/desktop/views/components/post-detail.vue new file mode 100644 index 0000000000..5c7a7dfdbe --- /dev/null +++ b/src/client/app/desktop/views/components/post-detail.vue @@ -0,0 +1,433 @@ +<template> +<div class="mk-post-detail" :title="title"> + <button + class="read-more" + v-if="p.reply && p.reply.replyId && context == null" + title="会話をもっと読み込む" + @click="fetchContext" + :disabled="contextFetching" + > + <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}`" v-user-preview="post.userId"> + <img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=32`" alt="avatar"/> + </router-link> + %fa:retweet% + <router-link class="name" :href="`/@${acct}`">{{ post.user.name }}</router-link> + がRepost + </p> + </div> + <article> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/> + </router-link> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="p.user.id">{{ p.user.name }}</router-link> + <span class="username">@{{ acct }}</span> + <router-link class="time" :to="`/@${acct}/${p.id}`"> + <mk-time :time="p.createdAt"/> + </router-link> + </header> + <div class="body"> + <mk-post-html :class="$style.text" v-if="p.ast" :ast="p.ast" :i="os.i"/> + <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"/> + <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> + <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> + <footer> + <mk-reactions-viewer :post="p"/> + <button @click="reply" title="返信"> + %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="リアクション"> + %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 dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; + +import MkPostFormWindow from './post-form-window.vue'; +import MkRepostFormWindow from './repost-form-window.vue'; +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 + } + }, + computed: { + acct() { + return getAcct(this.post.user); + } + }, + data() { + return { + context: [], + contextFetching: false, + replies: [], + }; + }, + computed: { + 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; + }, + title(): string { + return dateStringify(this.p.createdAt); + }, + 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).os.new(MkPostFormWindow, { + reply: this.p + }); + }, + repost() { + (this as any).os.new(MkRepostFormWindow, { + post: this.p + }); + }, + react() { + (this as any).os.new(MkReactionPicker, { + source: this.$refs.reactButton, + post: this.p + }); + }, + menu() { + (this as any).os.new(MkPostMenu, { + source: this.$refs.menuButton, + post: this.p + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-post-detail + margin 0 + padding 0 + overflow hidden + text-align left + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 8px + + > .read-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 + + &: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 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + width 60px + height 60px + + > .avatar + display block + width 60px + height 60px + margin 0 + border-radius 8px + vertical-align bottom + + > header + position absolute + top 28px + left 108px + width calc(100% - 108px) + + > .name + display inline-block + margin 0 + line-height 24px + color #777 + font-size 18px + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + display block + text-align left + margin 0 + color #ccc + + > .time + position absolute + top 0 + right 32px + font-size 1em + color #c0c0c0 + + > .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 300px + + &:empty + display none + + > .mk-url-preview + margin-top 8px + + > .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% + + &:hover + text-decoration none + background #e2e7ec + + > footer + font-size 1.2em + + > button + margin 0 28px 0 0 + padding 8px + background transparent + border none + font-size 1em + color #ddd + cursor pointer + + &: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 + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.5em + color #717171 +</style> diff --git a/src/client/app/desktop/views/components/post-form-window.vue b/src/client/app/desktop/views/components/post-form-window.vue new file mode 100644 index 0000000000..d0b115e852 --- /dev/null +++ b/src/client/app/desktop/views/components/post-form-window.vue @@ -0,0 +1,76 @@ +<template> +<mk-window ref="window" is-modal @closed="$destroy"> + <span slot="header"> + <span :class="$style.icon" v-if="geo">%fa:map-marker-alt%</span> + <span v-if="!reply">%i18n:desktop.tags.mk-post-form-window.post%</span> + <span v-if="reply">%i18n:desktop.tags.mk-post-form-window.reply%</span> + <span :class="$style.count" v-if="media.length != 0">{{ '%i18n:desktop.tags.mk-post-form-window.attaches%'.replace('{}', media.length) }}</span> + <span :class="$style.count" v-if="uploadings.length != 0">{{ '%i18n:desktop.tags.mk-post-form-window.uploading-media%'.replace('{}', uploadings.length) }}<mk-ellipsis/></span> + </span> + + <mk-post-preview v-if="reply" :class="$style.postPreview" :post="reply"/> + <mk-post-form ref="form" + :reply="reply" + @posted="onPosted" + @change-uploadings="onChangeUploadings" + @change-attached-media="onChangeMedia" + @geo-attached="onGeoAttached" + @geo-dettached="onGeoDettached"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['reply'], + data() { + return { + uploadings: [], + media: [], + geo: null + }; + }, + mounted() { + this.$nextTick(() => { + (this.$refs.form as any).focus(); + }); + }, + methods: { + onChangeUploadings(files) { + this.uploadings = files; + }, + onChangeMedia(media) { + this.media = media; + }, + onGeoAttached(geo) { + this.geo = geo; + }, + onGeoDettached() { + this.geo = null; + }, + onPosted() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +.icon + margin-right 8px + +.count + margin-left 8px + opacity 0.8 + + &:before + content '(' + + &:after + content ')' + +.postPreview + margin 16px 22px + +</style> diff --git a/src/client/app/desktop/views/components/post-form.vue b/src/client/app/desktop/views/components/post-form.vue new file mode 100644 index 0000000000..1c83a38b64 --- /dev/null +++ b/src/client/app/desktop/views/components/post-form.vue @@ -0,0 +1,536 @@ +<template> +<div class="mk-post-form" + @dragover.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <div class="content"> + <textarea :class="{ with: (files.length != 0 || poll) }" + ref="text" v-model="text" :disabled="posting" + @keydown="onKeydown" @paste="onPaste" :placeholder="placeholder" + v-autocomplete="'text'" + ></textarea> + <div class="medias" :class="{ with: poll }" v-show="files.length != 0"> + <x-draggable :list="files" :options="{ animation: 150 }"> + <div v-for="file in files" :key="file.id"> + <div class="img" :style="{ backgroundImage: `url(${file.url}?thumbnail&size=64)` }" :title="file.name"></div> + <img class="remove" @click="detachMedia(file.id)" src="/assets/desktop/remove.png" title="%i18n:desktop.tags.mk-post-form.attach-cancel%" alt=""/> + </div> + </x-draggable> + <p class="remain">{{ 4 - files.length }}/4</p> + </div> + <mk-poll-editor v-if="poll" ref="poll" @destroyed="poll = false" @updated="saveDraft()"/> + </div> + <mk-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/> + <button class="upload" title="%i18n:desktop.tags.mk-post-form.attach-media-from-local%" @click="chooseFile">%fa:upload%</button> + <button class="drive" title="%i18n:desktop.tags.mk-post-form.attach-media-from-drive%" @click="chooseFileFromDrive">%fa:cloud%</button> + <button class="kao" title="%i18n:desktop.tags.mk-post-form.insert-a-kao%" @click="kao">%fa:R smile%</button> + <button class="poll" title="%i18n:desktop.tags.mk-post-form.create-poll%" @click="poll = true">%fa:chart-pie%</button> + <button class="geo" title="位置情報を添付する" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button> + <p class="text-count" :class="{ over: text.length > 1000 }">{{ '%i18n:desktop.tags.mk-post-form.text-remain%'.replace('{}', 1000 - text.length) }}</p> + <button :class="{ posting }" class="submit" :disabled="!canPost" @click="post"> + {{ posting ? '%i18n:desktop.tags.mk-post-form.posting%' : submitText }}<mk-ellipsis v-if="posting"/> + </button> + <input ref="file" type="file" accept="image/*" multiple="multiple" tabindex="-1" @change="onChangeFile"/> + <div class="dropzone" v-if="draghover"></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', 'repost'], + data() { + return { + posting: false, + text: '', + files: [], + uploadings: [], + poll: false, + geo: null, + autocomplete: null, + draghover: false + }; + }, + computed: { + draftId(): string { + return this.repost + ? 'repost:' + this.repost.id + : this.reply + ? 'reply:' + this.reply.id + : 'post'; + }, + placeholder(): string { + return this.repost + ? '%i18n:desktop.tags.mk-post-form.quote-placeholder%' + : this.reply + ? '%i18n:desktop.tags.mk-post-form.reply-placeholder%' + : '%i18n:desktop.tags.mk-post-form.post-placeholder%'; + }, + submitText(): string { + return this.repost + ? '%i18n:desktop.tags.mk-post-form.repost%' + : this.reply + ? '%i18n:desktop.tags.mk-post-form.reply%' + : '%i18n:desktop.tags.mk-post-form.post%'; + }, + canPost(): boolean { + return !this.posting && (this.text.length != 0 || this.files.length != 0 || this.poll || this.repost); + } + }, + watch: { + text() { + this.saveDraft(); + }, + poll() { + this.saveDraft(); + }, + files() { + this.saveDraft(); + } + }, + mounted() { + this.$nextTick(() => { + // 書きかけの投稿を復元 + const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId]; + if (draft) { + this.text = draft.data.text; + this.files = draft.data.files; + if (draft.data.poll) { + this.poll = true; + this.$nextTick(() => { + (this.$refs.poll as any).set(draft.data.poll); + }); + } + this.$emit('change-attached-media', this.files); + } + }); + }, + 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(id) { + this.files = this.files.filter(x => x.id != 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); + }, + clear() { + this.text = ''; + this.files = []; + this.poll = false; + this.$emit('change-attached-media', this.files); + }, + onKeydown(e) { + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); + }, + onPaste(e) { + Array.from(e.clipboardData.items).forEach((item: any) => { + if (item.kind == 'file') { + this.upload(item.getAsFile()); + } + }); + }, + onDragover(e) { + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + if (isFile || isDriveFile) { + e.preventDefault(); + this.draghover = true; + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } + }, + onDragenter(e) { + this.draghover = true; + }, + onDragleave(e) { + this.draghover = false; + }, + onDrop(e): void { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + e.preventDefault(); + Array.from(e.dataTransfer.files).forEach(this.upload); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.files.push(file); + this.$emit('change-attached-media', this.files); + e.preventDefault(); + } + //#endregion + }, + setGeo() { + if (navigator.geolocation == null) { + alert('お使いの端末は位置情報に対応していません'); + return; + } + + navigator.geolocation.getCurrentPosition(pos => { + this.geo = pos.coords; + this.$emit('geo-attached', this.geo); + }, err => { + alert('エラー: ' + err.message); + }, { + enableHighAccuracy: true + }); + }, + removeGeo() { + this.geo = null; + this.$emit('geo-dettached'); + }, + post() { + this.posting = 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, + repostId: this.repost ? this.repost.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 + }).then(data => { + this.clear(); + this.deleteDraft(); + this.$emit('posted'); + (this as any).apis.notify(this.repost + ? '%i18n:desktop.tags.mk-post-form.reposted%' + : this.reply + ? '%i18n:desktop.tags.mk-post-form.replied%' + : '%i18n:desktop.tags.mk-post-form.posted%'); + }).catch(err => { + (this as any).apis.notify(this.repost + ? '%i18n:desktop.tags.mk-post-form.repost-failed%' + : this.reply + ? '%i18n:desktop.tags.mk-post-form.reply-failed%' + : '%i18n:desktop.tags.mk-post-form.post-failed%'); + }).then(() => { + this.posting = false; + }); + }, + saveDraft() { + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + data[this.draftId] = { + updatedAt: new Date(), + data: { + text: this.text, + files: this.files, + poll: this.poll && this.$refs.poll ? (this.$refs.poll as any).get() : undefined + } + } + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + deleteDraft() { + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + delete data[this.draftId]; + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + kao() { + this.text += getKao(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-post-form + display block + padding 16px + background lighten($theme-color, 95%) + + &:after + content "" + display block + clear both + + > .content + + textarea + display block + padding 12px + margin 0 + width 100% + max-width 100% + min-width 100% + min-height calc(16px + 12px + 12px) + font-size 16px + color #333 + background #fff + outline none + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + transition border-color .3s ease + + &:hover + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + & + * + & + * + * + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + color $theme-color + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + & + * + & + * + * + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &:disabled + opacity 0.5 + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + + &.with + border-bottom solid 1px rgba($theme-color, 0.1) !important + border-radius 4px 4px 0 0 + + > .medias + margin 0 + padding 0 + background lighten($theme-color, 98%) + border solid 1px rgba($theme-color, 0.1) + border-top none + border-radius 0 0 4px 4px + transition border-color .3s ease + + &.with + border-bottom solid 1px rgba($theme-color, 0.1) !important + border-radius 0 + + > .remain + display block + position absolute + top 8px + right 8px + margin 0 + padding 0 + color rgba($theme-color, 0.4) + + > div + padding 4px + + &:after + content "" + display block + clear both + + > div + float left + border solid 4px transparent + cursor move + + &:hover > .remove + display block + + > .img + width 64px + height 64px + background-size cover + background-position center center + + > .remove + display none + position absolute + top -6px + right -6px + width 16px + height 16px + cursor pointer + + > .mk-poll-editor + background lighten($theme-color, 98%) + border solid 1px rgba($theme-color, 0.1) + border-top none + border-radius 0 0 4px 4px + transition border-color .3s ease + + > .mk-uploader + margin 8px 0 0 0 + padding 8px + border solid 1px rgba($theme-color, 0.2) + border-radius 4px + + input[type='file'] + display none + + .text-count + pointer-events none + display block + position absolute + bottom 16px + right 138px + margin 0 + line-height 40px + color rgba($theme-color, 0.5) + + &.over + color #ec3828 + + .submit + display block + position absolute + bottom 16px + right 16px + cursor pointer + padding 0 + margin 0 + width 110px + height 40px + font-size 1em + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + outline none + border solid 1px lighten($theme-color, 15%) + border-radius 4px + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + &.wait + background linear-gradient( + 45deg, + darken($theme-color, 10%) 25%, + $theme-color 25%, + $theme-color 50%, + darken($theme-color, 10%) 50%, + darken($theme-color, 10%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation stripe-bg 1.5s linear infinite + opacity 0.7 + cursor wait + + @keyframes stripe-bg + from {background-position: 0 0;} + to {background-position: -64px 32px;} + + > .upload + > .drive + > .kao + > .poll + > .geo + display inline-block + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background linear-gradient(to bottom, lighten($theme-color, 80%) 0%, lighten($theme-color, 90%) 100%) + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(0, 0, 0, 0.15) inset + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + > .dropzone + position absolute + left 0 + top 0 + width 100% + height 100% + border dashed 2px rgba($theme-color, 0.5) + pointer-events none + +</style> diff --git a/src/client/app/desktop/views/components/post-preview.vue b/src/client/app/desktop/views/components/post-preview.vue new file mode 100644 index 0000000000..0ac3223be2 --- /dev/null +++ b/src/client/app/desktop/views/components/post-preview.vue @@ -0,0 +1,103 @@ +<template> +<div class="mk-post-preview" :title="title"> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="post.userId"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="post.userId">{{ 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 dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + }, + title(): string { + return dateStringify(this.post.createdAt); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-post-preview + 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 16px 0 0 + + > .avatar + display block + width 52px + height 52px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 68px) + + > header + display flex + white-space nowrap + + > .name + margin 0 .5em 0 0 + padding 0 + color #607073 + font-size 1em + font-weight bold + text-decoration none + white-space normal + + &:hover + text-decoration underline + + > .username + 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/desktop/views/components/posts.post.sub.vue b/src/client/app/desktop/views/components/posts.post.sub.vue new file mode 100644 index 0000000000..65d3017d3d --- /dev/null +++ b/src/client/app/desktop/views/components/posts.post.sub.vue @@ -0,0 +1,112 @@ +<template> +<div class="sub" :title="title"> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="post.userId"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="post.userId">{{ 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 dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + }, + title(): string { + return dateStringify(this.post.createdAt); + } + } +}); +</script> + +<style lang="stylus" scoped> +.sub + margin 0 + padding 16px + font-size 0.9em + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 14px 0 0 + + > .avatar + display block + width 52px + height 52px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 66px) + + > header + display flex + margin-bottom 2px + white-space nowrap + line-height 21px + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + color #607073 + font-size 1em + font-weight bold + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .username + margin 0 .5em 0 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/desktop/views/components/posts.post.vue b/src/client/app/desktop/views/components/posts.post.vue new file mode 100644 index 0000000000..37c6e63043 --- /dev/null +++ b/src/client/app/desktop/views/components/posts.post.vue @@ -0,0 +1,582 @@ +<template> +<div class="post" tabindex="-1" :title="title" @keydown="onKeydown"> + <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}`" v-user-preview="post.userId"> + <img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=32`" alt="avatar"/> + </router-link> + %fa:retweet% + <span>{{ '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('{')) }}</span> + <a class="name" :href="`/@${acct}`" v-user-preview="post.userId">{{ post.user.name }}</a> + <span>{{ '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr('%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1) }}</span> + </p> + <mk-time :time="post.createdAt"/> + </div> + <article> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="p.user.id">{{ acct }}</router-link> + <span class="is-bot" v-if="p.user.host === null && p.user.account.isBot">bot</span> + <span class="username">@{{ acct }}</span> + <div class="info"> + <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span> + <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"> + <a :href="`${_CH_URL_}/${p.channel.id}`" 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">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> + <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> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + </div> + <footer> + <mk-reactions-viewer :post="p" ref="reactionsViewer"/> + <button @click="reply" title="%i18n:desktop.tags.mk-timeline-post.reply%"> + %fa:reply%<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> + </button> + <button @click="repost" title="%i18n:desktop.tags.mk-timeline-post.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:desktop.tags.mk-timeline-post.add-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> + <button title="%i18n:desktop.tags.mk-timeline-post.detail"> + <template v-if="!isDetailOpened">%fa:caret-down%</template> + <template v-if="isDetailOpened">%fa:caret-up%</template> + </button> + </footer> + </div> + </article> + <div class="detail" v-if="isDetailOpened"> + <mk-post-status-graph width="462" height="130" :post="p"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; +import MkPostFormWindow from './post-form-window.vue'; +import MkRepostFormWindow from './repost-form-window.vue'; +import MkPostMenu from '../../../common/views/components/post-menu.vue'; +import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; +import XSub from './posts.post.sub.vue'; + +function focus(el, fn) { + const target = fn(el); + if (target) { + if (target.hasAttribute('tabindex')) { + target.focus(); + } else { + focus(target, fn); + } + } +} + +export default Vue.extend({ + components: { + XSub + }, + props: ['post'], + data() { + return { + isDetailOpened: false, + connection: null, + connectionId: null + }; + }, + computed: { + acct() { + 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; + }, + title(): string { + return dateStringify(this.p.createdAt); + }, + url(): string { + return `/@${this.acct}/${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).os.new(MkPostFormWindow, { + reply: this.p + }); + }, + repost() { + (this as any).os.new(MkRepostFormWindow, { + post: this.p + }); + }, + react() { + (this as any).os.new(MkReactionPicker, { + source: this.$refs.reactButton, + post: this.p + }); + }, + menu() { + (this as any).os.new(MkPostMenu, { + source: this.$refs.menuButton, + post: this.p + }); + }, + onKeydown(e) { + let shouldBeCancel = true; + + switch (true) { + case e.which == 38: // [↑] + case e.which == 74: // [j] + case e.which == 9 && e.shiftKey: // [Shift] + [Tab] + focus(this.$el, e => e.previousElementSibling); + break; + + case e.which == 40: // [↓] + case e.which == 75: // [k] + case e.which == 9: // [Tab] + focus(this.$el, e => e.nextElementSibling); + break; + + case e.which == 81: // [q] + case e.which == 69: // [e] + this.repost(); + break; + + case e.which == 70: // [f] + case e.which == 76: // [l] + //this.like(); + break; + + case e.which == 82: // [r] + this.reply(); + break; + + default: + shouldBeCancel = false; + } + + if (shouldBeCancel) e.preventDefault(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.post + margin 0 + padding 0 + background #fff + border-bottom solid 1px #eaeaea + + &:first-child + border-top-left-radius 6px + border-top-right-radius 6px + + > .repost + border-top-left-radius 6px + border-top-right-radius 6px + + &:last-of-type + border-bottom none + + &:focus + z-index 1 + + &:after + content "" + pointer-events none + position absolute + top 2px + right 2px + bottom 2px + left 2px + border 2px solid rgba($theme-color, 0.3) + border-radius 4px + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + line-height 28px + + .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 16px + right 32px + font-size 0.9em + line-height 28px + + & + article + padding-top 8px + + > .reply-to + padding 0 16px + background rgba(0, 0, 0, 0.0125) + + > .mk-post-preview + background transparent + + > article + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 10px 0 + //position -webkit-sticky + //position sticky + //top 74px + + > .avatar + display block + width 58px + height 58px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 74px) + + > header + display flex + align-items center + margin-bottom 4px + white-space nowrap + + > .name + display block + margin 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 .5em 0 0 + padding 1px 6px + font-size 12px + color #aaa + border solid 1px #ddd + border-radius 3px + + > .username + margin 0 .5em 0 0 + color #ccc + + > .info + margin-left auto + font-size 0.9em + + > .mobile + margin-right 8px + color #ccc + + > .app + margin-right 8px + padding-right 8px + color #ccc + border-right solid 1px #eaeaea + + > .created-at + color #c0c0c0 + + > .body + + > .text + cursor default + 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 + + > .location + margin 4px 0 + font-size 12px + color #ccc + + > .map + width 100% + height 300px + + &:empty + display none + + > .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% + + &:hover + text-decoration none + background #e2e7ec + + .mk-url-preview + margin-top 8px + + > .channel + margin 0 + + > .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 28px 0 0 + padding 0 8px + line-height 32px + font-size 1em + color #ddd + background transparent + border none + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.reacted + color $theme-color + + &:last-child + position absolute + right 0 + margin 0 + + > .detail + padding-top 4px + background rgba(0, 0, 0, 0.0125) + +</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 + + [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 +</style> diff --git a/src/client/app/desktop/views/components/posts.vue b/src/client/app/desktop/views/components/posts.vue new file mode 100644 index 0000000000..5031667c7c --- /dev/null +++ b/src/client/app/desktop/views/components/posts.vue @@ -0,0 +1,89 @@ +<template> +<div class="mk-posts"> + <template v-for="(post, i) in _posts"> + <x-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="footer"></slot> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPost from './posts.post.vue'; + +export default Vue.extend({ + components: { + XPost + }, + 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: { + focus() { + (this.$el as any).children[0].focus(); + }, + onPostUpdated(i, post) { + Vue.set((this as any).posts, i, post); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-posts + + > .date + display block + margin 0 + line-height 32px + font-size 14px + text-align center + color #aaa + background #fdfdfd + border-bottom solid 1px #eaeaea + + span + margin 0 16px + + [data-fa] + margin-right 8px + + > footer + > * + display block + margin 0 + padding 16px + width 100% + text-align center + color #ccc + border-top solid 1px #eaeaea + border-bottom-left-radius 4px + border-bottom-right-radius 4px + + > button + &:hover + background #f5f5f5 + + &:active + background #eee +</style> diff --git a/src/client/app/desktop/views/components/progress-dialog.vue b/src/client/app/desktop/views/components/progress-dialog.vue new file mode 100644 index 0000000000..a4292e1aec --- /dev/null +++ b/src/client/app/desktop/views/components/progress-dialog.vue @@ -0,0 +1,95 @@ +<template> +<mk-window ref="window" :is-modal="false" :can-close="false" width="500px" @closed="$destroy"> + <span slot="header">{{ title }}<mk-ellipsis/></span> + <div :class="$style.body"> + <p :class="$style.init" v-if="isNaN(value)">待機中<mk-ellipsis/></p> + <p :class="$style.percentage" v-if="!isNaN(value)">{{ Math.floor((value / max) * 100) }}</p> + <progress :class="$style.progress" + v-if="!isNaN(value) && value < max" + :value="isNaN(value) ? 0 : value" + :max="max" + ></progress> + <div :class="[$style.progress, $style.waiting]" v-if="value >= max"></div> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['title', 'initValue', 'initMax'], + data() { + return { + value: this.initValue, + max: this.initMax + }; + }, + methods: { + update(value, max) { + this.value = parseInt(value, 10); + this.max = parseInt(max, 10); + }, + close() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +@import '~const.styl' + +.body + padding 18px 24px 24px 24px + +.init + display block + margin 0 + text-align center + color rgba(#000, 0.7) + +.percentage + display block + margin 0 0 4px 0 + text-align center + line-height 16px + color rgba($theme-color, 0.7) + + &:after + content '%' + +.progress + display block + margin 0 + width 100% + height 10px + background transparent + border none + border-radius 4px + overflow hidden + + &::-webkit-progress-value + background $theme-color + + &::-webkit-progress-bar + background rgba($theme-color, 0.1) + +.waiting + background linear-gradient( + 45deg, + lighten($theme-color, 30%) 25%, + $theme-color 25%, + $theme-color 50%, + lighten($theme-color, 30%) 50%, + lighten($theme-color, 30%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation progress-dialog-tag-progress-waiting 1.5s linear infinite + + @keyframes progress-dialog-tag-progress-waiting + from {background-position: 0 0;} + to {background-position: -64px 32px;} + +</style> diff --git a/src/client/app/desktop/views/components/repost-form-window.vue b/src/client/app/desktop/views/components/repost-form-window.vue new file mode 100644 index 0000000000..7db5adbff3 --- /dev/null +++ b/src/client/app/desktop/views/components/repost-form-window.vue @@ -0,0 +1,42 @@ +<template> +<mk-window ref="window" is-modal @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:retweet%%i18n:desktop.tags.mk-repost-form-window.title%</span> + <mk-repost-form ref="form" :post="post" @posted="onPosted" @canceled="onCanceled"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['post'], + mounted() { + document.addEventListener('keydown', this.onDocumentKeydown); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onDocumentKeydown); + }, + methods: { + onDocumentKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 27) { // Esc + (this.$refs.window as any).close(); + } + } + }, + onPosted() { + (this.$refs.window as any).close(); + }, + onCanceled() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/components/repost-form.vue b/src/client/app/desktop/views/components/repost-form.vue new file mode 100644 index 0000000000..3a5e3a7c56 --- /dev/null +++ b/src/client/app/desktop/views/components/repost-form.vue @@ -0,0 +1,131 @@ +<template> +<div class="mk-repost-form"> + <mk-post-preview :post="post"/> + <template v-if="!quote"> + <footer> + <a class="quote" v-if="!quote" @click="onQuote">%i18n:desktop.tags.mk-repost-form.quote%</a> + <button class="cancel" @click="cancel">%i18n:desktop.tags.mk-repost-form.cancel%</button> + <button class="ok" @click="ok" :disabled="wait">{{ wait ? '%i18n:desktop.tags.mk-repost-form.reposting%' : '%i18n:desktop.tags.mk-repost-form.repost%' }}</button> + </footer> + </template> + <template v-if="quote"> + <mk-post-form ref="form" :repost="post" @posted="onChildFormPosted"/> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['post'], + data() { + return { + wait: false, + quote: false + }; + }, + methods: { + ok() { + this.wait = true; + (this as any).api('posts/create', { + repostId: this.post.id + }).then(data => { + this.$emit('posted'); + (this as any).apis.notify('%i18n:desktop.tags.mk-repost-form.success%'); + }).catch(err => { + (this as any).apis.notify('%i18n:desktop.tags.mk-repost-form.failure%'); + }).then(() => { + this.wait = false; + }); + }, + cancel() { + this.$emit('canceled'); + }, + onQuote() { + this.quote = true; + + this.$nextTick(() => { + (this.$refs.form as any).focus(); + }); + }, + onChildFormPosted() { + this.$emit('posted'); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-repost-form + + > .mk-post-preview + margin 16px 22px + + > footer + height 72px + background lighten($theme-color, 95%) + + > .quote + position absolute + bottom 16px + left 28px + line-height 40px + + button + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + > .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + > .ok + right 16px + font-weight bold + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:hover + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active + background $theme-color + border-color $theme-color + +</style> diff --git a/src/client/app/desktop/views/components/settings-window.vue b/src/client/app/desktop/views/components/settings-window.vue new file mode 100644 index 0000000000..d5be177dcc --- /dev/null +++ b/src/client/app/desktop/views/components/settings-window.vue @@ -0,0 +1,24 @@ +<template> +<mk-window ref="window" is-modal width="700px" height="550px" @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:cog%設定</span> + <mk-settings @done="close"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + methods: { + close() { + (this as any).$refs.window.close(); + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/components/settings.2fa.vue b/src/client/app/desktop/views/components/settings.2fa.vue new file mode 100644 index 0000000000..b8dd1dfd9b --- /dev/null +++ b/src/client/app/desktop/views/components/settings.2fa.vue @@ -0,0 +1,80 @@ +<template> +<div class="2fa"> + <p>%i18n:desktop.tags.mk-2fa-setting.intro%<a href="%i18n:desktop.tags.mk-2fa-setting.url%" target="_blank">%i18n:desktop.tags.mk-2fa-setting.detail%</a></p> + <div class="ui info warn"><p>%fa:exclamation-triangle%%i18n:desktop.tags.mk-2fa-setting.caution%</p></div> + <p v-if="!data && !os.i.account.twoFactorEnabled"><button @click="register" class="ui primary">%i18n:desktop.tags.mk-2fa-setting.register%</button></p> + <template v-if="os.i.account.twoFactorEnabled"> + <p>%i18n:desktop.tags.mk-2fa-setting.already-registered%</p> + <button @click="unregister" class="ui">%i18n:desktop.tags.mk-2fa-setting.unregister%</button> + </template> + <div v-if="data"> + <ol> + <li>%i18n:desktop.tags.mk-2fa-setting.authenticator% <a href="https://support.google.com/accounts/answer/1066447" target="_blank">%i18n:desktop.tags.mk-2fa-setting.howtoinstall%</a></li> + <li>%i18n:desktop.tags.mk-2fa-setting.scan%<br><img :src="data.qr"></li> + <li>%i18n:desktop.tags.mk-2fa-setting.done%<br> + <input type="number" v-model="token" class="ui"> + <button @click="submit" class="ui primary">%i18n:desktop.tags.mk-2fa-setting.submit%</button> + </li> + </ol> + <div class="ui info"><p>%fa:info-circle%%i18n:desktop.tags.mk-2fa-setting.info%</p></div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + data: null, + token: null + }; + }, + methods: { + register() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-2fa-setting.enter-password%', + type: 'password' + }).then(password => { + (this as any).api('i/2fa/register', { + password: password + }).then(data => { + this.data = data; + }); + }); + }, + + unregister() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-2fa-setting.enter-password%', + type: 'password' + }).then(password => { + (this as any).api('i/2fa/unregister', { + password: password + }).then(() => { + (this as any).apis.notify('%i18n:desktop.tags.mk-2fa-setting.unregistered%'); + (this as any).os.i.account.twoFactorEnabled = false; + }); + }); + }, + + submit() { + (this as any).api('i/2fa/done', { + token: this.token + }).then(() => { + (this as any).apis.notify('%i18n:desktop.tags.mk-2fa-setting.success%'); + (this as any).os.i.account.twoFactorEnabled = true; + }).catch(() => { + (this as any).apis.notify('%i18n:desktop.tags.mk-2fa-setting.failed%'); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.2fa + color #4a535a + +</style> diff --git a/src/client/app/desktop/views/components/settings.api.vue b/src/client/app/desktop/views/components/settings.api.vue new file mode 100644 index 0000000000..0d5921ab7f --- /dev/null +++ b/src/client/app/desktop/views/components/settings.api.vue @@ -0,0 +1,40 @@ +<template> +<div class="root api"> + <p>Token: <code>{{ os.i.account.token }}</code></p> + <p>%i18n:desktop.tags.mk-api-info.intro%</p> + <div class="ui info warn"><p>%fa:exclamation-triangle%%i18n:desktop.tags.mk-api-info.caution%</p></div> + <p>%i18n:desktop.tags.mk-api-info.regeneration-of-token%</p> + <button class="ui" @click="regenerateToken">%i18n:desktop.tags.mk-api-info.regenerate-token%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + methods: { + regenerateToken() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-api-info.enter-password%', + type: 'password' + }).then(password => { + (this as any).api('i/regenerate_token', { + password: password + }); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.root.api + color #4a535a + + code + display inline-block + padding 4px 6px + color #555 + background #eee + border-radius 2px +</style> diff --git a/src/client/app/desktop/views/components/settings.apps.vue b/src/client/app/desktop/views/components/settings.apps.vue new file mode 100644 index 0000000000..0503b03abd --- /dev/null +++ b/src/client/app/desktop/views/components/settings.apps.vue @@ -0,0 +1,39 @@ +<template> +<div class="root"> + <div class="none ui info" v-if="!fetching && apps.length == 0"> + <p>%fa:info-circle%%i18n:common.tags.mk-authorized-apps.no-apps%</p> + </div> + <div class="apps" v-if="apps.length != 0"> + <div v-for="app in apps"> + <p><b>{{ app.name }}</b></p> + <p>{{ app.description }}</p> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + apps: [] + }; + }, + mounted() { + (this as any).api('i/authorized_apps').then(apps => { + this.apps = apps; + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root + > .apps + > div + padding 16px 0 0 0 + border-bottom solid 1px #eee +</style> diff --git a/src/client/app/desktop/views/components/settings.drive.vue b/src/client/app/desktop/views/components/settings.drive.vue new file mode 100644 index 0000000000..8bb0c760a7 --- /dev/null +++ b/src/client/app/desktop/views/components/settings.drive.vue @@ -0,0 +1,35 @@ +<template> +<div class="root"> + <template v-if="!fetching"> + <el-progress :text-inside="true" :stroke-width="18" :percentage="Math.floor((usage / capacity) * 100)"/> + <p><b>{{ capacity | bytes }}</b>中<b>{{ usage | bytes }}</b>使用中</p> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + usage: null, + capacity: null + }; + }, + mounted() { + (this as any).api('drive').then(info => { + this.capacity = info.capacity; + this.usage = info.usage; + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root + > p + > b + margin 0 8px +</style> diff --git a/src/client/app/desktop/views/components/settings.mute.vue b/src/client/app/desktop/views/components/settings.mute.vue new file mode 100644 index 0000000000..a8dfe10604 --- /dev/null +++ b/src/client/app/desktop/views/components/settings.mute.vue @@ -0,0 +1,35 @@ +<template> +<div> + <div class="none ui info" v-if="!fetching && users.length == 0"> + <p>%fa:info-circle%%i18n:desktop.tags.mk-mute-setting.no-users%</p> + </div> + <div class="users" v-if="users.length != 0"> + <div v-for="user in users" :key="user.id"> + <p><b>{{ user.name }}</b> @{{ getAcct(user) }}</p> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + data() { + return { + fetching: true, + users: [] + }; + }, + methods: { + getAcct + }, + mounted() { + (this as any).api('mute/list').then(x => { + this.users = x.users; + this.fetching = false; + }); + } +}); +</script> diff --git a/src/client/app/desktop/views/components/settings.password.vue b/src/client/app/desktop/views/components/settings.password.vue new file mode 100644 index 0000000000..f883b54065 --- /dev/null +++ b/src/client/app/desktop/views/components/settings.password.vue @@ -0,0 +1,47 @@ +<template> +<div> + <button @click="reset" class="ui primary">%i18n:desktop.tags.mk-password-setting.reset%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + methods: { + reset() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-password-setting.enter-current-password%', + type: 'password' + }).then(currentPassword => { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-password-setting.enter-new-password%', + type: 'password' + }).then(newPassword => { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-password-setting.enter-new-password-again%', + type: 'password' + }).then(newPassword2 => { + if (newPassword !== newPassword2) { + (this as any).apis.dialog({ + title: null, + text: '%i18n:desktop.tags.mk-password-setting.not-match%', + actions: [{ + text: 'OK' + }] + }); + return; + } + (this as any).api('i/change_password', { + currentPasword: currentPassword, + newPassword: newPassword + }).then(() => { + (this as any).apis.notify('%i18n:desktop.tags.mk-password-setting.changed%'); + }); + }); + }); + }); + } + } +}); +</script> diff --git a/src/client/app/desktop/views/components/settings.profile.vue b/src/client/app/desktop/views/components/settings.profile.vue new file mode 100644 index 0000000000..ba86286f87 --- /dev/null +++ b/src/client/app/desktop/views/components/settings.profile.vue @@ -0,0 +1,87 @@ +<template> +<div class="profile"> + <label class="avatar ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.avatar%</p> + <img class="avatar" :src="`${os.i.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + <button class="ui" @click="updateAvatar">%i18n:desktop.tags.mk-profile-setting.choice-avatar%</button> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.name%</p> + <input v-model="name" type="text" class="ui"/> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.location%</p> + <input v-model="location" type="text" class="ui"/> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.description%</p> + <textarea v-model="description" class="ui"></textarea> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.birthday%</p> + <el-date-picker v-model="birthday" type="date" value-format="yyyy-MM-dd"/> + </label> + <button class="ui primary" @click="save">%i18n:desktop.tags.mk-profile-setting.save%</button> + <section> + <h2>その他</h2> + <mk-switch v-model="os.i.account.isBot" @change="onChangeIsBot" text="このアカウントはbotです"/> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + name: null, + location: null, + description: null, + birthday: null, + }; + }, + 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; + }, + methods: { + updateAvatar() { + (this as any).apis.updateAvatar(); + }, + save() { + (this as any).api('i/update', { + name: this.name, + location: this.location || null, + description: this.description || null, + birthday: this.birthday || null + }).then(() => { + (this as any).apis.notify('プロフィールを更新しました'); + }); + }, + onChangeIsBot() { + (this as any).api('i/update', { + isBot: (this as any).os.i.account.isBot + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.profile + > .avatar + > img + display inline-block + vertical-align top + width 64px + height 64px + border-radius 4px + + > button + margin-left 8px + +</style> + diff --git a/src/client/app/desktop/views/components/settings.signins.vue b/src/client/app/desktop/views/components/settings.signins.vue new file mode 100644 index 0000000000..a414c95c27 --- /dev/null +++ b/src/client/app/desktop/views/components/settings.signins.vue @@ -0,0 +1,98 @@ +<template> +<div class="root"> +<div class="signins" v-if="signins.length != 0"> + <div v-for="signin in signins"> + <header @click="signin._show = !signin._show"> + <template v-if="signin.success">%fa:check%</template> + <template v-else>%fa:times%</template> + <span class="ip">{{ signin.ip }}</span> + <mk-time :time="signin.createdAt"/> + </header> + <div class="headers" v-show="signin._show"> + <tree-view :data="signin.headers"/> + </div> + </div> +</div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + signins: [], + connection: null, + connectionId: null + }; + }, + mounted() { + (this as any).api('i/signin_history').then(signins => { + this.signins = signins; + this.fetching = false; + }); + + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('signin', this.onSignin); + }, + beforeDestroy() { + this.connection.off('signin', this.onSignin); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + onSignin(signin) { + this.signins.unshift(signin); + } + } +}); +</script> + +<style lang="stylus" scoped> +.root + > .signins + > div + border-bottom solid 1px #eee + + > header + display flex + padding 8px 0 + line-height 32px + cursor pointer + + > [data-fa] + margin-right 8px + text-align left + + &.check + color #0fda82 + + &.times + color #ff3100 + + > .ip + display inline-block + text-align left + padding 8px + line-height 16px + font-family monospace + font-size 14px + color #444 + background #f8f8f8 + border-radius 4px + + > .mk-time + margin-left auto + text-align right + color #777 + + > .headers + overflow auto + margin 0 0 16px 0 + max-height 100px + white-space pre-wrap + word-break break-all + +</style> diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue new file mode 100644 index 0000000000..fd82c171c1 --- /dev/null +++ b/src/client/app/desktop/views/components/settings.vue @@ -0,0 +1,419 @@ +<template> +<div class="mk-settings"> + <div class="nav"> + <p :class="{ active: page == 'profile' }" @mousedown="page = 'profile'">%fa:user .fw%%i18n:desktop.tags.mk-settings.profile%</p> + <p :class="{ active: page == 'web' }" @mousedown="page = 'web'">%fa:desktop .fw%Web</p> + <p :class="{ active: page == 'notification' }" @mousedown="page = 'notification'">%fa:R bell .fw%通知</p> + <p :class="{ active: page == 'drive' }" @mousedown="page = 'drive'">%fa:cloud .fw%%i18n:desktop.tags.mk-settings.drive%</p> + <p :class="{ active: page == 'mute' }" @mousedown="page = 'mute'">%fa:ban .fw%%i18n:desktop.tags.mk-settings.mute%</p> + <p :class="{ active: page == 'apps' }" @mousedown="page = 'apps'">%fa:puzzle-piece .fw%アプリ</p> + <p :class="{ active: page == 'twitter' }" @mousedown="page = 'twitter'">%fa:B twitter .fw%Twitter</p> + <p :class="{ active: page == 'security' }" @mousedown="page = 'security'">%fa:unlock-alt .fw%%i18n:desktop.tags.mk-settings.security%</p> + <p :class="{ active: page == 'api' }" @mousedown="page = 'api'">%fa:key .fw%API</p> + <p :class="{ active: page == 'other' }" @mousedown="page = 'other'">%fa:cogs .fw%%i18n:desktop.tags.mk-settings.other%</p> + </div> + <div class="pages"> + <section class="profile" v-show="page == 'profile'"> + <h1>%i18n:desktop.tags.mk-settings.profile%</h1> + <x-profile/> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>動作</h1> + <mk-switch v-model="os.i.account.clientSettings.fetchOnScroll" @change="onChangeFetchOnScroll" text="スクロールで自動読み込み"> + <span>ページを下までスクロールしたときに自動で追加のコンテンツを読み込みます。</span> + </mk-switch> + <mk-switch v-model="autoPopout" text="ウィンドウの自動ポップアウト"> + <span>ウィンドウが開かれるとき、ポップアウト(ブラウザ外に切り離す)可能なら自動でポップアウトします。この設定はブラウザに記憶されます。</span> + </mk-switch> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>デザインと表示</h1> + <div class="div"> + <button class="ui button" @click="customizeHome">ホームをカスタマイズ</button> + </div> + <mk-switch v-model="os.i.account.clientSettings.showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl" text="タイムライン上部に投稿フォームを表示する"/> + <mk-switch v-model="os.i.account.clientSettings.showMaps" @change="onChangeShowMaps" text="マップの自動展開"> + <span>位置情報が添付された投稿のマップを自動的に展開します。</span> + </mk-switch> + <mk-switch v-model="os.i.account.clientSettings.gradientWindowHeader" @change="onChangeGradientWindowHeader" text="ウィンドウのタイトルバーにグラデーションを使用"/> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>サウンド</h1> + <mk-switch v-model="enableSounds" text="サウンドを有効にする"> + <span>投稿やメッセージを送受信したときなどにサウンドを再生します。この設定はブラウザに記憶されます。</span> + </mk-switch> + <label>ボリューム</label> + <el-slider + v-model="soundVolume" + :show-input="true" + :format-tooltip="v => `${v}%`" + :disabled="!enableSounds" + /> + <button class="ui button" @click="soundTest">%fa:volume-up% テスト</button> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>モバイル</h1> + <mk-switch v-model="os.i.account.clientSettings.disableViaMobile" @change="onChangeDisableViaMobile" text="「モバイルからの投稿」フラグを付けない"/> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>言語</h1> + <el-select v-model="lang" placeholder="言語を選択"> + <el-option-group label="推奨"> + <el-option label="自動" value=""/> + </el-option-group> + <el-option-group label="言語を指定"> + <el-option label="ja-JP" value="ja"/> + <el-option label="en-US" value="en"/> + </el-option-group> + </el-select> + <div class="none ui info"> + <p>%fa:info-circle%変更はページの再度読み込み後に反映されます。</p> + </div> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>キャッシュ</h1> + <button class="ui button" @click="clean">クリーンアップ</button> + <div class="none ui info warn"> + <p>%fa:exclamation-triangle%クリーンアップを行うと、ブラウザに記憶されたアカウント情報のキャッシュ、書きかけの投稿・返信・メッセージ、およびその他のデータ(設定情報含む)が削除されます。クリーンアップを行った後はページを再度読み込みする必要があります。</p> + </div> + </section> + + <section class="notification" v-show="page == 'notification'"> + <h1>通知</h1> + <mk-switch v-model="os.i.account.settings.autoWatch" @change="onChangeAutoWatch" text="投稿の自動ウォッチ"> + <span>リアクションしたり返信したりした投稿に関する通知を自動的に受け取るようにします。</span> + </mk-switch> + </section> + + <section class="drive" v-show="page == 'drive'"> + <h1>%i18n:desktop.tags.mk-settings.drive%</h1> + <x-drive/> + </section> + + <section class="mute" v-show="page == 'mute'"> + <h1>%i18n:desktop.tags.mk-settings.mute%</h1> + <x-mute/> + </section> + + <section class="apps" v-show="page == 'apps'"> + <h1>アプリケーション</h1> + <x-apps/> + </section> + + <section class="twitter" v-show="page == 'twitter'"> + <h1>Twitter</h1> + <mk-twitter-setting/> + </section> + + <section class="password" v-show="page == 'security'"> + <h1>%i18n:desktop.tags.mk-settings.password%</h1> + <x-password/> + </section> + + <section class="2fa" v-show="page == 'security'"> + <h1>%i18n:desktop.tags.mk-settings.2fa%</h1> + <x-2fa/> + </section> + + <section class="signin" v-show="page == 'security'"> + <h1>サインイン履歴</h1> + <x-signins/> + </section> + + <section class="api" v-show="page == 'api'"> + <h1>API</h1> + <x-api/> + </section> + + <section class="other" v-show="page == 'other'"> + <h1>Misskeyについて</h1> + <p v-if="meta">このサーバーの運営者: <i><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></i></p> + </section> + + <section class="other" v-show="page == 'other'"> + <h1>Misskey Update</h1> + <p> + <span>バージョン: <i>{{ version }}</i></span> + <template v-if="latestVersion !== undefined"> + <br> + <span>最新のバージョン: <i>{{ latestVersion ? latestVersion : version }}</i></span> + </template> + </p> + <button class="ui button block" @click="checkForUpdate" :disabled="checkingForUpdate"> + <template v-if="checkingForUpdate">アップデートを確認中<mk-ellipsis/></template> + <template v-else>アップデートを確認</template> + </button> + <details> + <summary>詳細設定</summary> + <mk-switch v-model="preventUpdate" text="アップデートを延期する(非推奨)"> + <span>この設定をオンにしてもアップデートが反映される場合があります。この設定はこのデバイスのみ有効です。</span> + </mk-switch> + </details> + </section> + + <section class="other" v-show="page == 'other'"> + <h1>高度な設定</h1> + <mk-switch v-model="debug" text="デバッグモードを有効にする"> + <span>この設定はブラウザに記憶されます。</span> + </mk-switch> + <template v-if="debug"> + <mk-switch v-model="useRawScript" text="生のスクリプトを読み込む"> + <span>圧縮されていない「生の」スクリプトを使用します。サイズが大きいため、読み込みに時間がかかる場合があります。この設定はブラウザに記憶されます。</span> + </mk-switch> + <div class="none ui info"> + <p>%fa:info-circle%Misskeyはソースマップも提供しています。</p> + </div> + </template> + <mk-switch v-model="enableExperimental" text="実験的機能を有効にする"> + <span>実験的機能を有効にするとMisskeyの動作が不安定になる可能性があります。この設定はブラウザに記憶されます。</span> + </mk-switch> + <details v-if="debug"> + <summary>ツール</summary> + <button class="ui button block" @click="taskmngr">タスクマネージャ</button> + </details> + </section> + + <section class="other" v-show="page == 'other'"> + <h1>%i18n:desktop.tags.mk-settings.license%</h1> + <div v-html="license"></div> + <a :href="licenseUrl" target="_blank">サードパーティ</a> + </section> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XProfile from './settings.profile.vue'; +import XMute from './settings.mute.vue'; +import XPassword from './settings.password.vue'; +import X2fa from './settings.2fa.vue'; +import XApi from './settings.api.vue'; +import XApps from './settings.apps.vue'; +import XSignins from './settings.signins.vue'; +import XDrive from './settings.drive.vue'; +import { url, docsUrl, license, lang, version } from '../../../config'; +import checkForUpdate from '../../../common/scripts/check-for-update'; +import MkTaskManager from './taskmanager.vue'; + +export default Vue.extend({ + components: { + XProfile, + XMute, + XPassword, + X2fa, + XApi, + XApps, + XSignins, + XDrive + }, + data() { + return { + page: 'profile', + meta: null, + license, + version, + latestVersion: undefined, + checkingForUpdate: false, + enableSounds: localStorage.getItem('enableSounds') == 'true', + autoPopout: localStorage.getItem('autoPopout') == 'true', + soundVolume: localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) : 100, + lang: localStorage.getItem('lang') || '', + preventUpdate: localStorage.getItem('preventUpdate') == 'true', + debug: localStorage.getItem('debug') == 'true', + useRawScript: localStorage.getItem('useRawScript') == 'true', + enableExperimental: localStorage.getItem('enableExperimental') == 'true' + }; + }, + computed: { + licenseUrl(): string { + return `${docsUrl}/${lang}/license`; + } + }, + watch: { + autoPopout() { + localStorage.setItem('autoPopout', this.autoPopout ? 'true' : 'false'); + }, + enableSounds() { + localStorage.setItem('enableSounds', this.enableSounds ? 'true' : 'false'); + }, + soundVolume() { + localStorage.setItem('soundVolume', this.soundVolume.toString()); + }, + lang() { + localStorage.setItem('lang', this.lang); + }, + preventUpdate() { + localStorage.setItem('preventUpdate', this.preventUpdate ? 'true' : 'false'); + }, + debug() { + localStorage.setItem('debug', this.debug ? 'true' : 'false'); + }, + useRawScript() { + localStorage.setItem('useRawScript', this.useRawScript ? 'true' : 'false'); + }, + enableExperimental() { + localStorage.setItem('enableExperimental', this.enableExperimental ? 'true' : 'false'); + } + }, + created() { + (this as any).os.getMeta().then(meta => { + this.meta = meta; + }); + }, + methods: { + taskmngr() { + (this as any).os.new(MkTaskManager); + }, + customizeHome() { + this.$router.push('/i/customize-home'); + this.$emit('done'); + }, + onChangeFetchOnScroll(v) { + (this as any).api('i/update_client_setting', { + name: 'fetchOnScroll', + value: v + }); + }, + onChangeAutoWatch(v) { + (this as any).api('i/update', { + autoWatch: v + }); + }, + onChangeShowPostFormOnTopOfTl(v) { + (this as any).api('i/update_client_setting', { + name: 'showPostFormOnTopOfTl', + value: v + }); + }, + onChangeShowMaps(v) { + (this as any).api('i/update_client_setting', { + name: 'showMaps', + value: v + }); + }, + onChangeGradientWindowHeader(v) { + (this as any).api('i/update_client_setting', { + name: 'gradientWindowHeader', + value: v + }); + }, + onChangeDisableViaMobile(v) { + (this as any).api('i/update_client_setting', { + name: 'disableViaMobile', + value: v + }); + }, + checkForUpdate() { + this.checkingForUpdate = true; + checkForUpdate((this as any).os, true, true).then(newer => { + this.checkingForUpdate = false; + this.latestVersion = newer; + if (newer == null) { + (this as any).apis.dialog({ + title: '利用可能な更新はありません', + text: 'お使いのMisskeyは最新です。' + }); + } else { + (this as any).apis.dialog({ + title: '新しいバージョンが利用可能です', + text: 'ページを再度読み込みすると更新が適用されます。' + }); + } + }); + }, + clean() { + localStorage.clear(); + (this as any).apis.dialog({ + title: 'キャッシュを削除しました', + text: 'ページを再度読み込みしてください。' + }); + }, + soundTest() { + const sound = new Audio(`${url}/assets/message.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-settings + display flex + width 100% + height 100% + + > .nav + flex 0 0 200px + width 100% + height 100% + padding 16px 0 0 0 + overflow auto + border-right solid 1px #ddd + + > p + display block + padding 10px 16px + margin 0 + color #666 + cursor pointer + user-select none + transition margin-left 0.2s ease + + > [data-fa] + margin-right 4px + + &:hover + color #555 + + &.active + margin-left 8px + color $theme-color !important + + > .pages + width 100% + height 100% + flex auto + overflow auto + + > section + margin 32px + color #4a535a + + > h1 + margin 0 0 1em 0 + padding 0 0 8px 0 + font-size 1em + color #555 + border-bottom solid 1px #eee + + &, >>> * + .ui.button.block + margin 16px 0 + + > section + margin 32px 0 + + > h2 + margin 0 0 1em 0 + padding 0 0 8px 0 + font-size 1em + color #555 + border-bottom solid 1px #eee + + > .web + > .div + border-bottom solid 1px #eee + padding 0 0 16px 0 + margin 0 0 16px 0 + +</style> diff --git a/src/client/app/desktop/views/components/sub-post-content.vue b/src/client/app/desktop/views/components/sub-post-content.vue new file mode 100644 index 0000000000..f13822331b --- /dev/null +++ b/src/client/app/desktop/views/components/sub-post-content.vue @@ -0,0 +1,56 @@ +<template> +<div class="mk-sub-post-content"> + <div class="body"> + <a class="reply" v-if="post.replyId">%fa:reply%</a> + <mk-post-html :ast="post.ast" :i="os.i"/> + <a class="rp" v-if="post.repostId" :href="`/post:${post.repostId}`">RP: ...</a> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + </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>投票</summary> + <mk-poll :post="post"/> + </details> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['post'], + computed: { + urls(): string[] { + if (this.post.ast) { + return this.post.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + } +}); +</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/desktop/views/components/taskmanager.vue b/src/client/app/desktop/views/components/taskmanager.vue new file mode 100644 index 0000000000..a00fabb047 --- /dev/null +++ b/src/client/app/desktop/views/components/taskmanager.vue @@ -0,0 +1,219 @@ +<template> +<mk-window ref="window" width="750px" height="500px" @closed="$destroy" name="TaskManager"> + <span slot="header" :class="$style.header">%fa:stethoscope%タスクマネージャ</span> + <el-tabs :class="$style.content"> + <el-tab-pane label="Requests"> + <el-table + :data="os.requests" + style="width: 100%" + :default-sort="{prop: 'date', order: 'descending'}" + > + <el-table-column type="expand"> + <template slot-scope="props"> + <pre>{{ props.row.data }}</pre> + <pre>{{ props.row.res }}</pre> + </template> + </el-table-column> + + <el-table-column + label="Requested at" + prop="date" + sortable + > + <template slot-scope="scope"> + <b style="margin-right: 8px">{{ scope.row.date.getTime() }}</b> + <span>(<mk-time :time="scope.row.date"/>)</span> + </template> + </el-table-column> + + <el-table-column + label="Name" + > + <template slot-scope="scope"> + <b>{{ scope.row.name }}</b> + </template> + </el-table-column> + + <el-table-column + label="Status" + > + <template slot-scope="scope"> + <span>{{ scope.row.status || '(pending)' }}</span> + </template> + </el-table-column> + </el-table> + </el-tab-pane> + + <el-tab-pane label="Streams"> + <el-table + :data="os.connections" + style="width: 100%" + > + <el-table-column + label="Uptime" + > + <template slot-scope="scope"> + <mk-timer v-if="scope.row.connectedAt" :time="scope.row.connectedAt"/> + <span v-else>-</span> + </template> + </el-table-column> + + <el-table-column + label="Name" + > + <template slot-scope="scope"> + <b>{{ scope.row.name == '' ? '[Home]' : scope.row.name }}</b> + </template> + </el-table-column> + + <el-table-column + label="User" + > + <template slot-scope="scope"> + <span>{{ scope.row.user || '(anonymous)' }}</span> + </template> + </el-table-column> + + <el-table-column + prop="state" + label="State" + /> + + <el-table-column + prop="in" + label="In" + /> + + <el-table-column + prop="out" + label="Out" + /> + </el-table> + </el-tab-pane> + + <el-tab-pane label="Streams (Inspect)"> + <el-tabs type="card" style="height:50%"> + <el-tab-pane v-for="c in os.connections" :label="c.name == '' ? '[Home]' : c.name" :key="c.id" :name="c.id" ref="connectionsTab"> + <div style="padding: 12px 0 0 12px"> + <el-button size="mini" @click="send(c)">Send</el-button> + <el-button size="mini" type="warning" @click="c.isSuspended = true" v-if="!c.isSuspended">Suspend</el-button> + <el-button size="mini" type="success" @click="c.isSuspended = false" v-else>Resume</el-button> + <el-button size="mini" type="danger" @click="c.close">Disconnect</el-button> + </div> + + <el-table + :data="c.inout" + style="width: 100%" + :default-sort="{prop: 'at', order: 'descending'}" + > + <el-table-column type="expand"> + <template slot-scope="props"> + <pre>{{ props.row.data }}</pre> + </template> + </el-table-column> + + <el-table-column + label="Date" + prop="at" + sortable + > + <template slot-scope="scope"> + <b style="margin-right: 8px">{{ scope.row.at.getTime() }}</b> + <span>(<mk-time :time="scope.row.at"/>)</span> + </template> + </el-table-column> + + <el-table-column + label="Type" + > + <template slot-scope="scope"> + <span>{{ getMessageType(scope.row.data) }}</span> + </template> + </el-table-column> + + <el-table-column + label="Incoming / Outgoing" + prop="type" + /> + </el-table> + </el-tab-pane> + </el-tabs> + </el-tab-pane> + + <el-tab-pane label="Windows"> + <el-table + :data="Array.from(os.windows.windows)" + style="width: 100%" + > + <el-table-column + label="Name" + > + <template slot-scope="scope"> + <b>{{ scope.row.name || '(unknown)' }}</b> + </template> + </el-table-column> + + <el-table-column + label="Operations" + > + <template slot-scope="scope"> + <el-button size="mini" type="danger" @click="scope.row.close">Close</el-button> + </template> + </el-table-column> + </el-table> + </el-tab-pane> + </el-tabs> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + mounted() { + (this as any).os.windows.on('added', this.onWindowsChanged); + (this as any).os.windows.on('removed', this.onWindowsChanged); + }, + beforeDestroy() { + (this as any).os.windows.off('added', this.onWindowsChanged); + (this as any).os.windows.off('removed', this.onWindowsChanged); + }, + methods: { + getMessageType(data): string { + return data.type ? data.type : '-'; + }, + onWindowsChanged() { + this.$forceUpdate(); + }, + send(c) { + (this as any).apis.input({ + title: 'Send a JSON message', + allowEmpty: false + }).then(json => { + c.send(JSON.parse(json)); + }); + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +.content + height 100% + overflow auto + +</style> + +<style> +.el-tabs__header { + margin-bottom: 0 !important; +} + +.el-tabs__item { + padding: 0 20px !important; +} +</style> diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue new file mode 100644 index 0000000000..65b4bd1c7a --- /dev/null +++ b/src/client/app/desktop/views/components/timeline.vue @@ -0,0 +1,156 @@ +<template> +<div class="mk-timeline"> + <mk-friends-maker v-if="alone"/> + <div class="fetching" v-if="fetching"> + <mk-ellipsis-icon/> + </div> + <p class="empty" v-if="posts.length == 0 && !fetching"> + %fa:R comments%自分の投稿や、自分がフォローしているユーザーの投稿が表示されます。 + </p> + <mk-posts :posts="posts" ref="timeline"> + <button slot="footer" @click="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">もっと見る</template> + <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> + </button> + </mk-posts> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url } from '../../../config'; + +export default Vue.extend({ + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + posts: [], + connection: null, + connectionId: null, + date: 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); + + document.addEventListener('keydown', this.onKeydown); + window.addEventListener('scroll', this.onScroll); + + 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); + + document.removeEventListener('keydown', this.onKeydown); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + fetch(cb?) { + this.fetching = true; + + (this as any).api('posts/timeline', { + limit: 11, + untilDate: this.date ? this.date.getTime() : undefined + }).then(posts => { + if (posts.length == 11) { + posts.pop(); + this.existMore = true; + } + this.posts = posts; + this.fetching = false; + this.$emit('loaded'); + if (cb) cb(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0 || !this.existMore) return; + this.moreFetching = true; + (this as any).api('posts/timeline', { + limit: 11, + untilId: this.posts[this.posts.length - 1].id + }).then(posts => { + if (posts.length == 11) { + posts.pop(); + } else { + this.existMore = false; + } + this.posts = this.posts.concat(posts); + this.moreFetching = false; + }); + }, + onPost(post) { + // サウンドを再生する + if ((this as any).os.isEnableSounds) { + const sound = new Audio(`${url}/assets/post.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + + this.posts.unshift(post); + }, + onChangeFollowing() { + this.fetch(); + }, + onScroll() { + if ((this as any).os.i.account.clientSettings.fetchOnScroll !== false) { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 8) this.more(); + } + }, + onKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 84) { // t + (this.$refs.timeline as any).focus(); + } + } + }, + warp(date) { + this.date = date; + this.fetch(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-timeline + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .mk-friends-maker + border-bottom solid 1px #eee + + > .fetching + padding 64px 0 + + > .empty + display block + 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 + +</style> diff --git a/src/client/app/desktop/views/components/ui-notification.vue b/src/client/app/desktop/views/components/ui-notification.vue new file mode 100644 index 0000000000..9983f02c5e --- /dev/null +++ b/src/client/app/desktop/views/components/ui-notification.vue @@ -0,0 +1,61 @@ +<template> +<div class="mk-ui-notification"> + <p>{{ message }}</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['message'], + mounted() { + this.$nextTick(() => { + anime({ + targets: this.$el, + opacity: 1, + translateY: [-64, 0], + easing: 'easeOutElastic', + duration: 500 + }); + + setTimeout(() => { + anime({ + targets: this.$el, + opacity: 0, + translateY: -64, + duration: 500, + easing: 'easeInElastic', + complete: () => this.$destroy() + }); + }, 6000); + }); + } +}); +</script> + +<style lang="stylus" scoped> +.mk-ui-notification + display block + position fixed + z-index 10000 + top -128px + left 0 + right 0 + margin 0 auto + padding 128px 0 0 0 + width 500px + color rgba(#000, 0.6) + background rgba(#fff, 0.9) + border-radius 0 0 8px 8px + box-shadow 0 2px 4px rgba(#000, 0.2) + transform translateY(-64px) + opacity 0 + + > p + margin 0 + line-height 64px + text-align center + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.account.vue b/src/client/app/desktop/views/components/ui.header.account.vue new file mode 100644 index 0000000000..ec4635f338 --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.account.vue @@ -0,0 +1,225 @@ +<template> +<div class="account"> + <button class="header" :data-active="isOpen" @click="toggle"> + <span class="username">{{ os.i.username }}<template v-if="!isOpen">%fa:angle-down%</template><template v-if="isOpen">%fa:angle-up%</template></span> + <img class="avatar" :src="`${ os.i.avatarUrl }?thumbnail&size=64`" alt="avatar"/> + </button> + <transition name="zoom-in-top"> + <div class="menu" v-if="isOpen"> + <ul> + <li> + <router-link :to="`/@${ os.i.username }`">%fa:user%%i18n:desktop.tags.mk-ui-header-account.profile%%fa:angle-right%</router-link> + </li> + <li @click="drive"> + <p>%fa:cloud%%i18n:desktop.tags.mk-ui-header-account.drive%%fa:angle-right%</p> + </li> + <li> + <a href="/i/mentions">%fa:at%%i18n:desktop.tags.mk-ui-header-account.mentions%%fa:angle-right%</a> + </li> + </ul> + <ul> + <li @click="settings"> + <p>%fa:cog%%i18n:desktop.tags.mk-ui-header-account.settings%%fa:angle-right%</p> + </li> + </ul> + <ul> + <li @click="signout"> + <p>%fa:power-off%%i18n:desktop.tags.mk-ui-header-account.signout%%fa:angle-right%</p> + </li> + </ul> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkSettingsWindow from './settings-window.vue'; +import MkDriveWindow from './drive-window.vue'; +import contains from '../../../common/scripts/contains'; + +export default Vue.extend({ + data() { + return { + isOpen: false + }; + }, + beforeDestroy() { + this.close(); + }, + methods: { + toggle() { + this.isOpen ? this.close() : this.open(); + }, + open() { + this.isOpen = true; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + }, + close() { + this.isOpen = false; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + }, + onMousedown(e) { + e.preventDefault(); + if (!contains(this.$el, e.target) && this.$el != e.target) this.close(); + return false; + }, + drive() { + this.close(); + (this as any).os.new(MkDriveWindow); + }, + settings() { + this.close(); + (this as any).os.new(MkSettingsWindow); + }, + signout() { + (this as any).os.signout(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.account + > .header + display block + margin 0 + padding 0 + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + &[data-active='true'] + color darken(#9eaba8, 20%) + + > .avatar + filter saturate(150%) + + &:active + color darken(#9eaba8, 30%) + + > .username + display block + float left + margin 0 12px 0 16px + max-width 16em + line-height 48px + font-weight bold + font-family Meiryo, sans-serif + text-decoration none + + [data-fa] + margin-left 8px + + > .avatar + display block + float left + min-width 32px + max-width 32px + min-height 32px + max-height 32px + margin 8px 8px 8px 0 + border-radius 4px + transition filter 100ms ease + + > .menu + display block + position absolute + top 56px + right -2px + width 230px + font-size 0.8em + background #fff + border-radius 4px + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + + &:before + content "" + pointer-events none + display block + position absolute + top -28px + right 12px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-left solid 14px transparent + + &:after + content "" + pointer-events none + display block + position absolute + top -27px + right 12px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px #fff + border-left solid 14px transparent + + ul + display block + margin 10px 0 + padding 0 + list-style none + + & + ul + padding-top 10px + border-top solid 1px #eee + + > li + display block + margin 0 + padding 0 + + > a + > p + display block + z-index 1 + padding 0 28px + margin 0 + line-height 40px + color #868C8C + cursor pointer + + * + pointer-events none + + > [data-fa]:first-of-type + margin-right 6px + + > [data-fa]:last-of-type + display block + position absolute + top 0 + right 8px + z-index 1 + padding 0 20px + font-size 1.2em + line-height 40px + + &:hover, &:active + text-decoration none + background $theme-color + color $theme-color-foreground + + &:active + background darken($theme-color, 10%) + +.zoom-in-top-enter-active, +.zoom-in-top-leave-active { + transform-origin: center -16px; +} + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.clock.vue b/src/client/app/desktop/views/components/ui.header.clock.vue new file mode 100644 index 0000000000..cd23a67506 --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.clock.vue @@ -0,0 +1,109 @@ +<template> +<div class="clock"> + <div class="header"> + <time ref="time"> + <span class="yyyymmdd">{{ yyyy }}/{{ mm }}/{{ dd }}</span> + <br> + <span class="hhnn">{{ hh }}<span :style="{ visibility: now.getSeconds() % 2 == 0 ? 'visible' : 'hidden' }">:</span>{{ nn }}</span> + </time> + </div> + <div class="content"> + <mk-analog-clock/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + now: new Date(), + clock: null + }; + }, + computed: { + yyyy(): number { + return this.now.getFullYear(); + }, + mm(): string { + return ('0' + (this.now.getMonth() + 1)).slice(-2); + }, + dd(): string { + return ('0' + this.now.getDate()).slice(-2); + }, + hh(): string { + return ('0' + this.now.getHours()).slice(-2); + }, + nn(): string { + return ('0' + this.now.getMinutes()).slice(-2); + } + }, + mounted() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + tick() { + this.now = new Date(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.clock + display inline-block + overflow visible + + > .header + padding 0 12px + text-align center + font-size 10px + + &, * + cursor: default + + &:hover + background #899492 + + & + .content + visibility visible + + > time + color #fff !important + + * + color #fff !important + + &:after + content "" + display block + clear both + + > time + display table-cell + vertical-align middle + height 48px + color #9eaba8 + + > .yyyymmdd + opacity 0.7 + + > .content + visibility hidden + display block + position absolute + top auto + right 0 + z-index 3 + margin 0 + padding 0 + width 256px + background #899492 + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.nav.vue b/src/client/app/desktop/views/components/ui.header.nav.vue new file mode 100644 index 0000000000..7582e8afce --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.nav.vue @@ -0,0 +1,175 @@ +<template> +<div class="nav"> + <ul> + <template v-if="os.isSignedIn"> + <li class="home" :class="{ active: $route.name == 'index' }"> + <router-link to="/"> + %fa:home% + <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p> + </router-link> + </li> + <li class="messaging"> + <a @click="messaging"> + %fa:comments% + <p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p> + <template v-if="hasUnreadMessagingMessages">%fa:circle%</template> + </a> + </li> + <li class="game"> + <a @click="game"> + %fa:gamepad% + <p>ゲーム</p> + <template v-if="hasGameInvitations">%fa:circle%</template> + </a> + </li> + </template> + <li class="ch"> + <a :href="chUrl" target="_blank"> + %fa:tv% + <p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p> + </a> + </li> + </ul> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { chUrl } from '../../../config'; +import MkMessagingWindow from './messaging-window.vue'; +import MkGameWindow from './game-window.vue'; + +export default Vue.extend({ + data() { + return { + hasUnreadMessagingMessages: false, + hasGameInvitations: false, + connection: null, + connectionId: null, + 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_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 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_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: { + onUnreadMessagingMessage() { + this.hasUnreadMessagingMessages = true; + }, + + onReadAllMessagingMessages() { + this.hasUnreadMessagingMessages = false; + }, + + onOthelloInvited() { + this.hasGameInvitations = true; + }, + + onOthelloNoInvites() { + this.hasGameInvitations = false; + }, + + messaging() { + (this as any).os.new(MkMessagingWindow); + }, + + game() { + (this as any).os.new(MkGameWindow); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.nav + display inline-block + margin 0 + padding 0 + line-height 3rem + vertical-align top + + > ul + display inline-block + margin 0 + padding 0 + vertical-align top + line-height 3rem + list-style none + + > li + display inline-block + vertical-align top + height 48px + line-height 48px + + &.active + > a + border-bottom solid 3px $theme-color + + > a + display inline-block + z-index 1 + height 100% + padding 0 24px + font-size 13px + font-variant small-caps + color #9eaba8 + text-decoration none + transition none + cursor pointer + + * + pointer-events none + + &:hover + color darken(#9eaba8, 20%) + text-decoration none + + > [data-fa]:first-child + margin-right 8px + + > [data-fa]:last-child + margin-left 5px + font-size 10px + color $theme-color + + @media (max-width 1100px) + margin-left -5px + + > p + display inline + margin 0 + + @media (max-width 1100px) + display none + + @media (max-width 700px) + padding 0 12px + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.notifications.vue b/src/client/app/desktop/views/components/ui.header.notifications.vue new file mode 100644 index 0000000000..e829418d18 --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.notifications.vue @@ -0,0 +1,158 @@ +<template> +<div class="notifications"> + <button :data-active="isOpen" @click="toggle" title="%i18n:desktop.tags.mk-ui-header-notifications.title%"> + %fa:R bell%<template v-if="hasUnreadNotifications">%fa:circle%</template> + </button> + <div class="pop" v-if="isOpen"> + <mk-notifications/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import contains from '../../../common/scripts/contains'; + +export default Vue.extend({ + data() { + return { + isOpen: false, + hasUnreadNotifications: 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); + + // Fetch count of unread notifications + (this as any).api('notifications/get_unread_count').then(res => { + if (res.count > 0) { + this.hasUnreadNotifications = true; + } + }); + } + }, + beforeDestroy() { + if ((this as any).os.isSignedIn) { + this.connection.off('read_all_notifications', this.onReadAllNotifications); + this.connection.off('unread_notification', this.onUnreadNotification); + (this as any).os.stream.dispose(this.connectionId); + } + }, + methods: { + onReadAllNotifications() { + this.hasUnreadNotifications = false; + }, + + onUnreadNotification() { + this.hasUnreadNotifications = true; + }, + + toggle() { + this.isOpen ? this.close() : this.open(); + }, + + open() { + this.isOpen = true; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + }, + + close() { + this.isOpen = false; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + }, + + onMousedown(e) { + e.preventDefault(); + if (!contains(this.$el, e.target) && this.$el != e.target) this.close(); + return false; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.notifications + + > button + display block + margin 0 + padding 0 + width 32px + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + &[data-active='true'] + color darken(#9eaba8, 20%) + + &:active + color darken(#9eaba8, 30%) + + > [data-fa].bell + font-size 1.2em + line-height 48px + + > [data-fa].circle + margin-left -5px + vertical-align super + font-size 10px + color $theme-color + + > .pop + display block + position absolute + top 56px + right -72px + width 300px + background #fff + border-radius 4px + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + + &:before + content "" + pointer-events none + display block + position absolute + top -28px + right 74px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-left solid 14px transparent + + &:after + content "" + pointer-events none + display block + position absolute + top -27px + right 74px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px #fff + border-left solid 14px transparent + + > .mk-notifications + max-height 350px + font-size 1rem + overflow auto + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.post.vue b/src/client/app/desktop/views/components/ui.header.post.vue new file mode 100644 index 0000000000..c2f0e07dd3 --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.post.vue @@ -0,0 +1,54 @@ +<template> +<div class="post"> + <button @click="post" title="%i18n:desktop.tags.mk-ui-header-post-button.post%">%fa:pencil-alt%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + methods: { + post() { + (this as any).apis.post(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.post + display inline-block + padding 8px + height 100% + vertical-align top + + > button + display inline-block + margin 0 + padding 0 10px + height 100% + font-size 1.2em + font-weight normal + text-decoration none + color $theme-color-foreground + background $theme-color !important + outline none + border none + border-radius 4px + transition background 0.1s ease + cursor pointer + + * + pointer-events none + + &:hover + background lighten($theme-color, 10%) !important + + &:active + background darken($theme-color, 10%) !important + transition background 0s ease + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.search.vue b/src/client/app/desktop/views/components/ui.header.search.vue new file mode 100644 index 0000000000..86215556ad --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.search.vue @@ -0,0 +1,70 @@ +<template> +<form class="search" @submit.prevent="onSubmit"> + %fa:search% + <input v-model="q" type="search" placeholder="%i18n:desktop.tags.mk-ui-header-search.placeholder%"/> + <div class="result"></div> +</form> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + q: '' + }; + }, + methods: { + onSubmit() { + location.href = `/search?q=${encodeURIComponent(this.q)}`; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.search + + > [data-fa] + display block + position absolute + top 0 + left 0 + width 48px + text-align center + line-height 48px + color #9eaba8 + pointer-events none + + > * + vertical-align middle + + > input + user-select text + cursor auto + margin 8px 0 0 0 + padding 6px 18px 6px 36px + width 14em + height 32px + font-size 1em + background rgba(0, 0, 0, 0.05) + outline none + //border solid 1px #ddd + border none + border-radius 16px + transition color 0.5s ease, border 0.5s ease + font-family FontAwesome, sans-serif + + &::placeholder + color #9eaba8 + + &:hover + background rgba(0, 0, 0, 0.08) + + &:focus + box-shadow 0 0 0 2px rgba($theme-color, 0.5) !important + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.vue b/src/client/app/desktop/views/components/ui.header.vue new file mode 100644 index 0000000000..7e337d2ae5 --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.vue @@ -0,0 +1,172 @@ +<template> +<div class="header"> + <mk-special-message/> + <div class="main" ref="main"> + <div class="backdrop"></div> + <div class="main"> + <p ref="welcomeback" v-if="os.isSignedIn">おかえりなさい、<b>{{ os.i.name }}</b>さん</p> + <div class="container" ref="mainContainer"> + <div class="left"> + <x-nav/> + </div> + <div class="right"> + <x-search/> + <x-account v-if="os.isSignedIn"/> + <x-notifications v-if="os.isSignedIn"/> + <x-post v-if="os.isSignedIn"/> + <x-clock/> + </div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +import XNav from './ui.header.nav.vue'; +import XSearch from './ui.header.search.vue'; +import XAccount from './ui.header.account.vue'; +import XNotifications from './ui.header.notifications.vue'; +import XPost from './ui.header.post.vue'; +import XClock from './ui.header.clock.vue'; + +export default Vue.extend({ + components: { + XNav, + XSearch, + XAccount, + XNotifications, + XPost, + XClock, + }, + mounted() { + if ((this as any).os.isSignedIn) { + 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); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.header + position -webkit-sticky + position sticky + top 0 + z-index 1000 + width 100% + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + > .main + height 48px + + > .backdrop + position absolute + top 0 + z-index 1000 + width 100% + height 48px + background #f7f7f7 + + > .main + z-index 1001 + margin 0 + padding 0 + background-clip content-box + font-size 0.9rem + user-select none + + > p + display none + position absolute + top 48px + width 100% + line-height 48px + margin 0 + text-align center + color #888 + opacity 0 + + > .container + display flex + width 100% + max-width 1300px + margin 0 auto + + &:before + content "" + position absolute + top 0 + left 0 + display block + width 100% + height 48px + background-image url(/assets/desktop/header-logo.svg) + background-size 46px + background-position center + background-repeat no-repeat + opacity 0.3 + + > .left + margin 0 auto 0 0 + height 48px + + > .right + margin 0 0 0 auto + height 48px + + > * + display inline-block + vertical-align top + + @media (max-width 1100px) + > .mk-ui-header-search + display none + +</style> diff --git a/src/client/app/desktop/views/components/ui.vue b/src/client/app/desktop/views/components/ui.vue new file mode 100644 index 0000000000..87f932ff14 --- /dev/null +++ b/src/client/app/desktop/views/components/ui.vue @@ -0,0 +1,37 @@ +<template> +<div> + <x-header/> + <div class="content"> + <slot></slot> + </div> + <mk-stream-indicator v-if="os.isSignedIn"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XHeader from './ui.header.vue'; + +export default Vue.extend({ + components: { + XHeader + }, + mounted() { + document.addEventListener('keydown', this.onKeydown); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onKeydown); + }, + methods: { + onKeydown(e) { + if (e.target.tagName == 'INPUT' || e.target.tagName == 'TEXTAREA') return; + + if (e.which == 80 || e.which == 78) { // p or n + e.preventDefault(); + (this as any).apis.post(); + } + } + } +}); +</script> + diff --git a/src/client/app/desktop/views/components/user-preview.vue b/src/client/app/desktop/views/components/user-preview.vue new file mode 100644 index 0000000000..8c86b2efe8 --- /dev/null +++ b/src/client/app/desktop/views/components/user-preview.vue @@ -0,0 +1,173 @@ +<template> +<div class="mk-user-preview"> + <template v-if="u != null"> + <div class="banner" :style="u.bannerUrl ? `background-image: url(${u.bannerUrl}?thumbnail&size=512)` : ''"></div> + <router-link class="avatar" :to="`/@${acct}`"> + <img :src="`${u.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="title"> + <router-link class="name" :to="`/@${acct}`">{{ u.name }}</router-link> + <p class="username">@{{ acct }}</p> + </div> + <div class="description">{{ u.description }}</div> + <div class="status"> + <div> + <p>投稿</p><a>{{ u.postsCount }}</a> + </div> + <div> + <p>フォロー</p><a>{{ u.followingCount }}</a> + </div> + <div> + <p>フォロワー</p><a>{{ u.followersCount }}</a> + </div> + </div> + <mk-follow-button v-if="os.isSignedIn && user.id != os.i.id" :user="u"/> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; +import getAcct from '../../../../../common/user/get-acct'; +import parseAcct from '../../../../../common/user/parse-acct'; + +export default Vue.extend({ + props: { + user: { + type: [Object, String], + required: true + } + }, + computed: { + acct() { + return getAcct(this.u); + } + }, + data() { + return { + u: null + }; + }, + mounted() { + if (typeof this.user == 'object') { + this.u = this.user; + this.$nextTick(() => { + this.open(); + }); + } else { + const query = this.user[0] == '@' ? + parseAcct(this.user[0].substr(1)) : + { userId: this.user[0] }; + + (this as any).api('users/show', query).then(user => { + this.u = user; + this.open(); + }); + } + }, + methods: { + open() { + anime({ + targets: this.$el, + opacity: 1, + 'margin-top': 0, + duration: 200, + easing: 'easeOutQuad' + }); + }, + close() { + anime({ + targets: this.$el, + opacity: 0, + 'margin-top': '-8px', + duration: 200, + easing: 'easeOutQuad', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-user-preview + position absolute + z-index 2048 + margin-top -8px + width 250px + background #fff + background-clip content-box + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + overflow hidden + opacity 0 + + > .banner + height 84px + background-color #f5f5f5 + background-size cover + background-position center + + > .avatar + display block + position absolute + top 62px + left 13px + z-index 2 + + > img + display block + width 58px + height 58px + margin 0 + border solid 3px #fff + border-radius 8px + + > .title + display block + padding 8px 0 8px 82px + + > .name + display inline-block + margin 0 + font-weight bold + line-height 16px + color #656565 + + > .username + display block + margin 0 + line-height 16px + font-size 0.8em + color #999 + + > .description + padding 0 16px + font-size 0.7em + color #555 + + > .status + padding 8px 16px + + > div + display inline-block + width 33% + + > p + margin 0 + font-size 0.7em + color #aaa + + > a + font-size 1em + color $theme-color + + > .mk-follow-button + position absolute + top 92px + right 8px + +</style> diff --git a/src/client/app/desktop/views/components/users-list.item.vue b/src/client/app/desktop/views/components/users-list.item.vue new file mode 100644 index 0000000000..d2bfc117da --- /dev/null +++ b/src/client/app/desktop/views/components/users-list.item.vue @@ -0,0 +1,107 @@ +<template> +<div class="root item"> + <router-link class="avatar-anchor" :to="`/@${acct}`" v-user-preview="user.id"> + <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="user.id">{{ user.name }}</router-link> + <span class="username">@{{ acct }}</span> + </header> + <div class="body"> + <p class="followed" v-if="user.isFollowed">フォローされています</p> + <div class="description">{{ user.description }}</div> + </div> + </div> + <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> +.root.item + padding 16px + font-size 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 58px + height 58px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 74px) + + > header + 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 + > .followed + display inline-block + margin 0 0 4px 0 + padding 2px 8px + vertical-align top + font-size 10px + color #71afc7 + background #eefaff + border-radius 4px + + > .description + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.1em + color #717171 + + > .mk-follow-button + position absolute + top 16px + right 16px + +</style> diff --git a/src/client/app/desktop/views/components/users-list.vue b/src/client/app/desktop/views/components/users-list.vue new file mode 100644 index 0000000000..a08e76f573 --- /dev/null +++ b/src/client/app/desktop/views/components/users-list.vue @@ -0,0 +1,143 @@ +<template> +<div class="mk-users-list"> + <nav> + <div> + <span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて<span>{{ count }}</span></span> + <span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">知り合い<span>{{ youKnowCount }}</span></span> + </div> + </nav> + <div class="users" v-if="!fetching && users.length != 0"> + <div v-for="u in users" :key="u.id"> + <x-item :user="u"/> + </div> + </div> + <button class="more" v-if="!fetching && next != null" @click="more" :disabled="moreFetching"> + <span v-if="!moreFetching">もっと</span> + <span v-if="moreFetching">読み込み中<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%読み込んでいます<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XItem from './users-list.item.vue'; + +export default Vue.extend({ + components: { + XItem + }, + props: ['fetch', 'count', 'youKnowCount'], + data() { + return { + limit: 30, + mode: 'all', + fetching: true, + moreFetching: false, + users: [], + next: null + }; + }, + 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 + height 100% + background #fff + + > nav + z-index 1 + box-shadow 0 1px 0 rgba(#000, 0.1) + + > div + display flex + justify-content center + margin 0 auto + max-width 600px + + > span + display block + flex 1 1 + text-align center + line-height 52px + font-size 14px + color #657786 + border-bottom solid 2px transparent + cursor pointer + + * + pointer-events none + + &[data-is-active] + font-weight bold + color $theme-color + border-color $theme-color + cursor default + + > span + display inline-block + margin-left 4px + padding 2px 5px + font-size 12px + line-height 1 + color #888 + background #eee + border-radius 20px + + > .users + height calc(100% - 54px) + overflow auto + + > * + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + > * + max-width 600px + margin 0 auto + + > .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/desktop/views/components/widget-container.vue b/src/client/app/desktop/views/components/widget-container.vue new file mode 100644 index 0000000000..68c5bcb8dc --- /dev/null +++ b/src/client/app/desktop/views/components/widget-container.vue @@ -0,0 +1,85 @@ +<template> +<div class="mk-widget-container" :class="{ naked }"> + <header :class="{ withGradient }" 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 + } + }, + computed: { + withGradient(): boolean { + return (this as any).os.isSignedIn + ? (this as any).os.i.account.clientSettings.gradientWindowHeader != null + ? (this as any).os.i.account.clientSettings.gradientWindowHeader + : false + : false; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-widget-container + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + + &.naked + background transparent !important + border none !important + + > header + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + &:empty + display none + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + &.withGradient + > .title + background linear-gradient(to bottom, #fff, #ececec) + box-shadow 0 1px rgba(#000, 0.11) +</style> diff --git a/src/client/app/desktop/views/components/window.vue b/src/client/app/desktop/views/components/window.vue new file mode 100644 index 0000000000..48dc46febd --- /dev/null +++ b/src/client/app/desktop/views/components/window.vue @@ -0,0 +1,635 @@ +<template> +<div class="mk-window" :data-flexible="isFlexible" @dragover="onDragover"> + <div class="bg" ref="bg" v-show="isModal" @click="onBgClick"></div> + <div class="main" ref="main" tabindex="-1" :data-is-modal="isModal" @mousedown="onBodyMousedown" @keydown="onKeydown" :style="{ width, height }"> + <div class="body"> + <header ref="header" + :class="{ withGradient }" + @contextmenu.prevent="() => {}" @mousedown.prevent="onHeaderMousedown" + > + <h1><slot name="header"></slot></h1> + <div> + <button class="popout" v-if="popoutUrl" @mousedown.stop="() => {}" @click="popout" title="ポップアウト">%fa:R window-restore%</button> + <button class="close" v-if="canClose" @mousedown.stop="() => {}" @click="close" title="閉じる">%fa:times%</button> + </div> + </header> + <div class="content"> + <slot></slot> + </div> + </div> + <div class="handle top" v-if="canResize" @mousedown.prevent="onTopHandleMousedown"></div> + <div class="handle right" v-if="canResize" @mousedown.prevent="onRightHandleMousedown"></div> + <div class="handle bottom" v-if="canResize" @mousedown.prevent="onBottomHandleMousedown"></div> + <div class="handle left" v-if="canResize" @mousedown.prevent="onLeftHandleMousedown"></div> + <div class="handle top-left" v-if="canResize" @mousedown.prevent="onTopLeftHandleMousedown"></div> + <div class="handle top-right" v-if="canResize" @mousedown.prevent="onTopRightHandleMousedown"></div> + <div class="handle bottom-right" v-if="canResize" @mousedown.prevent="onBottomRightHandleMousedown"></div> + <div class="handle bottom-left" v-if="canResize" @mousedown.prevent="onBottomLeftHandleMousedown"></div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; +import contains from '../../../common/scripts/contains'; + +const minHeight = 40; +const minWidth = 200; + +function dragListen(fn) { + window.addEventListener('mousemove', fn); + window.addEventListener('mouseleave', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); +} + +function dragClear(fn) { + window.removeEventListener('mousemove', fn); + window.removeEventListener('mouseleave', dragClear); + window.removeEventListener('mouseup', dragClear); +} + +export default Vue.extend({ + props: { + isModal: { + type: Boolean, + default: false + }, + canClose: { + type: Boolean, + default: true + }, + width: { + type: String, + default: '530px' + }, + height: { + type: String, + default: 'auto' + }, + popoutUrl: { + type: [String, Function], + default: null + }, + name: { + type: String, + default: null + } + }, + + data() { + return { + preventMount: false + }; + }, + + computed: { + isFlexible(): boolean { + return this.height == null; + }, + canResize(): boolean { + return !this.isFlexible; + }, + withGradient(): boolean { + return (this as any).os.isSignedIn + ? (this as any).os.i.account.clientSettings.gradientWindowHeader != null + ? (this as any).os.i.account.clientSettings.gradientWindowHeader + : false + : false; + } + }, + + created() { + if (localStorage.getItem('autoPopout') == 'true' && this.popoutUrl) { + this.popout(); + this.preventMount = true; + } else { + // ウィンドウをウィンドウシステムに登録 + (this as any).os.windows.add(this); + } + }, + + mounted() { + if (this.preventMount) { + this.$destroy(); + return; + } + + this.$nextTick(() => { + const main = this.$refs.main as any; + main.style.top = '15%'; + main.style.left = (window.innerWidth / 2) - (main.offsetWidth / 2) + 'px'; + + window.addEventListener('resize', this.onBrowserResize); + + this.open(); + }); + }, + + destroyed() { + // ウィンドウをウィンドウシステムから削除 + (this as any).os.windows.remove(this); + + window.removeEventListener('resize', this.onBrowserResize); + }, + + methods: { + open() { + this.$emit('opening'); + + this.top(); + + const bg = this.$refs.bg as any; + const main = this.$refs.main as any; + + if (this.isModal) { + bg.style.pointerEvents = 'auto'; + anime({ + targets: bg, + opacity: 1, + duration: 100, + easing: 'linear' + }); + } + + main.style.pointerEvents = 'auto'; + anime({ + targets: main, + opacity: 1, + scale: [1.1, 1], + duration: 200, + easing: 'easeOutQuad' + }); + + if (focus) main.focus(); + + setTimeout(() => { + this.$emit('opened'); + }, 300); + }, + + close() { + this.$emit('before-close'); + + const bg = this.$refs.bg as any; + const main = this.$refs.main as any; + + if (this.isModal) { + bg.style.pointerEvents = 'none'; + anime({ + targets: bg, + opacity: 0, + duration: 300, + easing: 'linear' + }); + } + + main.style.pointerEvents = 'none'; + + anime({ + targets: main, + opacity: 0, + scale: 0.8, + duration: 300, + easing: [0.5, -0.5, 1, 0.5] + }); + + setTimeout(() => { + this.$destroy(); + this.$emit('closed'); + }, 300); + }, + + popout() { + const url = typeof this.popoutUrl == 'function' ? this.popoutUrl() : this.popoutUrl; + + const main = this.$refs.main as any; + + if (main) { + const position = main.getBoundingClientRect(); + + const width = parseInt(getComputedStyle(main, '').width, 10); + const height = parseInt(getComputedStyle(main, '').height, 10); + const x = window.screenX + position.left; + const y = window.screenY + position.top; + + window.open(url, url, + `width=${width}, height=${height}, top=${y}, left=${x}`); + + this.close(); + } else { + const x = window.top.outerHeight / 2 + window.top.screenY - (parseInt(this.height, 10) / 2); + const y = window.top.outerWidth / 2 + window.top.screenX - (parseInt(this.width, 10) / 2); + window.open(url, url, + `width=${this.width}, height=${this.height}, top=${x}, left=${y}`); + } + }, + + // 最前面へ移動 + top() { + let z = 0; + + (this as any).os.windows.getAll().forEach(w => { + if (w == this) return; + const m = w.$refs.main; + const mz = Number(document.defaultView.getComputedStyle(m, null).zIndex); + if (mz > z) z = mz; + }); + + if (z > 0) { + (this.$refs.main as any).style.zIndex = z + 1; + if (this.isModal) (this.$refs.bg as any).style.zIndex = z + 1; + } + }, + + onBgClick() { + if (this.canClose) this.close(); + }, + + onBodyMousedown() { + this.top(); + }, + + onHeaderMousedown(e) { + const main = this.$refs.main as any; + + if (!contains(main, document.activeElement)) main.focus(); + + const position = main.getBoundingClientRect(); + + const clickX = e.clientX; + const clickY = e.clientY; + const moveBaseX = clickX - position.left; + const moveBaseY = clickY - position.top; + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + + // 動かした時 + dragListen(me => { + let moveLeft = me.clientX - moveBaseX; + let moveTop = me.clientY - moveBaseY; + + // 上はみ出し + if (moveTop < 0) moveTop = 0; + + // 左はみ出し + if (moveLeft < 0) moveLeft = 0; + + // 下はみ出し + if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight; + + // 右はみ出し + if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; + + main.style.left = moveLeft + 'px'; + main.style.top = moveTop + 'px'; + }); + }, + + // 上ハンドル掴み時 + onTopHandleMousedown(e) { + const main = this.$refs.main as any; + + const base = e.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + move > 0) { + if (height + -move > minHeight) { + this.applyTransformHeight(height + -move); + this.applyTransformTop(top + move); + } else { // 最小の高さより小さくなろうとした時 + this.applyTransformHeight(minHeight); + this.applyTransformTop(top + (height - minHeight)); + } + } else { // 上のはみ出し時 + this.applyTransformHeight(top + height); + this.applyTransformTop(0); + } + }); + }, + + // 右ハンドル掴み時 + onRightHandleMousedown(e) { + const main = this.$refs.main as any; + + const base = e.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + const browserWidth = window.innerWidth; + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + width + move < browserWidth) { + if (width + move > minWidth) { + this.applyTransformWidth(width + move); + } else { // 最小の幅より小さくなろうとした時 + this.applyTransformWidth(minWidth); + } + } else { // 右のはみ出し時 + this.applyTransformWidth(browserWidth - left); + } + }); + }, + + // 下ハンドル掴み時 + onBottomHandleMousedown(e) { + const main = this.$refs.main as any; + + const base = e.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + const browserHeight = window.innerHeight; + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + height + move < browserHeight) { + if (height + move > minHeight) { + this.applyTransformHeight(height + move); + } else { // 最小の高さより小さくなろうとした時 + this.applyTransformHeight(minHeight); + } + } else { // 下のはみ出し時 + this.applyTransformHeight(browserHeight - top); + } + }); + }, + + // 左ハンドル掴み時 + onLeftHandleMousedown(e) { + const main = this.$refs.main as any; + + const base = e.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + move > 0) { + if (width + -move > minWidth) { + this.applyTransformWidth(width + -move); + this.applyTransformLeft(left + move); + } else { // 最小の幅より小さくなろうとした時 + this.applyTransformWidth(minWidth); + this.applyTransformLeft(left + (width - minWidth)); + } + } else { // 左のはみ出し時 + this.applyTransformWidth(left + width); + this.applyTransformLeft(0); + } + }); + }, + + // 左上ハンドル掴み時 + onTopLeftHandleMousedown(e) { + this.onTopHandleMousedown(e); + this.onLeftHandleMousedown(e); + }, + + // 右上ハンドル掴み時 + onTopRightHandleMousedown(e) { + this.onTopHandleMousedown(e); + this.onRightHandleMousedown(e); + }, + + // 右下ハンドル掴み時 + onBottomRightHandleMousedown(e) { + this.onBottomHandleMousedown(e); + this.onRightHandleMousedown(e); + }, + + // 左下ハンドル掴み時 + onBottomLeftHandleMousedown(e) { + this.onBottomHandleMousedown(e); + this.onLeftHandleMousedown(e); + }, + + // 高さを適用 + applyTransformHeight(height) { + (this.$refs.main as any).style.height = height + 'px'; + }, + + // 幅を適用 + applyTransformWidth(width) { + (this.$refs.main as any).style.width = width + 'px'; + }, + + // Y座標を適用 + applyTransformTop(top) { + (this.$refs.main as any).style.top = top + 'px'; + }, + + // X座標を適用 + applyTransformLeft(left) { + (this.$refs.main as any).style.left = left + 'px'; + }, + + onDragover(e) { + e.dataTransfer.dropEffect = 'none'; + }, + + onKeydown(e) { + if (e.which == 27) { // Esc + if (this.canClose) { + e.preventDefault(); + e.stopPropagation(); + this.close(); + } + } + }, + + onBrowserResize() { + const main = this.$refs.main as any; + const position = main.getBoundingClientRect(); + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + if (position.left < 0) main.style.left = 0; + if (position.top < 0) main.style.top = 0; + if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; + if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-window + display block + + > .bg + display block + position fixed + z-index 2000 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + opacity 0 + pointer-events none + + > .main + display block + position fixed + z-index 2000 + top 15% + left 0 + margin 0 + opacity 0 + pointer-events none + + &:focus + &:not([data-is-modal]) + > .body + box-shadow 0 0 0px 1px rgba($theme-color, 0.5), 0 2px 6px 0 rgba(0, 0, 0, 0.2) + + > .handle + $size = 8px + + position absolute + + &.top + top -($size) + left 0 + width 100% + height $size + cursor ns-resize + + &.right + top 0 + right -($size) + width $size + height 100% + cursor ew-resize + + &.bottom + bottom -($size) + left 0 + width 100% + height $size + cursor ns-resize + + &.left + top 0 + left -($size) + width $size + height 100% + cursor ew-resize + + &.top-left + top -($size) + left -($size) + width $size * 2 + height $size * 2 + cursor nwse-resize + + &.top-right + top -($size) + right -($size) + width $size * 2 + height $size * 2 + cursor nesw-resize + + &.bottom-right + bottom -($size) + right -($size) + width $size * 2 + height $size * 2 + cursor nwse-resize + + &.bottom-left + bottom -($size) + left -($size) + width $size * 2 + height $size * 2 + cursor nesw-resize + + > .body + height 100% + overflow hidden + background #fff + border-radius 6px + box-shadow 0 2px 6px 0 rgba(0, 0, 0, 0.2) + + > header + $header-height = 40px + + z-index 1001 + height $header-height + overflow hidden + white-space nowrap + cursor move + background #fff + border-radius 6px 6px 0 0 + box-shadow 0 1px 0 rgba(#000, 0.1) + + &.withGradient + background linear-gradient(to bottom, #fff, #ececec) + box-shadow 0 1px 0 rgba(#000, 0.15) + + &, * + user-select none + + > h1 + pointer-events none + display block + margin 0 auto + overflow hidden + height $header-height + text-overflow ellipsis + text-align center + font-size 1em + line-height $header-height + font-weight normal + color #666 + + > div:last-child + position absolute + top 0 + right 0 + display block + z-index 1 + + > * + display inline-block + margin 0 + padding 0 + cursor pointer + font-size 1em + color rgba(#000, 0.4) + border none + outline none + background transparent + + &:hover + color rgba(#000, 0.6) + + &:active + color darken(#000, 30%) + + > [data-fa] + padding 0 + width $header-height + line-height $header-height + text-align center + + > .content + height 100% + + &:not([flexible]) + > .main > .body > .content + height calc(100% - 40px) + +</style> diff --git a/src/client/app/desktop/views/directives/index.ts b/src/client/app/desktop/views/directives/index.ts new file mode 100644 index 0000000000..324e07596d --- /dev/null +++ b/src/client/app/desktop/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/desktop/views/directives/user-preview.ts b/src/client/app/desktop/views/directives/user-preview.ts new file mode 100644 index 0000000000..8a4035881a --- /dev/null +++ b/src/client/app/desktop/views/directives/user-preview.ts @@ -0,0 +1,72 @@ +/** + * マウスオーバーするとユーザーがプレビューされる要素を設定します + */ + +import MkUserPreview from '../components/user-preview.vue'; + +export default { + bind(el, binding, vn) { + const self = el._userPreviewDirective_ = {} as any; + + self.user = binding.value; + self.tag = null; + self.showTimer = null; + self.hideTimer = null; + + self.close = () => { + if (self.tag) { + self.tag.close(); + self.tag = null; + } + }; + + const show = () => { + if (self.tag) return; + + self.tag = new MkUserPreview({ + parent: vn.context, + propsData: { + user: self.user + } + }).$mount(); + + const preview = self.tag.$el; + const rect = el.getBoundingClientRect(); + const x = rect.left + el.offsetWidth + window.pageXOffset; + const y = rect.top + window.pageYOffset; + + preview.style.top = y + 'px'; + preview.style.left = x + 'px'; + + preview.addEventListener('mouseover', () => { + clearTimeout(self.hideTimer); + }); + + preview.addEventListener('mouseleave', () => { + clearTimeout(self.showTimer); + self.hideTimer = setTimeout(self.close, 500); + }); + + document.body.appendChild(preview); + }; + + el.addEventListener('mouseover', () => { + clearTimeout(self.showTimer); + clearTimeout(self.hideTimer); + self.showTimer = setTimeout(show, 500); + }); + + el.addEventListener('mouseleave', () => { + clearTimeout(self.showTimer); + clearTimeout(self.hideTimer); + self.hideTimer = setTimeout(self.close, 500); + }); + }, + + unbind(el, binding, vn) { + const self = el._userPreviewDirective_; + clearTimeout(self.showTimer); + clearTimeout(self.hideTimer); + self.close(); + } +}; diff --git a/src/client/app/desktop/views/pages/drive.vue b/src/client/app/desktop/views/pages/drive.vue new file mode 100644 index 0000000000..353f59b703 --- /dev/null +++ b/src/client/app/desktop/views/pages/drive.vue @@ -0,0 +1,52 @@ +<template> +<div class="mk-drive-page"> + <mk-drive :init-folder="folder" @move-root="onMoveRoot" @open-folder="onOpenFolder"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + folder: null + }; + }, + created() { + this.folder = this.$route.params.folder; + }, + mounted() { + document.title = 'Misskey Drive'; + }, + methods: { + onMoveRoot() { + const title = 'Misskey Drive'; + + // Rewrite URL + history.pushState(null, title, '/i/drive'); + + document.title = title; + }, + onOpenFolder(folder) { + const title = folder.name + ' | Misskey Drive'; + + // Rewrite URL + history.pushState(null, title, '/i/drive/folder/' + folder.id); + + document.title = title; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-drive-page + position fixed + width 100% + height 100% + background #fff + + > .mk-drive + height 100% +</style> + diff --git a/src/client/app/desktop/views/pages/home-customize.vue b/src/client/app/desktop/views/pages/home-customize.vue new file mode 100644 index 0000000000..8aa06be57f --- /dev/null +++ b/src/client/app/desktop/views/pages/home-customize.vue @@ -0,0 +1,12 @@ +<template> +<mk-home customize/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + mounted() { + document.title = 'Misskey - ホームのカスタマイズ'; + } +}); +</script> diff --git a/src/client/app/desktop/views/pages/home.vue b/src/client/app/desktop/views/pages/home.vue new file mode 100644 index 0000000000..69e134f79f --- /dev/null +++ b/src/client/app/desktop/views/pages/home.vue @@ -0,0 +1,62 @@ +<template> +<mk-ui> + <mk-home :mode="mode" @loaded="loaded"/> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; +import getPostSummary from '../../../../../common/get-post-summary'; + +export default Vue.extend({ + props: { + mode: { + type: String, + default: 'timeline' + } + }, + data() { + return { + connection: null, + connectionId: null, + unreadCount: 0 + }; + }, + mounted() { + document.title = 'Misskey'; + + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('post', this.onStreamPost); + document.addEventListener('visibilitychange', this.onVisibilitychange, false); + + Progress.start(); + }, + beforeDestroy() { + this.connection.off('post', this.onStreamPost); + (this as any).os.stream.dispose(this.connectionId); + document.removeEventListener('visibilitychange', this.onVisibilitychange); + }, + methods: { + loaded() { + 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'; + } + } + } +}); +</script> diff --git a/src/client/app/desktop/views/pages/index.vue b/src/client/app/desktop/views/pages/index.vue new file mode 100644 index 0000000000..0ea47d913b --- /dev/null +++ b/src/client/app/desktop/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/desktop/views/pages/messaging-room.vue b/src/client/app/desktop/views/pages/messaging-room.vue new file mode 100644 index 0000000000..0cab1e0d10 --- /dev/null +++ b/src/client/app/desktop/views/pages/messaging-room.vue @@ -0,0 +1,54 @@ +<template> +<div class="mk-messaging-room-page"> + <mk-messaging-room v-if="user" :user="user" :is-naked="true"/> +</div> +</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 = '#fff'; + }, + 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 = 'メッセージ: ' + this.user.name; + + Progress.done(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-messaging-room-page + display flex + flex 1 + flex-direction column + min-height 100% + background #fff + +</style> diff --git a/src/client/app/desktop/views/pages/othello.vue b/src/client/app/desktop/views/pages/othello.vue new file mode 100644 index 0000000000..0d8e987dd9 --- /dev/null +++ b/src/client/app/desktop/views/pages/othello.vue @@ -0,0 +1,50 @@ +<template> +<component :is="ui ? 'mk-ui' : 'div'"> + <mk-othello v-if="!fetching" :init-game="game" @gamed="onGamed"/> +</component> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +export default Vue.extend({ + props: { + ui: { + default: false + } + }, + data() { + return { + fetching: false, + game: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + 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/desktop/views/pages/post.vue b/src/client/app/desktop/views/pages/post.vue new file mode 100644 index 0000000000..dbd707e049 --- /dev/null +++ b/src/client/app/desktop/views/pages/post.vue @@ -0,0 +1,67 @@ +<template> +<mk-ui> + <main v-if="!fetching"> + <a v-if="post.next" :href="post.next">%fa:angle-up%%i18n:desktop.tags.mk-post-page.next%</a> + <mk-post-detail :post="post"/> + <a v-if="post.prev" :href="post.prev">%fa:angle-down%%i18n:desktop.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(); + }, + 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 + padding 16px + text-align center + + > a + display inline-block + + &:first-child + margin-bottom 4px + + &:last-child + margin-top 4px + + > [data-fa] + margin-right 4px + + > .mk-post-detail + margin 0 auto + width 640px + +</style> diff --git a/src/client/app/desktop/views/pages/search.vue b/src/client/app/desktop/views/pages/search.vue new file mode 100644 index 0000000000..afd37c8cee --- /dev/null +++ b/src/client/app/desktop/views/pages/search.vue @@ -0,0 +1,138 @@ +<template> +<mk-ui> + <header :class="$style.header"> + <h1>{{ q }}</h1> + </header> + <div :class="$style.loading" v-if="fetching"> + <mk-ellipsis-icon/> + </div> + <p :class="$style.empty" v-if="!fetching && empty">%fa:search%「{{ q }}」に関する投稿は見つかりませんでした。</p> + <mk-posts ref="timeline" :class="$style.posts" :posts="posts"> + <div slot="footer"> + <template v-if="!moreFetching">%fa:search%</template> + <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> + </div> + </mk-posts> +</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, + moreFetching: false, + existMore: false, + offset: 0, + posts: [] + }; + }, + watch: { + $route: 'fetch' + }, + computed: { + empty(): boolean { + return this.posts.length == 0; + }, + q(): string { + return this.$route.query.q; + } + }, + mounted() { + document.addEventListener('keydown', this.onDocumentKeydown); + window.addEventListener('scroll', this.onScroll); + + this.fetch(); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onDocumentKeydown); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + onDocumentKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 84) { // t + (this.$refs.timeline as any).focus(); + } + } + }, + fetch() { + this.fetching = true; + Progress.start(); + + (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(); + this.existMore = true; + } + this.posts = posts; + this.fetching = false; + Progress.done(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0 || !this.existMore) return; + this.offset += limit; + this.moreFetching = true; + return (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); + this.moreFetching = false; + }); + }, + onScroll() { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 16) this.more(); + } + } +}); +</script> + +<style lang="stylus" module> +.header + width 100% + max-width 600px + margin 0 auto + color #555 + +.posts + max-width 600px + margin 0 auto + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + +.loading + padding 64px 0 + +.empty + display block + 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 + +</style> diff --git a/src/client/app/desktop/views/pages/selectdrive.vue b/src/client/app/desktop/views/pages/selectdrive.vue new file mode 100644 index 0000000000..4f0b86014b --- /dev/null +++ b/src/client/app/desktop/views/pages/selectdrive.vue @@ -0,0 +1,177 @@ +<template> +<div class="mkp-selectdrive"> + <mk-drive ref="browser" + :multiple="multiple" + @selected="onSelected" + @change-selection="onChangeSelection" + /> + <footer> + <button class="upload" title="%i18n:desktop.tags.mk-selectdrive-page.upload%" @click="upload">%fa:upload%</button> + <button class="cancel" @click="close">%i18n:desktop.tags.mk-selectdrive-page.cancel%</button> + <button class="ok" @click="ok">%i18n:desktop.tags.mk-selectdrive-page.ok%</button> + </footer> +</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> +@import '~const.styl' + +.mkp-selectdrive + display block + position fixed + width 100% + height 100% + background #fff + + > .mk-drive + height calc(100% - 72px) + + > footer + position fixed + bottom 0 + left 0 + width 100% + height 72px + background lighten($theme-color, 95%) + + .upload + display inline-block + position absolute + top 8px + left 16px + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background transparent + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + .ok + .cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + .ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.followers-you-know.vue b/src/client/app/desktop/views/pages/user/user.followers-you-know.vue new file mode 100644 index 0000000000..d0dab6c3df --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.followers-you-know.vue @@ -0,0 +1,84 @@ +<template> +<div class="followers-you-know"> + <p class="title">%fa:users%%i18n:desktop.tags.mk-user.followers-you-know.title%</p> + <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:desktop.tags.mk-user.followers-you-know.loading%<mk-ellipsis/></p> + <div v-if="!fetching && users.length > 0"> + <router-link v-for="user in users" :to="`/@${getAcct(user)}`" :key="user.id"> + <img :src="`${user.avatarUrl}?thumbnail&size=64`" :alt="user.name" v-user-preview="user.id"/> + </router-link> + </div> + <p class="empty" v-if="!fetching && users.length == 0">%i18n:desktop.tags.mk-user.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 { + users: [], + fetching: true + }; + }, + method() { + getAcct + }, + mounted() { + (this as any).api('users/followers', { + userId: this.user.id, + iknow: true, + limit: 16 + }).then(x => { + this.users = x.users; + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.followers-you-know + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > div + padding 8px + + > 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/desktop/views/pages/user/user.friends.vue b/src/client/app/desktop/views/pages/user/user.friends.vue new file mode 100644 index 0000000000..3ec30fb438 --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.friends.vue @@ -0,0 +1,124 @@ +<template> +<div class="friends"> + <p class="title">%fa:users%%i18n:desktop.tags.mk-user.frequently-replied-users.title%</p> + <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:desktop.tags.mk-user.frequently-replied-users.loading%<mk-ellipsis/></p> + <template v-if="!fetching && users.length != 0"> + <div class="user" v-for="friend in users"> + <router-link class="avatar-anchor" :to="`/@${getAcct(friend)}`"> + <img class="avatar" :src="`${friend.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="friend.id"/> + </router-link> + <div class="body"> + <router-link class="name" :to="`/@${getAcct(friend)}`" v-user-preview="friend.id">{{ friend.name }}</router-link> + <p class="username">@{{ getAcct(friend) }}</p> + </div> + <mk-follow-button :user="friend"/> + </div> + </template> + <p class="empty" v-if="!fetching && users.length == 0">%i18n:desktop.tags.mk-user.frequently-replied-users.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 { + users: [], + fetching: true + }; + }, + method() { + getAcct + }, + mounted() { + (this as any).api('users/get_frequently_replied_users', { + userId: this.user.id, + limit: 4 + }).then(docs => { + this.users = docs.map(doc => doc.user); + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.friends + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + > .user + padding 16px + border-bottom solid 1px #eee + + &:last-child + border-bottom none + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 42px + height 42px + margin 0 + border-radius 8px + vertical-align bottom + + > .body + float left + width calc(100% - 54px) + + > .name + margin 0 + font-size 16px + line-height 24px + color #555 + + > .username + display block + margin 0 + font-size 15px + line-height 16px + color #ccc + + > .mk-follow-button + position absolute + top 16px + right 16px + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.header.vue b/src/client/app/desktop/views/pages/user/user.header.vue new file mode 100644 index 0000000000..54f431fd2e --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.header.vue @@ -0,0 +1,196 @@ +<template> +<div class="header" :data-is-dark-background="user.bannerUrl != null"> + <div class="banner-container" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=2048)` : ''"> + <div class="banner" ref="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=2048)` : ''" @click="onBannerClick"></div> + </div> + <div class="fade"></div> + <div class="container"> + <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=150`" alt="avatar"/> + <div class="title"> + <p class="name">{{ user.name }}</p> + <p class="username">@{{ acct }}</p> + <p class="location" v-if="user.host === null && user.account.profile.location">%fa:map-marker%{{ user.account.profile.location }}</p> + </div> + <footer> + <router-link :to="`/@${acct}`" :data-active="$parent.page == 'home'">%fa:home%概要</router-link> + <router-link :to="`/@${acct}/media`" :data-active="$parent.page == 'media'">%fa:image%メディア</router-link> + <router-link :to="`/@${acct}/graphs`" :data-active="$parent.page == 'graphs'">%fa:chart-bar%グラフ</router-link> + </footer> + </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); + } + }, + mounted() { + window.addEventListener('load', this.onScroll); + window.addEventListener('scroll', this.onScroll); + window.addEventListener('resize', this.onScroll); + }, + beforeDestroy() { + window.removeEventListener('load', this.onScroll); + window.removeEventListener('scroll', this.onScroll); + window.removeEventListener('resize', this.onScroll); + }, + methods: { + onScroll() { + const banner = this.$refs.banner as any; + + const top = window.scrollY; + + const z = 1.25; // 奥行き(小さいほど奥) + const pos = -(top / z); + banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; + + const blur = top / 32 + if (blur <= 10) banner.style.filter = `blur(${blur}px)`; + }, + + onBannerClick() { + if (!(this as any).os.isSignedIn || (this as any).os.i.id != this.user.id) return; + + (this as any).apis.updateBanner((this as any).os.i, i => { + this.user.bannerUrl = i.bannerUrl; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.header + $banner-height = 320px + $footer-height = 58px + + overflow hidden + background #f7f7f7 + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + &[data-is-dark-background] + > .banner-container + > .banner + background-color #383838 + + > .fade + background linear-gradient(transparent, rgba(0, 0, 0, 0.7)) + + > .container + > .title + color #fff + + > .name + text-shadow 0 0 8px #000 + + > .banner-container + height $banner-height + overflow hidden + background-size cover + background-position center + + > .banner + height 100% + background-color #f5f5f5 + background-size cover + background-position center + + > .fade + $fade-hight = 78px + + position absolute + top ($banner-height - $fade-hight) + left 0 + width 100% + height $fade-hight + + > .container + max-width 1200px + margin 0 auto + + > .avatar + display block + position absolute + bottom 16px + left 16px + z-index 2 + width 160px + height 160px + margin 0 + border solid 3px #fff + border-radius 8px + box-shadow 1px 1px 3px rgba(0, 0, 0, 0.2) + + > .title + position absolute + bottom $footer-height + left 0 + width 100% + padding 0 0 8px 195px + color #656565 + font-family '游ゴシック', 'YuGothic', 'ヒラギノ角ゴ ProN W3', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'メイリオ', sans-serif + + > .name + display block + margin 0 + line-height 40px + font-weight bold + font-size 2em + + > .username + > .location + display inline-block + margin 0 16px 0 0 + line-height 20px + opacity 0.8 + + > i + margin-right 4px + + > footer + z-index 1 + height $footer-height + padding-left 195px + + > a + display inline-block + margin 0 + padding 0 16px + height $footer-height + line-height $footer-height + color #555 + + &[data-active] + border-bottom solid 4px $theme-color + + > i + margin-right 6px + + > button + display block + position absolute + top 0 + right 0 + margin 8px + padding 0 + width $footer-height - 16px + line-height $footer-height - 16px - 2px + font-size 1.2em + color #777 + border solid 1px #eee + border-radius 4px + + &:hover + color #555 + border solid 1px #ddd + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.home.vue b/src/client/app/desktop/views/pages/user/user.home.vue new file mode 100644 index 0000000000..071c9bb61c --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.home.vue @@ -0,0 +1,103 @@ +<template> +<div class="home"> + <div> + <div ref="left"> + <x-profile :user="user"/> + <x-photos :user="user"/> + <x-followers-you-know v-if="os.isSignedIn && os.i.id != user.id" :user="user"/> + <p v-if="user.host === null">%i18n:desktop.tags.mk-user.last-used-at%: <b><mk-time :time="user.account.lastUsedAt"/></b></p> + </div> + </div> + <main> + <mk-post-detail v-if="user.pinnedPost" :post="user.pinnedPost" :compact="true"/> + <x-timeline class="timeline" ref="tl" :user="user"/> + </main> + <div> + <div ref="right"> + <mk-calendar @chosen="warp" :start="new Date(user.createdAt)"/> + <mk-activity :user="user"/> + <x-friends :user="user"/> + <div class="nav"><mk-nav/></div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XTimeline from './user.timeline.vue'; +import XProfile from './user.profile.vue'; +import XPhotos from './user.photos.vue'; +import XFollowersYouKnow from './user.followers-you-know.vue'; +import XFriends from './user.friends.vue'; + +export default Vue.extend({ + components: { + XTimeline, + XProfile, + XPhotos, + XFollowersYouKnow, + XFriends + }, + props: ['user'], + methods: { + warp(date) { + (this.$refs.tl as any).warp(date); + } + } +}); +</script> + +<style lang="stylus" scoped> +.home + display flex + justify-content center + margin 0 auto + max-width 1200px + + > main + > div > div + > *:not(:last-child) + margin-bottom 16px + + > main + padding 16px + width calc(100% - 275px * 2) + + > .timeline + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > div + width 275px + margin 0 + + &:first-child > div + padding 16px 0 16px 16px + + > p + display block + margin 0 + padding 0 12px + text-align center + font-size 0.8em + color #aaa + + &:last-child > div + padding 16px 16px 16px 0 + + > .nav + padding 16px + font-size 12px + color #aaa + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + a + color #999 + + i + color #ccc + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.photos.vue b/src/client/app/desktop/views/pages/user/user.photos.vue new file mode 100644 index 0000000000..1ff79b4aee --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.photos.vue @@ -0,0 +1,88 @@ +<template> +<div class="photos"> + <p class="title">%fa:camera%%i18n:desktop.tags.mk-user.photos.title%</p> + <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:desktop.tags.mk-user.photos.loading%<mk-ellipsis/></p> + <div class="stream" v-if="!fetching && images.length > 0"> + <div v-for="image in images" class="img" + :style="`background-image: url(${image.url}?thumbnail&size=256)`" + ></div> + </div> + <p class="empty" v-if="!fetching && images.length == 0">%i18n:desktop.tags.mk-user.photos.no-photos%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + images: [], + fetching: true + }; + }, + mounted() { + (this as any).api('users/posts', { + userId: this.user.id, + withMedia: true, + limit: 9 + }).then(posts => { + posts.forEach(post => { + post.media.forEach(media => { + if (this.images.length < 9) this.images.push(media); + }); + }); + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.photos + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > .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 + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.profile.vue b/src/client/app/desktop/views/pages/user/user.profile.vue new file mode 100644 index 0000000000..f5562d0915 --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.profile.vue @@ -0,0 +1,138 @@ +<template> +<div class="profile"> + <div class="friend-form" v-if="os.isSignedIn && os.i.id != user.id"> + <mk-follow-button :user="user" size="big"/> + <p class="followed" v-if="user.isFollowed">%i18n:desktop.tags.mk-user.follows-you%</p> + <p v-if="user.isMuted">%i18n:desktop.tags.mk-user.muted% <a @click="unmute">%i18n:desktop.tags.mk-user.unmute%</a></p> + <p v-if="!user.isMuted"><a @click="mute">%i18n:desktop.tags.mk-user.mute%</a></p> + </div> + <div class="description" v-if="user.description">{{ user.description }}</div> + <div class="birthday" v-if="user.host === null && user.account.profile.birthday"> + <p>%fa:birthday-cake%{{ user.account.profile.birthday.replace('-', '年').replace('-', '月') + '日' }} ({{ age }}歳)</p> + </div> + <div class="twitter" v-if="user.host === null && user.account.twitter"> + <p>%fa:B twitter%<a :href="`https://twitter.com/${user.account.twitter.screenName}`" target="_blank">@{{ user.account.twitter.screenName }}</a></p> + </div> + <div class="status"> + <p class="posts-count">%fa:angle-right%<a>{{ user.postsCount }}</a><b>投稿</b></p> + <p class="following">%fa:angle-right%<a @click="showFollowing">{{ user.followingCount }}</a>人を<b>フォロー</b></p> + <p class="followers">%fa:angle-right%<a @click="showFollowers">{{ user.followersCount }}</a>人の<b>フォロワー</b></p> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as age from 's-age'; +import MkFollowingWindow from '../../components/following-window.vue'; +import MkFollowersWindow from '../../components/followers-window.vue'; + +export default Vue.extend({ + props: ['user'], + computed: { + age(): number { + return age(this.user.account.profile.birthday); + } + }, + methods: { + showFollowing() { + (this as any).os.new(MkFollowingWindow, { + user: this.user + }); + }, + + showFollowers() { + (this as any).os.new(MkFollowersWindow, { + user: this.user + }); + }, + + mute() { + (this as any).api('mute/create', { + userId: this.user.id + }).then(() => { + this.user.isMuted = true; + }, () => { + alert('error'); + }); + }, + + unmute() { + (this as any).api('mute/delete', { + userId: this.user.id + }).then(() => { + this.user.isMuted = false; + }, () => { + alert('error'); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.profile + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > *:first-child + border-top none !important + + > .friend-form + padding 16px + border-top solid 1px #eee + + > .mk-big-follow-button + width 100% + + > .followed + margin 12px 0 0 0 + padding 0 + text-align center + line-height 24px + font-size 0.8em + color #71afc7 + background #eefaff + border-radius 4px + + > .description + padding 16px + color #555 + border-top solid 1px #eee + + > .birthday + padding 16px + color #555 + border-top solid 1px #eee + + > p + margin 0 + + > i + margin-right 8px + + > .twitter + padding 16px + color #555 + border-top solid 1px #eee + + > p + margin 0 + + > i + margin-right 8px + + > .status + padding 16px + color #555 + border-top solid 1px #eee + + > p + margin 8px 0 + + > i + margin-left 8px + margin-right 8px + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.timeline.vue b/src/client/app/desktop/views/pages/user/user.timeline.vue new file mode 100644 index 0000000000..134ad423ce --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.timeline.vue @@ -0,0 +1,139 @@ +<template> +<div class="timeline"> + <header> + <span :data-is-active="mode == 'default'" @click="mode = 'default'">投稿</span> + <span :data-is-active="mode == 'with-replies'" @click="mode = 'with-replies'">投稿と返信</span> + </header> + <div class="loading" v-if="fetching"> + <mk-ellipsis-icon/> + </div> + <p class="empty" v-if="empty">%fa:R comments%このユーザーはまだ何も投稿していないようです。</p> + <mk-posts ref="timeline" :posts="posts"> + <div slot="footer"> + <template v-if="!moreFetching">%fa:moon%</template> + <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> + </div> + </mk-posts> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + fetching: true, + moreFetching: false, + mode: 'default', + unreadCount: 0, + posts: [], + date: null + }; + }, + watch: { + mode() { + this.fetch(); + } + }, + computed: { + empty(): boolean { + return this.posts.length == 0; + } + }, + mounted() { + document.addEventListener('keydown', this.onDocumentKeydown); + window.addEventListener('scroll', this.onScroll); + + this.fetch(() => this.$emit('loaded')); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onDocumentKeydown); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + onDocumentKeydown(e) { + if (e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') { + if (e.which == 84) { // [t] + (this.$refs.timeline as any).focus(); + } + } + }, + fetch(cb?) { + (this as any).api('users/posts', { + userId: this.user.id, + untilDate: this.date ? this.date.getTime() : undefined, + with_replies: this.mode == 'with-replies' + }).then(posts => { + this.posts = posts; + this.fetching = false; + if (cb) cb(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0) return; + this.moreFetching = true; + (this as any).api('users/posts', { + userId: this.user.id, + with_replies: this.mode == 'with-replies', + untilId: this.posts[this.posts.length - 1].id + }).then(posts => { + this.moreFetching = false; + this.posts = this.posts.concat(posts); + }); + }, + onScroll() { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 16/*遊び*/) { + this.more(); + } + }, + warp(date) { + this.date = date; + this.fetch(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.timeline + background #fff + + > header + padding 8px 16px + border-bottom solid 1px #eee + + > span + margin-right 16px + line-height 27px + font-size 18px + color #555 + + &:not([data-is-active]) + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + > .loading + padding 64px 0 + + > .empty + display block + 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 + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.vue b/src/client/app/desktop/views/pages/user/user.vue new file mode 100644 index 0000000000..67cef93269 --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.vue @@ -0,0 +1,53 @@ +<template> +<mk-ui> + <div class="user" v-if="!fetching"> + <x-header :user="user"/> + <x-home v-if="page == 'home'" :user="user"/> + </div> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import parseAcct from '../../../../../../common/user/parse-acct'; +import Progress from '../../../../common/scripts/loading'; +import XHeader from './user.header.vue'; +import XHome from './user.home.vue'; + +export default Vue.extend({ + components: { + XHeader, + XHome + }, + props: { + page: { + default: 'home' + } + }, + data() { + return { + fetching: true, + user: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + this.fetching = true; + 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> + diff --git a/src/client/app/desktop/views/pages/welcome.vue b/src/client/app/desktop/views/pages/welcome.vue new file mode 100644 index 0000000000..34c28854b1 --- /dev/null +++ b/src/client/app/desktop/views/pages/welcome.vue @@ -0,0 +1,319 @@ +<template> +<div class="mk-welcome"> + <main> + <div class="top"> + <div> + <div> + <h1>Share<br><span ref="share">Everything!</span><span class="cursor">_</span></h1> + <p>ようこそ! <b>Misskey</b>はTwitter風ミニブログSNSです。思ったことや皆と共有したいことを投稿しましょう。タイムラインを見れば、皆の関心事をすぐにチェックすることもできます。<a :href="aboutUrl">詳しく...</a></p> + <p><button class="signup" @click="signup">はじめる</button><button class="signin" @click="signin">ログイン</button></p> + <div class="users"> + <router-link v-for="user in users" :key="user.id" class="avatar-anchor" :to="`/@${getAcct(user)}`" v-user-preview="user.id"> + <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + </router-link> + </div> + </div> + <div> + <div> + <header>%fa:comments R% タイムライン<div><span></span><span></span><span></span></div></header> + <mk-welcome-timeline/> + </div> + </div> + </div> + </div> + </main> + <mk-forkit/> + <footer> + <div> + <mk-nav :class="$style.nav"/> + <p class="c">{{ copyright }}</p> + </div> + </footer> + <modal name="signup" width="500px" height="auto" scrollable> + <header :class="$style.signupFormHeader">新規登録</header> + <mk-signup :class="$style.signupForm"/> + </modal> + <modal name="signin" width="500px" height="auto" scrollable> + <header :class="$style.signinFormHeader">ログイン</header> + <mk-signin :class="$style.signinForm"/> + </modal> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { docsUrl, copyright, lang } from '../../../config'; +import getAcct from '../../../../../common/user/get-acct'; + +const shares = [ + 'Everything!', + 'Webpages', + 'Photos', + 'Interests', + 'Favorites' +]; + +export default Vue.extend({ + data() { + return { + aboutUrl: `${docsUrl}/${lang}/about`, + copyright, + users: [], + clock: null, + i: 0 + }; + }, + mounted() { + (this as any).api('users', { + sort: '+follower', + limit: 20 + }).then(users => { + this.users = users; + }); + + this.clock = setInterval(() => { + if (++this.i == shares.length) this.i = 0; + const speed = 70; + const text = (this.$refs.share as any).innerText; + for (let i = 0; i < text.length; i++) { + setTimeout(() => { + if (this.$refs.share) { + (this.$refs.share as any).innerText = text.substr(0, text.length - i); + } + }, i * speed) + } + setTimeout(() => { + const newText = shares[this.i]; + for (let i = 0; i <= newText.length; i++) { + setTimeout(() => { + if (this.$refs.share) { + (this.$refs.share as any).innerText = newText.substr(0, i); + } + }, i * speed) + } + }, text.length * speed); + }, 4000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + getAcct, + signup() { + this.$modal.show('signup'); + }, + signin() { + this.$modal.show('signin'); + } + } +}); +</script> + +<style> +#wait { + right: auto; + left: 15px; +} +</style> + +<style lang="stylus" scoped> +@import '~const.styl' + +@import url('https://fonts.googleapis.com/css?family=Sarpanch:700') + +.mk-welcome + display flex + flex-direction column + flex 1 + $width = 1000px + + background-image url('/assets/welcome-bg.svg') + background-size cover + background-position top center + + &:before + content "" + display block + position fixed + bottom 0 + left 0 + width 100% + height 100% + background-image url('/assets/welcome-fg.svg') + background-size cover + background-position bottom center + + > main + display flex + flex 1 + + > .top + display flex + width 100% + + > div + display flex + max-width $width + 64px + margin 0 auto + padding 80px 32px 0 32px + + > * + margin-bottom 48px + + > div:first-child + margin-right 48px + color #fff + text-shadow 0 0 12px #172062 + + > h1 + margin 0 + font-weight bold + //font-variant small-caps + letter-spacing 12px + font-family 'Sarpanch', sans-serif + font-size 42px + line-height 48px + + > .cursor + animation cursor 1s infinite linear both + + @keyframes cursor + 0% + opacity 1 + 50% + opacity 0 + + > p + margin 1em 0 + line-height 2em + + button + padding 8px 16px + font-size inherit + + .signup + color $theme-color + border solid 2px $theme-color + border-radius 4px + + &:focus + box-shadow 0 0 0 3px rgba($theme-color, 0.2) + + &:hover + color $theme-color-foreground + background $theme-color + + &:active + color $theme-color-foreground + background darken($theme-color, 10%) + border-color darken($theme-color, 10%) + + .signin + &:hover + color #fff + + > .users + margin 16px 0 0 0 + + > * + display inline-block + margin 4px + + > * + display inline-block + width 38px + height 38px + vertical-align top + border-radius 6px + + > div:last-child + + > div + width 410px + background #fff + border-radius 8px + box-shadow 0 0 0 12px rgba(0, 0, 0, 0.1) + overflow hidden + + > header + z-index 1 + padding 12px 16px + color #888d94 + box-shadow 0 1px 0px rgba(0, 0, 0, 0.1) + + > div + position absolute + top 0 + right 0 + padding inherit + + > span + display inline-block + height 11px + width 11px + margin-left 6px + background #ccc + border-radius 100% + vertical-align middle + + &:nth-child(1) + background #5BCC8B + + &:nth-child(2) + background #E6BB46 + + &:nth-child(3) + background #DF7065 + + > .mk-welcome-timeline + max-height 350px + overflow auto + + > footer + font-size 12px + color #949ea5 + + > div + max-width $width + margin 0 auto + padding 0 0 42px 0 + text-align center + + > .c + margin 16px 0 0 0 + font-size 10px + opacity 0.7 + +</style> + +<style lang="stylus" module> +.signupForm + padding 24px 48px 48px 48px + +.signupFormHeader + padding 48px 0 12px 0 + margin: 0 48px + font-size 1.5em + color #777 + border-bottom solid 1px #eee + +.signinForm + padding 24px 48px 48px 48px + +.signinFormHeader + padding 48px 0 12px 0 + margin: 0 48px + font-size 1.5em + color #777 + border-bottom solid 1px #eee + +.nav + a + color #666 +</style> + +<style lang="stylus"> +html +body + background linear-gradient(to bottom, #1e1d65, #bd6659) +</style> diff --git a/src/client/app/desktop/views/widgets/activity.vue b/src/client/app/desktop/views/widgets/activity.vue new file mode 100644 index 0000000000..0bdf4622af --- /dev/null +++ b/src/client/app/desktop/views/widgets/activity.vue @@ -0,0 +1,31 @@ +<template> +<mk-activity + :design="props.design" + :init-view="props.view" + :user="os.i" + @view-changed="viewChanged"/> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'activity', + props: () => ({ + design: 0, + view: 0 + }) +}).extend({ + methods: { + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + }, + viewChanged(view) { + this.props.view = view; + } + } +}); +</script> diff --git a/src/client/app/desktop/views/widgets/channel.channel.form.vue b/src/client/app/desktop/views/widgets/channel.channel.form.vue new file mode 100644 index 0000000000..aaf327f1ef --- /dev/null +++ b/src/client/app/desktop/views/widgets/channel.channel.form.vue @@ -0,0 +1,67 @@ +<template> +<div class="form"> + <input v-model="text" :disabled="wait" @keydown="onKeydown" placeholder="書いて"> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + text: '', + wait: false + }; + }, + methods: { + onKeydown(e) { + if (e.which == 10 || e.which == 13) this.post(); + }, + post() { + this.wait = true; + + let reply = null; + + if (/^>>([0-9]+) /.test(this.text)) { + const index = this.text.match(/^>>([0-9]+) /)[1]; + reply = (this.$parent as any).posts.find(p => p.index.toString() == index); + this.text = this.text.replace(/^>>([0-9]+) /, ''); + } + + (this as any).api('posts/create', { + text: this.text, + replyId: reply ? reply.id : undefined, + channelId: (this.$parent as any).channel.id + }).then(data => { + this.text = ''; + }).catch(err => { + alert('失敗した'); + }).then(() => { + this.wait = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.form + width 100% + height 38px + padding 4px + border-top solid 1px #ddd + + > input + padding 0 8px + width 100% + height 100% + font-size 14px + color #55595c + border solid 1px #dadada + border-radius 4px + + &:hover + &:focus + border-color #aeaeae + +</style> diff --git a/src/client/app/desktop/views/widgets/channel.channel.post.vue b/src/client/app/desktop/views/widgets/channel.channel.post.vue new file mode 100644 index 0000000000..433f9a00aa --- /dev/null +++ b/src/client/app/desktop/views/widgets/channel.channel.post.vue @@ -0,0 +1,71 @@ +<template> +<div class="post"> + <header> + <a class="index" @click="reply">{{ post.index }}:</a> + <router-link class="name" :to="`/@${acct}`" v-user-preview="post.user.id"><b>{{ post.user.name }}</b></router-link> + <span>ID:<i>{{ acct }}</i></span> + </header> + <div> + <a v-if="post.reply">>>{{ post.reply.index }}</a> + {{ post.text }} + <div class="media" v-if="post.media"> + <a v-for="file in post.media" :href="file.url" target="_blank"> + <img :src="`${file.url}?thumbnail&size=512`" :alt="file.name" :title="file.name"/> + </a> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + } + }, + methods: { + reply() { + this.$emit('reply', this.post); + } + } +}); +</script> + +<style lang="stylus" scoped> +.post + margin 0 + padding 0 + color #444 + + > header + position -webkit-sticky + position sticky + z-index 1 + top 0 + padding 8px 4px 4px 16px + background rgba(255, 255, 255, 0.9) + + > .index + margin-right 0.25em + + > .name + margin-right 0.5em + color #008000 + + > div + padding 0 16px 16px 16px + + > .media + > a + display inline-block + + > img + max-width 100% + vertical-align bottom + +</style> diff --git a/src/client/app/desktop/views/widgets/channel.channel.vue b/src/client/app/desktop/views/widgets/channel.channel.vue new file mode 100644 index 0000000000..e9fb9e3fd7 --- /dev/null +++ b/src/client/app/desktop/views/widgets/channel.channel.vue @@ -0,0 +1,106 @@ +<template> +<div class="channel"> + <p v-if="fetching">読み込み中<mk-ellipsis/></p> + <div v-if="!fetching" ref="posts" class="posts"> + <p v-if="posts.length == 0">まだ投稿がありません</p> + <x-post class="post" v-for="post in posts.slice().reverse()" :post="post" :key="post.id" @reply="reply"/> + </div> + <x-form class="form" ref="form"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import ChannelStream from '../../../common/scripts/streaming/channel'; +import XForm from './channel.channel.form.vue'; +import XPost from './channel.channel.post.vue'; + +export default Vue.extend({ + components: { + XForm, + XPost + }, + props: ['channel'], + data() { + return { + fetching: true, + posts: [], + connection: null + }; + }, + watch: { + channel() { + this.zap(); + } + }, + mounted() { + this.zap(); + }, + beforeDestroy() { + this.disconnect(); + }, + methods: { + zap() { + this.fetching = true; + + (this as any).api('channels/posts', { + channelId: this.channel.id + }).then(posts => { + this.posts = posts; + this.fetching = false; + + this.$nextTick(() => { + this.scrollToBottom(); + }); + + this.disconnect(); + this.connection = new ChannelStream((this as any).os, this.channel.id); + this.connection.on('post', this.onPost); + }); + }, + disconnect() { + if (this.connection) { + this.connection.off('post', this.onPost); + this.connection.close(); + } + }, + onPost(post) { + this.posts.unshift(post); + this.scrollToBottom(); + }, + scrollToBottom() { + (this.$refs.posts as any).scrollTop = (this.$refs.posts as any).scrollHeight; + }, + reply(post) { + (this.$refs.form as any).text = `>>${ post.index } `; + } + } +}); +</script> + +<style lang="stylus" scoped> +.channel + + > p + margin 0 + padding 16px + text-align center + color #aaa + + > .posts + height calc(100% - 38px) + overflow auto + font-size 0.9em + + > .post + border-bottom solid 1px #eee + + &:last-child + border-bottom none + + > .form + position absolute + left 0 + bottom 0 + +</style> diff --git a/src/client/app/desktop/views/widgets/channel.vue b/src/client/app/desktop/views/widgets/channel.vue new file mode 100644 index 0000000000..c9b62dfeab --- /dev/null +++ b/src/client/app/desktop/views/widgets/channel.vue @@ -0,0 +1,107 @@ +<template> +<div class="mkw-channel"> + <template v-if="!props.compact"> + <p class="title">%fa:tv%{{ channel ? channel.title : '%i18n:desktop.tags.mk-channel-home-widget.title%' }}</p> + <button @click="settings" title="%i18n:desktop.tags.mk-channel-home-widget.settings%">%fa:cog%</button> + </template> + <p class="get-started" v-if="props.channel == null">%i18n:desktop.tags.mk-channel-home-widget.get-started%</p> + <x-channel class="channel" :channel="channel" v-if="channel != null"/> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import XChannel from './channel.channel.vue'; + +export default define({ + name: 'server', + props: () => ({ + channel: null, + compact: false + }) +}).extend({ + components: { + XChannel + }, + data() { + return { + fetching: true, + channel: null + }; + }, + mounted() { + if (this.props.channel) { + this.zap(); + } + }, + methods: { + func() { + this.props.compact = !this.props.compact; + }, + settings() { + const id = window.prompt('チャンネルID'); + if (!id) return; + this.props.channel = id; + this.zap(); + }, + zap() { + this.fetching = true; + + (this as any).api('channels/show', { + channelId: this.props.channel + }).then(channel => { + this.channel = channel; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-channel + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + + > .title + z-index 2 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .get-started + margin 0 + padding 16px + text-align center + color #aaa + + > .channel + height 200px + +</style> diff --git a/src/client/app/desktop/views/widgets/index.ts b/src/client/app/desktop/views/widgets/index.ts new file mode 100644 index 0000000000..77d771d6b3 --- /dev/null +++ b/src/client/app/desktop/views/widgets/index.ts @@ -0,0 +1,23 @@ +import Vue from 'vue'; + +import wNotifications from './notifications.vue'; +import wTimemachine from './timemachine.vue'; +import wActivity from './activity.vue'; +import wTrends from './trends.vue'; +import wUsers from './users.vue'; +import wPolls from './polls.vue'; +import wPostForm from './post-form.vue'; +import wMessaging from './messaging.vue'; +import wChannel from './channel.vue'; +import wProfile from './profile.vue'; + +Vue.component('mkw-notifications', wNotifications); +Vue.component('mkw-timemachine', wTimemachine); +Vue.component('mkw-activity', wActivity); +Vue.component('mkw-trends', wTrends); +Vue.component('mkw-users', wUsers); +Vue.component('mkw-polls', wPolls); +Vue.component('mkw-post-form', wPostForm); +Vue.component('mkw-messaging', wMessaging); +Vue.component('mkw-channel', wChannel); +Vue.component('mkw-profile', wProfile); diff --git a/src/client/app/desktop/views/widgets/messaging.vue b/src/client/app/desktop/views/widgets/messaging.vue new file mode 100644 index 0000000000..2c9f473bd1 --- /dev/null +++ b/src/client/app/desktop/views/widgets/messaging.vue @@ -0,0 +1,59 @@ +<template> +<div class="mkw-messaging"> + <p class="title" v-if="props.design == 0">%fa:comments%%i18n:desktop.tags.mk-messaging-home-widget.title%</p> + <mk-messaging ref="index" compact @navigate="navigate"/> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import MkMessagingRoomWindow from '../components/messaging-room-window.vue'; + +export default define({ + name: 'messaging', + props: () => ({ + design: 0 + }) +}).extend({ + methods: { + navigate(user) { + (this as any).os.new(MkMessagingRoomWindow, { + user: user + }); + }, + func() { + if (this.props.design == 1) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-messaging + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 2 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + > .mk-messaging + max-height 250px + overflow auto + +</style> diff --git a/src/client/app/desktop/views/widgets/notifications.vue b/src/client/app/desktop/views/widgets/notifications.vue new file mode 100644 index 0000000000..1a2b3d3f89 --- /dev/null +++ b/src/client/app/desktop/views/widgets/notifications.vue @@ -0,0 +1,70 @@ +<template> +<div class="mkw-notifications"> + <template v-if="!props.compact"> + <p class="title">%fa:R bell%%i18n:desktop.tags.mk-notifications-home-widget.title%</p> + <button @click="settings" title="%i18n:desktop.tags.mk-notifications-home-widget.settings%">%fa:cog%</button> + </template> + <mk-notifications/> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'notifications', + props: () => ({ + compact: false + }) +}).extend({ + methods: { + settings() { + alert('not implemented yet'); + }, + func() { + this.props.compact = !this.props.compact; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-notifications + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .mk-notifications + max-height 300px + overflow auto + +</style> diff --git a/src/client/app/desktop/views/widgets/polls.vue b/src/client/app/desktop/views/widgets/polls.vue new file mode 100644 index 0000000000..e5db34fc7a --- /dev/null +++ b/src/client/app/desktop/views/widgets/polls.vue @@ -0,0 +1,129 @@ +<template> +<div class="mkw-polls"> + <template v-if="!props.compact"> + <p class="title">%fa:chart-pie%%i18n:desktop.tags.mk-recommended-polls-home-widget.title%</p> + <button @click="fetch" title="%i18n:desktop.tags.mk-recommended-polls-home-widget.refresh%">%fa:sync%</button> + </template> + <div class="poll" v-if="!fetching && poll != null"> + <p v-if="poll.text"><router-link to="`/@${ acct }/${ poll.id }`">{{ poll.text }}</router-link></p> + <p v-if="!poll.text"><router-link to="`/@${ acct }/${ poll.id }`">%fa:link%</router-link></p> + <mk-poll :post="poll"/> + </div> + <p class="empty" v-if="!fetching && poll == null">%i18n:desktop.tags.mk-recommended-polls-home-widget.nothing%</p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import getAcct from '../../../../../common/user/get-acct'; + +export default define({ + name: 'polls', + props: () => ({ + compact: false + }) +}).extend({ + computed: { + acct() { + return getAcct(this.poll.user); + }, + }, + data() { + return { + poll: null, + fetching: true, + offset: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + func() { + this.props.compact = !this.props.compact; + }, + fetch() { + this.fetching = true; + this.poll = null; + + (this as any).api('posts/polls/recommendation', { + limit: 1, + offset: this.offset + }).then(posts => { + const poll = posts ? posts[0] : null; + if (poll == null) { + this.offset = 0; + } else { + this.offset++; + } + this.poll = poll; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-polls + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + border-bottom solid 1px #eee + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .poll + padding 16px + font-size 12px + color #555 + + > p + margin 0 0 8px 0 + + > a + color inherit + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/widgets/post-form.vue b/src/client/app/desktop/views/widgets/post-form.vue new file mode 100644 index 0000000000..cf7fd1f2b2 --- /dev/null +++ b/src/client/app/desktop/views/widgets/post-form.vue @@ -0,0 +1,111 @@ +<template> +<div class="mkw-post-form"> + <template v-if="props.design == 0"> + <p class="title">%fa:pencil-alt%%i18n:desktop.tags.mk-post-form-home-widget.title%</p> + </template> + <textarea :disabled="posting" v-model="text" @keydown="onKeydown" placeholder="%i18n:desktop.tags.mk-post-form-home-widget.placeholder%"></textarea> + <button @click="post" :disabled="posting">%i18n:desktop.tags.mk-post-form-home-widget.post%</button> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'post-form', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + posting: false, + text: '' + }; + }, + methods: { + func() { + if (this.props.design == 1) { + this.props.design = 0; + } else { + this.props.design++; + } + }, + onKeydown(e) { + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); + }, + post() { + this.posting = true; + + (this as any).api('posts/create', { + text: this.text + }).then(data => { + this.clear(); + }).catch(err => { + alert('失敗した'); + }).then(() => { + this.posting = false; + }); + }, + clear() { + this.text = ''; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mkw-post-form + background #fff + overflow hidden + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + > textarea + display block + width 100% + max-width 100% + min-width 100% + padding 16px + margin-bottom 28px + 16px + border none + border-bottom solid 1px #eee + + > button + display block + position absolute + bottom 8px + right 8px + margin 0 + padding 0 10px + height 28px + color $theme-color-foreground + background $theme-color !important + outline none + border none + border-radius 4px + transition background 0.1s ease + cursor pointer + + &:hover + background lighten($theme-color, 10%) !important + + &:active + background darken($theme-color, 10%) !important + transition background 0s ease + +</style> diff --git a/src/client/app/desktop/views/widgets/profile.vue b/src/client/app/desktop/views/widgets/profile.vue new file mode 100644 index 0000000000..83cd67b50c --- /dev/null +++ b/src/client/app/desktop/views/widgets/profile.vue @@ -0,0 +1,125 @@ +<template> +<div class="mkw-profile" + :data-compact="props.design == 1 || props.design == 2" + :data-melt="props.design == 2" +> + <div class="banner" + :style="os.i.bannerUrl ? `background-image: url(${os.i.bannerUrl}?thumbnail&size=256)` : ''" + title="クリックでバナー編集" + @click="os.apis.updateBanner" + ></div> + <img class="avatar" + :src="`${os.i.avatarUrl}?thumbnail&size=96`" + @click="os.apis.updateAvatar" + alt="avatar" + title="クリックでアバター編集" + v-user-preview="os.i.id" + /> + <router-link class="name" :to="`/@${os.i.username}`">{{ os.i.name }}</router-link> + <p class="username">@{{ os.i.username }}</p> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'profile', + props: () => ({ + design: 0 + }) +}).extend({ + methods: { + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-profile + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-compact] + > .banner:before + content "" + display block + width 100% + height 100% + background rgba(0, 0, 0, 0.5) + + > .avatar + top ((100px - 58px) / 2) + left ((100px - 58px) / 2) + border none + border-radius 100% + box-shadow 0 0 16px rgba(0, 0, 0, 0.5) + + > .name + position absolute + top 0 + left 92px + margin 0 + line-height 100px + color #fff + text-shadow 0 0 8px rgba(0, 0, 0, 0.5) + + > .username + display none + + &[data-melt] + background transparent !important + border none !important + + > .banner + visibility hidden + + > .avatar + box-shadow none + + > .name + color #666 + text-shadow none + + > .banner + height 100px + background-color #f5f5f5 + background-size cover + background-position center + cursor pointer + + > .avatar + display block + position absolute + top 76px + left 16px + width 58px + height 58px + margin 0 + border solid 3px #fff + border-radius 8px + vertical-align bottom + cursor pointer + + > .name + display block + margin 10px 0 0 84px + line-height 16px + font-weight bold + color #555 + + > .username + display block + margin 4px 0 8px 84px + line-height 16px + font-size 0.9em + color #999 + +</style> diff --git a/src/client/app/desktop/views/widgets/timemachine.vue b/src/client/app/desktop/views/widgets/timemachine.vue new file mode 100644 index 0000000000..6db3b14c62 --- /dev/null +++ b/src/client/app/desktop/views/widgets/timemachine.vue @@ -0,0 +1,28 @@ +<template> +<div class="mkw-timemachine"> + <mk-calendar :design="props.design" @chosen="chosen"/> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'timemachine', + props: () => ({ + design: 0 + }) +}).extend({ + methods: { + chosen(date) { + this.$emit('chosen', date); + }, + func() { + if (this.props.design == 5) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> diff --git a/src/client/app/desktop/views/widgets/trends.vue b/src/client/app/desktop/views/widgets/trends.vue new file mode 100644 index 0000000000..77779787ee --- /dev/null +++ b/src/client/app/desktop/views/widgets/trends.vue @@ -0,0 +1,135 @@ +<template> +<div class="mkw-trends"> + <template v-if="!props.compact"> + <p class="title">%fa:fire%%i18n:desktop.tags.mk-trends-home-widget.title%</p> + <button @click="fetch" title="%i18n:desktop.tags.mk-trends-home-widget.refresh%">%fa:sync%</button> + </template> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <div class="post" v-else-if="post != null"> + <p class="text"><router-link :to="`/@${ acct }/${ post.id }`">{{ post.text }}</router-link></p> + <p class="author">―<router-link :to="`/@${ acct }`">@{{ acct }}</router-link></p> + </div> + <p class="empty" v-else>%i18n:desktop.tags.mk-trends-home-widget.nothing%</p> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import getAcct from '../../../../../common/user/get-acct'; + +export default define({ + name: 'trends', + props: () => ({ + compact: false + }) +}).extend({ + computed: { + acct() { + return getAcct(this.post.user); + }, + }, + data() { + return { + post: null, + fetching: true, + offset: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + func() { + this.props.compact = !this.props.compact; + }, + fetch() { + this.fetching = true; + this.post = null; + + (this as any).api('posts/trend', { + limit: 1, + offset: this.offset, + repost: false, + reply: false, + media: false, + poll: false + }).then(posts => { + const post = posts ? posts[0] : null; + if (post == null) { + this.offset = 0; + } else { + this.offset++; + } + this.post = post; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-trends + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + border-bottom solid 1px #eee + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .post + padding 16px + font-size 12px + font-style oblique + color #555 + + > p + margin 0 + + > .text, + > .author + > a + color inherit + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/widgets/users.vue b/src/client/app/desktop/views/widgets/users.vue new file mode 100644 index 0000000000..7b89441126 --- /dev/null +++ b/src/client/app/desktop/views/widgets/users.vue @@ -0,0 +1,172 @@ +<template> +<div class="mkw-users"> + <template v-if="!props.compact"> + <p class="title">%fa:users%%i18n:desktop.tags.mk-user-recommendation-home-widget.title%</p> + <button @click="refresh" title="%i18n:desktop.tags.mk-user-recommendation-home-widget.refresh%">%fa:sync%</button> + </template> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <template v-else-if="users.length != 0"> + <div class="user" v-for="_user in users"> + <router-link class="avatar-anchor" :to="`/@${getAcct(_user)}`"> + <img class="avatar" :src="`${_user.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="_user.id"/> + </router-link> + <div class="body"> + <router-link class="name" :to="`/@${getAcct(_user)}`" v-user-preview="_user.id">{{ _user.name }}</router-link> + <p class="username">@{{ getAcct(_user) }}</p> + </div> + <mk-follow-button :user="_user"/> + </div> + </template> + <p class="empty" v-else>%i18n:desktop.tags.mk-user-recommendation-home-widget.no-one%</p> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import getAcct from '../../../../../common/user/get-acct'; + +const limit = 3; + +export default define({ + name: 'users', + props: () => ({ + compact: false + }) +}).extend({ + data() { + return { + users: [], + fetching: true, + page: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + getAcct, + func() { + this.props.compact = !this.props.compact; + }, + fetch() { + this.fetching = true; + this.users = []; + + (this as any).api('users/recommendation', { + limit: limit, + offset: limit * this.page + }).then(users => { + this.users = users; + this.fetching = false; + }); + }, + refresh() { + if (this.users.length < limit) { + this.page = 0; + } else { + this.page++; + } + this.fetch(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-users + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + border-bottom solid 1px #eee + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .user + padding 16px + border-bottom solid 1px #eee + + &:last-child + border-bottom none + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 42px + height 42px + margin 0 + border-radius 8px + vertical-align bottom + + > .body + float left + width calc(100% - 54px) + + > .name + margin 0 + font-size 16px + line-height 24px + color #555 + + > .username + display block + margin 0 + font-size 15px + line-height 16px + color #ccc + + > .mk-follow-button + position absolute + top 16px + right 16px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/dev/script.ts b/src/client/app/dev/script.ts new file mode 100644 index 0000000000..c043813b40 --- /dev/null +++ b/src/client/app/dev/script.ts @@ -0,0 +1,44 @@ +/** + * Developer Center + */ + +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import BootstrapVue from 'bootstrap-vue'; +import 'bootstrap/dist/css/bootstrap.css'; +import 'bootstrap-vue/dist/bootstrap-vue.css'; + +// Style +import './style.styl'; + +import init from '../init'; + +import Index from './views/index.vue'; +import Apps from './views/apps.vue'; +import AppNew from './views/new-app.vue'; +import App from './views/app.vue'; +import ui from './views/ui.vue'; + +Vue.use(BootstrapVue); + +Vue.component('mk-ui', ui); + +/** + * init + */ +init(launch => { + // Init router + const router = new VueRouter({ + mode: 'history', + base: '/dev/', + routes: [ + { path: '/', component: Index }, + { path: '/apps', component: Apps }, + { path: '/app/new', component: AppNew }, + { path: '/app/:id', component: App }, + ] + }); + + // Launch the app + launch(router); +}); diff --git a/src/client/app/dev/style.styl b/src/client/app/dev/style.styl new file mode 100644 index 0000000000..e635897b17 --- /dev/null +++ b/src/client/app/dev/style.styl @@ -0,0 +1,10 @@ +@import "../app" +@import "../reset" + +// Bootstrapのデザインを崩すので: +* + position initial + background-clip initial !important + +html + background-color #fff diff --git a/src/client/app/dev/views/app.vue b/src/client/app/dev/views/app.vue new file mode 100644 index 0000000000..a35b032b73 --- /dev/null +++ b/src/client/app/dev/views/app.vue @@ -0,0 +1,39 @@ +<template> +<mk-ui> + <p v-if="fetching">読み込み中</p> + <b-card v-if="!fetching" :header="app.name"> + <b-form-group label="App Secret"> + <b-input :value="app.secret" readonly/> + </b-form-group> + </b-card> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + app: null + }; + }, + watch: { + $route: 'fetch' + }, + mounted() { + this.fetch(); + }, + methods: { + fetch() { + this.fetching = true; + (this as any).api('app/show', { + appId: this.$route.params.id + }).then(app => { + this.app = app; + this.fetching = false; + }); + } + } +}); +</script> diff --git a/src/client/app/dev/views/apps.vue b/src/client/app/dev/views/apps.vue new file mode 100644 index 0000000000..7e0b107a30 --- /dev/null +++ b/src/client/app/dev/views/apps.vue @@ -0,0 +1,37 @@ +<template> +<mk-ui> + <b-card header="アプリを管理"> + <b-button to="/app/new" variant="primary">アプリ作成</b-button> + <hr> + <div class="apps"> + <p v-if="fetching">読み込み中</p> + <template v-if="!fetching"> + <b-alert v-if="apps.length == 0">アプリなし</b-alert> + <b-list-group v-else> + <b-list-group-item v-for="app in apps" :key="app.id" :to="`/app/${app.id}`"> + {{ app.name }} + </b-list-group-item> + </b-list-group> + </template> + </div> + </b-card> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + apps: [] + }; + }, + mounted() { + (this as any).api('my/apps').then(apps => { + this.apps = apps; + this.fetching = false; + }); + } +}); +</script> diff --git a/src/client/app/dev/views/index.vue b/src/client/app/dev/views/index.vue new file mode 100644 index 0000000000..3f572b3907 --- /dev/null +++ b/src/client/app/dev/views/index.vue @@ -0,0 +1,10 @@ +<template> +<mk-ui> + <b-button to="/apps" variant="primary">アプリの管理</b-button> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend(); +</script> diff --git a/src/client/app/dev/views/new-app.vue b/src/client/app/dev/views/new-app.vue new file mode 100644 index 0000000000..e407ca00d7 --- /dev/null +++ b/src/client/app/dev/views/new-app.vue @@ -0,0 +1,105 @@ +<template> +<mk-ui> + <b-card header="アプリケーションの作成"> + <b-form @submit.prevent="onSubmit" autocomplete="off"> + <b-form-group label="アプリケーション名" description="あなたのアプリの名称。"> + <b-form-input v-model="name" type="text" placeholder="ex) Misskey for iOS" autocomplete="off" required/> + </b-form-group> + <b-form-group label="ID" description="あなたのアプリのID。"> + <b-input v-model="nid" type="text" pattern="^[a-zA-Z0-9_]{3,30}$" placeholder="ex) misskey-for-ios" autocomplete="off" required/> + <p class="info" v-if="nidState == 'wait'" style="color:#999">%fa:spinner .pulse .fw%確認しています...</p> + <p class="info" v-if="nidState == 'ok'" style="color:#3CB7B5">%fa:fw check%利用できます</p> + <p class="info" v-if="nidState == 'unavailable'" style="color:#FF1161">%fa:fw exclamation-triangle%既に利用されています</p> + <p class="info" v-if="nidState == 'error'" style="color:#FF1161">%fa:fw exclamation-triangle%通信エラー</p> + <p class="info" v-if="nidState == 'invalid-format'" style="color:#FF1161">%fa:fw exclamation-triangle%a~z、A~Z、0~9、_が使えます</p> + <p class="info" v-if="nidState == 'min-range'" style="color:#FF1161">%fa:fw exclamation-triangle%3文字以上でお願いします!</p> + <p class="info" v-if="nidState == 'max-range'" style="color:#FF1161">%fa:fw exclamation-triangle%30文字以内でお願いします</p> + </b-form-group> + <b-form-group label="アプリの概要" description="あなたのアプリの簡単な説明や紹介。"> + <b-textarea v-model="description" placeholder="ex) Misskey iOSクライアント。" autocomplete="off" required></b-textarea> + </b-form-group> + <b-form-group label="コールバックURL (オプション)" description="ユーザーが認証フォームで認証した際にリダイレクトするURLを設定できます。"> + <b-input v-model="cb" type="url" placeholder="ex) https://your.app.example.com/callback.php" autocomplete="off"/> + </b-form-group> + <b-card header="権限"> + <b-form-group description="ここで要求した機能だけがAPIからアクセスできます。"> + <b-alert show variant="warning">%fa:exclamation-triangle%アプリ作成後も変更できますが、新たな権限を付与する場合、その時点で関連付けられているユーザーキーはすべて無効になります。</b-alert> + <b-form-checkbox-group v-model="permission" stacked> + <b-form-checkbox value="account-read">アカウントの情報を見る。</b-form-checkbox> + <b-form-checkbox value="account-write">アカウントの情報を操作する。</b-form-checkbox> + <b-form-checkbox value="post-write">投稿する。</b-form-checkbox> + <b-form-checkbox value="reaction-write">リアクションしたりリアクションをキャンセルする。</b-form-checkbox> + <b-form-checkbox value="following-write">フォローしたりフォロー解除する。</b-form-checkbox> + <b-form-checkbox value="drive-read">ドライブを見る。</b-form-checkbox> + <b-form-checkbox value="drive-write">ドライブを操作する。</b-form-checkbox> + <b-form-checkbox value="notification-read">通知を見る。</b-form-checkbox> + <b-form-checkbox value="notification-write">通知を操作する。</b-form-checkbox> + </b-form-checkbox-group> + </b-form-group> + </b-card> + <hr> + <b-button type="submit" variant="primary">アプリ作成</b-button> + </b-form> + </b-card> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + name: '', + nid: '', + description: '', + cb: '', + nidState: null, + permission: [] + }; + }, + watch: { + nid() { + if (this.nid == null || this.nid == '') { + this.nidState = null; + return; + } + + const err = + !this.nid.match(/^[a-zA-Z0-9\-]+$/) ? 'invalid-format' : + this.nid.length < 3 ? 'min-range' : + this.nid.length > 30 ? 'max-range' : + null; + + if (err) { + this.nidState = err; + return; + } + + this.nidState = 'wait'; + + (this as any).api('app/nameId/available', { + nameId: this.nid + }).then(result => { + this.nidState = result.available ? 'ok' : 'unavailable'; + }).catch(err => { + this.nidState = 'error'; + }); + } + }, + methods: { + onSubmit() { + (this as any).api('app/create', { + name: this.name, + nameId: this.nid, + description: this.description, + callbackUrl: this.cb, + permission: this.permission + }).then(() => { + location.href = '/apps'; + }).catch(() => { + alert('アプリの作成に失敗しました。再度お試しください。'); + }); + } + } +}); +</script> diff --git a/src/client/app/dev/views/ui.vue b/src/client/app/dev/views/ui.vue new file mode 100644 index 0000000000..4a0fcee635 --- /dev/null +++ b/src/client/app/dev/views/ui.vue @@ -0,0 +1,20 @@ +<template> +<div> + <b-navbar toggleable="md" type="dark" variant="info"> + <b-navbar-brand>Misskey Developers</b-navbar-brand> + <b-navbar-nav> + <b-nav-item to="/">Home</b-nav-item> + <b-nav-item to="/apps">Apps</b-nav-item> + </b-navbar-nav> + </b-navbar> + <main> + <slot></slot> + </main> +</div> +</template> + +<style lang="stylus" scoped> +main + padding 32px + max-width 700px +</style> diff --git a/src/client/app/init.css b/src/client/app/init.css new file mode 100644 index 0000000000..2587f63943 --- /dev/null +++ b/src/client/app/init.css @@ -0,0 +1,66 @@ +/** + * Boot screen style + */ + +@charset 'utf-8'; + +html { + font-family: sans-serif; +} + +body > noscript { + position: fixed; + z-index: 2; + top: 0; + left: 0; + width: 100%; + height: 100%; + text-align: center; + background: #fff; +} + body > noscript > p { + display: block; + margin: 32px; + font-size: 2em; + color: #555; + } + +#ini { + position: fixed; + z-index: 1; + top: 0; + left: 0; + width: 100%; + height: 100%; + text-align: center; + background: #fff; + cursor: wait; +} + #ini > p { + display: block; + user-select: none; + margin: 32px; + font-size: 4em; + color: #555; + } + #ini > p > span { + animation: ini 1.4s infinite ease-in-out both; + } + #ini > p > span:nth-child(1) { + animation-delay: 0s; + } + #ini > p > span:nth-child(2) { + animation-delay: 0.16s; + } + #ini > p > span:nth-child(3) { + animation-delay: 0.32s; + } + +@keyframes ini { + 0%, 80%, 100% { + opacity: 1; + } + 40% { + opacity: 0; + } +} diff --git a/src/client/app/init.ts b/src/client/app/init.ts new file mode 100644 index 0000000000..3e5c38961f --- /dev/null +++ b/src/client/app/init.ts @@ -0,0 +1,172 @@ +/** + * App initializer + */ + +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import VModal from 'vue-js-modal'; +import * as TreeView from 'vue-json-tree-view'; +import VAnimateCss from 'v-animate-css'; +import Element from 'element-ui'; +import ElementLocaleEn from 'element-ui/lib/locale/lang/en'; +import ElementLocaleJa from 'element-ui/lib/locale/lang/ja'; + +import App from './app.vue'; +import checkForUpdate from './common/scripts/check-for-update'; +import MiOS, { API } from './common/mios'; +import { version, codename, hostname, lang } from './config'; + +let elementLocale; +switch (lang) { + case 'ja': elementLocale = ElementLocaleJa; break; + case 'en': elementLocale = ElementLocaleEn; break; + default: elementLocale = ElementLocaleEn; break; +} + +Vue.use(VueRouter); +Vue.use(VModal); +Vue.use(TreeView); +Vue.use(VAnimateCss); +Vue.use(Element, { locale: elementLocale }); + +// Register global directives +require('./common/views/directives'); + +// Register global components +require('./common/views/components'); +require('./common/views/widgets'); + +// Register global filters +require('./common/views/filters'); + +Vue.mixin({ + destroyed(this: any) { + if (this.$el.parentNode) { + this.$el.parentNode.removeChild(this.$el); + } + } +}); + +/** + * APP ENTRY POINT! + */ + +console.info(`Misskey v${version} (${codename})`); +console.info( + '%cここにコードを入力したり張り付けたりしないでください。アカウントが不正利用される可能性があります。', + 'color: red; background: yellow; font-size: 16px; font-weight: bold;'); + +// BootTimer解除 +window.clearTimeout((window as any).mkBootTimer); +delete (window as any).mkBootTimer; + +if (hostname != 'localhost') { + document.domain = hostname; +} + +//#region Set lang attr +const html = document.documentElement; +html.setAttribute('lang', lang); +//#endregion + +//#region Set description meta tag +const head = document.getElementsByTagName('head')[0]; +const meta = document.createElement('meta'); +meta.setAttribute('name', 'description'); +meta.setAttribute('content', '%i18n:common.misskey%'); +head.appendChild(meta); +//#endregion + +// iOSでプライベートモードだとlocalStorageが使えないので既存のメソッドを上書きする +try { + localStorage.setItem('kyoppie', 'yuppie'); +} catch (e) { + Storage.prototype.setItem = () => { }; // noop +} + +// クライアントを更新すべきならする +if (localStorage.getItem('should-refresh') == 'true') { + localStorage.removeItem('should-refresh'); + location.reload(true); +} + +// MiOSを初期化してコールバックする +export default (callback: (launch: (router: VueRouter, api?: (os: MiOS) => API) => [Vue, MiOS]) => void, sw = false) => { + const os = new MiOS(sw); + + os.init(() => { + // アプリ基底要素マウント + document.body.innerHTML = '<div id="app"></div>'; + + const launch = (router: VueRouter, api?: (os: MiOS) => API) => { + os.apis = api ? api(os) : null; + + Vue.mixin({ + data() { + return { + os, + api: os.api, + apis: os.apis + }; + } + }); + + const app = new Vue({ + router, + created() { + this.$watch('os.i', i => { + // キャッシュ更新 + localStorage.setItem('me', JSON.stringify(i)); + }, { + deep: true + }); + }, + render: createEl => createEl(App) + }); + + os.app = app; + + // マウント + app.$mount('#app'); + + return [app, os] as [Vue, MiOS]; + }; + + try { + callback(launch); + } catch (e) { + panic(e); + } + + //#region 更新チェック + const preventUpdate = localStorage.getItem('preventUpdate') == 'true'; + if (!preventUpdate) { + setTimeout(() => { + checkForUpdate(os); + }, 3000); + } + //#endregion + }); +}; + +// BSoD +function panic(e) { + console.error(e); + + // Display blue screen + document.documentElement.style.background = '#1269e2'; + document.body.innerHTML = + '<div id="error">' + + '<h1>:( 致命的な問題が発生しました。</h1>' + + '<p>お使いのブラウザ(またはOS)のバージョンを更新すると解決する可能性があります。</p>' + + '<hr>' + + `<p>エラーコード: ${e.toString()}</p>` + + `<p>ブラウザ バージョン: ${navigator.userAgent}</p>` + + `<p>クライアント バージョン: ${version}</p>` + + '<hr>' + + '<p>問題が解決しない場合は、上記の情報をお書き添えの上 syuilotan@yahoo.co.jp までご連絡ください。</p>' + + '<p>Thank you for using Misskey.</p>' + + '</div>'; + + // TODO: Report the bug +} 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> diff --git a/src/client/app/reset.styl b/src/client/app/reset.styl new file mode 100644 index 0000000000..10bd3113a2 --- /dev/null +++ b/src/client/app/reset.styl @@ -0,0 +1,32 @@ +input:not([type]) +input[type='text'] +input[type='password'] +input[type='search'] +input[type='email'] +textarea +button +progress + -webkit-appearance none + -moz-appearance none + appearance none + box-shadow none + +textarea + font-family sans-serif + +button + margin 0 + background transparent + border none + cursor pointer + color inherit + + * + pointer-events none + + &[disabled] + cursor default + +pre + overflow auto + white-space pre diff --git a/src/client/app/safe.js b/src/client/app/safe.js new file mode 100644 index 0000000000..2fd5361725 --- /dev/null +++ b/src/client/app/safe.js @@ -0,0 +1,31 @@ +/** + * ブラウザの検証 + */ + +// Detect an old browser +if (!('fetch' in window)) { + alert( + 'お使いのブラウザが古いためMisskeyを動作させることができません。' + + 'バージョンを最新のものに更新するか、別のブラウザをお試しください。' + + '\n\n' + + 'Your browser seems outdated. ' + + 'To run Misskey, please update your browser to latest version or try other browsers.'); +} + +// Detect Edge +if (navigator.userAgent.toLowerCase().indexOf('edge') != -1) { + alert( + '現在、お使いのブラウザ(Microsoft Edge)ではMisskeyは正しく動作しません。' + + 'サポートしているブラウザ: Google Chrome, Mozilla Firefox, Apple Safari など' + + '\n\n' + + 'Currently, Misskey cannot run correctly on your browser (Microsoft Edge). ' + + 'Supported browsers: Google Chrome, Mozilla Firefox, Apple Safari, etc'); +} + +// Check whether cookie enabled +if (!navigator.cookieEnabled) { + alert( + 'Misskeyを利用するにはCookieを有効にしてください。' + + '\n\n' + + 'To use Misskey, please enable Cookie.'); +} diff --git a/src/client/app/stats/style.styl b/src/client/app/stats/style.styl new file mode 100644 index 0000000000..5ae230ea56 --- /dev/null +++ b/src/client/app/stats/style.styl @@ -0,0 +1,10 @@ +@import "../app" +@import "../reset" + +html + color #456267 + background #fff + +body + margin 0 + padding 0 diff --git a/src/client/app/stats/tags/index.tag b/src/client/app/stats/tags/index.tag new file mode 100644 index 0000000000..63fdd24044 --- /dev/null +++ b/src/client/app/stats/tags/index.tag @@ -0,0 +1,209 @@ +<mk-index> + <h1>Misskey<i>Statistics</i></h1> + <main v-if="!initializing"> + <mk-users stats={ stats }/> + <mk-posts stats={ stats }/> + </main> + <footer><a href={ _URL_ }>{ _HOST_ }</a></footer> + <style lang="stylus" scoped> + :scope + display block + margin 0 auto + padding 0 16px + max-width 700px + + > h1 + margin 0 + padding 24px 0 0 0 + font-size 24px + font-weight normal + + > i + font-style normal + color #f43b16 + + > main + > * + margin 24px 0 + padding-top 24px + border-top solid 1px #eee + + > h2 + margin 0 0 12px 0 + font-size 18px + font-weight normal + + > footer + margin 24px 0 + text-align center + + > a + color #546567 + </style> + <script lang="typescript"> + this.mixin('api'); + + this.initializing = true; + + this.on('mount', () => { + this.$root.$data.os.api('stats').then(stats => { + this.update({ + initializing: false, + stats + }); + }); + }); + </script> +</mk-index> + +<mk-posts> + <h2>%i18n:stats.posts-count% <b>{ stats.postsCount }</b></h2> + <mk-posts-chart v-if="!initializing" data={ data }/> + <style lang="stylus" scoped> + :scope + display block + </style> + <script lang="typescript"> + this.mixin('api'); + + this.initializing = true; + this.stats = this.opts.stats; + + this.on('mount', () => { + this.$root.$data.os.api('aggregation/posts', { + limit: 365 + }).then(data => { + this.update({ + initializing: false, + data + }); + }); + }); + </script> +</mk-posts> + +<mk-users> + <h2>%i18n:stats.users-count% <b>{ stats.usersCount }</b></h2> + <mk-users-chart v-if="!initializing" data={ data }/> + <style lang="stylus" scoped> + :scope + display block + </style> + <script lang="typescript"> + this.mixin('api'); + + this.initializing = true; + this.stats = this.opts.stats; + + this.on('mount', () => { + this.$root.$data.os.api('aggregation/users', { + limit: 365 + }).then(data => { + this.update({ + initializing: false, + data + }); + }); + }); + </script> +</mk-users> + +<mk-posts-chart> + <svg riot-viewBox="0 0 { viewBoxX } { viewBoxY }" preserveAspectRatio="none"> + <title>Black ... Total<br/>Blue ... Posts<br/>Red ... Replies<br/>Green ... Reposts</title> + <polyline + riot-points={ pointsPost } + fill="none" + stroke-width="1" + stroke="#41ddde"/> + <polyline + riot-points={ pointsReply } + fill="none" + stroke-width="1" + stroke="#f7796c"/> + <polyline + riot-points={ pointsRepost } + fill="none" + stroke-width="1" + stroke="#a1de41"/> + <polyline + riot-points={ pointsTotal } + fill="none" + stroke-width="1" + stroke="#555" + stroke-dasharray="2 2"/> + </svg> + <style lang="stylus" scoped> + :scope + display block + + > svg + display block + padding 1px + width 100% + </style> + <script lang="typescript"> + this.viewBoxX = 365; + this.viewBoxY = 80; + + this.data = this.opts.data.reverse(); + this.data.forEach(d => d.total = d.posts + d.replies + d.reposts); + const peak = Math.max.apply(null, this.data.map(d => d.total)); + + this.on('mount', () => { + this.render(); + }); + + this.render = () => { + this.update({ + pointsPost: this.data.map((d, i) => `${i},${(1 - (d.posts / peak)) * this.viewBoxY}`).join(' '), + pointsReply: this.data.map((d, i) => `${i},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' '), + pointsRepost: this.data.map((d, i) => `${i},${(1 - (d.reposts / peak)) * this.viewBoxY}`).join(' '), + pointsTotal: this.data.map((d, i) => `${i},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' ') + }); + }; + </script> +</mk-posts-chart> + +<mk-users-chart> + <svg riot-viewBox="0 0 { viewBoxX } { viewBoxY }" preserveAspectRatio="none"> + <polyline + riot-points={ createdPoints } + fill="none" + stroke-width="1" + stroke="#1cde84"/> + <polyline + riot-points={ totalPoints } + fill="none" + stroke-width="1" + stroke="#555"/> + </svg> + <style lang="stylus" scoped> + :scope + display block + + > svg + display block + padding 1px + width 100% + </style> + <script lang="typescript"> + this.viewBoxX = 365; + this.viewBoxY = 80; + + this.data = this.opts.data.reverse(); + const totalPeak = Math.max.apply(null, this.data.map(d => d.total)); + const createdPeak = Math.max.apply(null, this.data.map(d => d.created)); + + this.on('mount', () => { + this.render(); + }); + + this.render = () => { + this.update({ + totalPoints: this.data.map((d, i) => `${i},${(1 - (d.total / totalPeak)) * this.viewBoxY}`).join(' '), + createdPoints: this.data.map((d, i) => `${i},${(1 - (d.created / createdPeak)) * this.viewBoxY}`).join(' ') + }); + }; + </script> +</mk-users-chart> diff --git a/src/client/app/stats/tags/index.ts b/src/client/app/stats/tags/index.ts new file mode 100644 index 0000000000..f41151949f --- /dev/null +++ b/src/client/app/stats/tags/index.ts @@ -0,0 +1 @@ +require('./index.tag'); diff --git a/src/client/app/status/style.styl b/src/client/app/status/style.styl new file mode 100644 index 0000000000..5ae230ea56 --- /dev/null +++ b/src/client/app/status/style.styl @@ -0,0 +1,10 @@ +@import "../app" +@import "../reset" + +html + color #456267 + background #fff + +body + margin 0 + padding 0 diff --git a/src/client/app/status/tags/index.tag b/src/client/app/status/tags/index.tag new file mode 100644 index 0000000000..899467097a --- /dev/null +++ b/src/client/app/status/tags/index.tag @@ -0,0 +1,201 @@ +<mk-index> + <h1>Misskey<i>Status</i></h1> + <p>%fa:info-circle%%i18n:status.all-systems-maybe-operational%</p> + <main> + <mk-cpu-usage connection={ connection }/> + <mk-mem-usage connection={ connection }/> + </main> + <footer><a href={ _URL_ }>{ _HOST_ }</a></footer> + <style lang="stylus" scoped> + :scope + display block + margin 0 auto + padding 0 16px + max-width 700px + + > h1 + margin 0 + padding 24px 0 16px 0 + font-size 24px + font-weight normal + + > [data-fa] + font-style normal + color #f43b16 + + > p + display block + margin 0 + padding 12px 16px + background #eaf4ef + //border solid 1px #99ccb2 + border-radius 4px + + > [data-fa] + margin-right 5px + + > main + > * + margin 24px 0 + + > h2 + margin 0 0 12px 0 + font-size 18px + font-weight normal + + > footer + margin 24px 0 + text-align center + + > a + color #546567 + </style> + <script lang="typescript"> + import Connection from '../../common/scripts/streaming/server-stream'; + + this.mixin('api'); + + this.initializing = true; + this.connection = new Connection(); + + this.on('mount', () => { + this.$root.$data.os.api('meta').then(meta => { + this.update({ + initializing: false, + meta + }); + }); + }); + + this.on('unmount', () => { + this.connection.close(); + }); + + </script> +</mk-index> + +<mk-cpu-usage> + <h2>CPU <b>{ percentage }%</b></h2> + <mk-line-chart ref="chart"/> + <style lang="stylus" scoped> + :scope + display block + </style> + <script lang="typescript"> + this.connection = this.opts.connection; + + this.on('mount', () => { + this.connection.on('stats', this.onStats); + }); + + this.on('unmount', () => { + this.connection.off('stats', this.onStats); + }); + + this.onStats = stats => { + this.$refs.chart.addData(1 - stats.cpu_usage); + + const percentage = (stats.cpu_usage * 100).toFixed(0); + + this.update({ + percentage + }); + }; + </script> +</mk-cpu-usage> + +<mk-mem-usage> + <h2>MEM <b>{ percentage }%</b></h2> + <mk-line-chart ref="chart"/> + <style lang="stylus" scoped> + :scope + display block + </style> + <script lang="typescript"> + this.connection = this.opts.connection; + + this.on('mount', () => { + this.connection.on('stats', this.onStats); + }); + + this.on('unmount', () => { + this.connection.off('stats', this.onStats); + }); + + this.onStats = stats => { + stats.mem.used = stats.mem.total - stats.mem.free; + this.$refs.chart.addData(1 - (stats.mem.used / stats.mem.total)); + + const percentage = (stats.mem.used / stats.mem.total * 100).toFixed(0); + + this.update({ + percentage + }); + }; + </script> +</mk-mem-usage> + +<mk-line-chart> + <svg riot-viewBox="0 0 { viewBoxX } { viewBoxY }" preserveAspectRatio="none"> + <defs> + <linearGradient id={ gradientId } x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="rgba(244, 59, 22, 0)"></stop> + <stop offset="100%" stop-color="#f43b16"></stop> + </linearGradient> + <mask id={ maskId } x="0" y="0" riot-width={ viewBoxX } riot-height={ viewBoxY }> + <polygon + riot-points={ polygonPoints } + fill="#fff" + fill-opacity="0.5"/> + </mask> + </defs> + <line x1="0" y1="0" riot-x2={ viewBoxX } y2="0" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/> + <line x1="0" y1="25%" riot-x2={ viewBoxX } y2="25%" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/> + <line x1="0" y1="50%" riot-x2={ viewBoxX } y2="50%" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/> + <line x1="0" y1="75%" riot-x2={ viewBoxX } y2="75%" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/> + <line x1="0" y1="100%" riot-x2={ viewBoxX } y2="100%" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/> + <rect + x="-1" y="-1" + riot-width={ viewBoxX + 2 } riot-height={ viewBoxY + 2 } + style="stroke: none; fill: url(#{ gradientId }); mask: url(#{ maskId })"/> + <polyline + riot-points={ polylinePoints } + fill="none" + stroke="#f43b16" + stroke-width="0.5"/> + </svg> + <style lang="stylus" scoped> + :scope + display block + padding 16px + border-radius 8px + background #1c2531 + + > svg + display block + padding 1px + width 100% + </style> + <script lang="typescript"> + import uuid from 'uuid'; + + this.viewBoxX = 100; + this.viewBoxY = 30; + this.data = []; + this.gradientId = uuid(); + this.maskId = uuid(); + + this.addData = data => { + this.data.push(data); + if (this.data.length > 100) this.data.shift(); + + const polylinePoints = this.data.map((d, i) => `${this.viewBoxX - ((this.data.length - 1) - i)},${d * this.viewBoxY}`).join(' '); + const polygonPoints = `${this.viewBoxX - (this.data.length - 1)},${ this.viewBoxY } ${ polylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; + + this.update({ + polylinePoints, + polygonPoints + }); + }; + </script> +</mk-line-chart> diff --git a/src/client/app/status/tags/index.ts b/src/client/app/status/tags/index.ts new file mode 100644 index 0000000000..f41151949f --- /dev/null +++ b/src/client/app/status/tags/index.ts @@ -0,0 +1 @@ +require('./index.tag'); diff --git a/src/client/app/sw.js b/src/client/app/sw.js new file mode 100644 index 0000000000..669703b16c --- /dev/null +++ b/src/client/app/sw.js @@ -0,0 +1,71 @@ +/** + * Service Worker + */ + +import composeNotification from './common/scripts/compose-notification'; + +// キャッシュするリソース +const cachee = [ + '/' +]; + +// インストールされたとき +self.addEventListener('install', ev => { + console.info('installed'); + + ev.waitUntil(Promise.all([ + self.skipWaiting(), // Force activate + caches.open(_VERSION_).then(cache => cache.addAll(cachee)) // Cache + ])); +}); + +// アクティベートされたとき +self.addEventListener('activate', ev => { + // Clean up old caches + ev.waitUntil( + caches.keys().then(keys => Promise.all( + keys + .filter(key => key != _VERSION_) + .map(key => caches.delete(key)) + )) + ); +}); + +// リクエストが発生したとき +self.addEventListener('fetch', ev => { + ev.respondWith( + // キャッシュがあるか確認してあればそれを返す + caches.match(ev.request).then(response => + response || fetch(ev.request) + ) + ); +}); + +// プッシュ通知を受け取ったとき +self.addEventListener('push', ev => { + console.log('pushed'); + + // クライアント取得 + ev.waitUntil(self.clients.matchAll({ + includeUncontrolled: true + }).then(clients => { + // クライアントがあったらストリームに接続しているということなので通知しない + if (clients.length != 0) return; + + const { type, body } = ev.data.json(); + + console.log(type, body); + + const n = composeNotification(type, body); + return self.registration.showNotification(n.title, { + body: n.body, + icon: n.icon, + }); + })); +}); + +self.addEventListener('message', ev => { + if (ev.data == 'clear') { + caches.keys().then(keys => keys.forEach(key => caches.delete(key))); + } +}); diff --git a/src/client/app/tsconfig.json b/src/client/app/tsconfig.json new file mode 100644 index 0000000000..e31b52dab1 --- /dev/null +++ b/src/client/app/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "allowJs": true, + "noEmitOnError": false, + "noImplicitAny": false, + "noImplicitReturns": true, + "noUnusedParameters": false, + "noUnusedLocals": true, + "noFallthroughCasesInSwitch": true, + "declaration": false, + "sourceMap": false, + "target": "es2017", + "module": "commonjs", + "removeComments": false, + "noLib": false, + "strict": true, + "strictNullChecks": false + }, + "compileOnSave": false, + "include": [ + "./**/*.ts" + ] +} diff --git a/src/client/app/v.d.ts b/src/client/app/v.d.ts new file mode 100644 index 0000000000..8f3a240d80 --- /dev/null +++ b/src/client/app/v.d.ts @@ -0,0 +1,4 @@ +declare module "*.vue" { + import Vue from 'vue'; + export default Vue; +} |