diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000000..679d4f12db --- /dev/null +++ b/.eslintrc @@ -0,0 +1,19 @@ +{ + "parserOptions": { + "parser": "typescript-eslint-parser" + }, + "extends": [ + "eslint:recommended", + "plugin:vue/recommended" + ], + "rules": { + "vue/require-v-for-key": false, + "vue/max-attributes-per-line": false, + "vue/html-indent": false, + "vue/html-self-closing": false, + "vue/no-unused-vars": false, + "no-console": 0, + "no-unused-vars": 0, + "no-empty": 0 + } +} diff --git a/.gitattributes b/.gitattributes index c6c5947baf..952d6cd0e9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,3 @@ *.svg -diff -text *.psd -diff -text *.ai -diff -text - -*.tag linguist-language=HTML diff --git a/gulpfile.ts b/gulpfile.ts index 21870473ed..736507bafb 100644 --- a/gulpfile.ts +++ b/gulpfile.ts @@ -56,7 +56,7 @@ gulp.task('build:js', () => ); gulp.task('build:ts', () => { - const tsProject = ts.createProject('./src/tsconfig.json'); + const tsProject = ts.createProject('./tsconfig.json'); return tsProject .src() diff --git a/locales/index.ts b/locales/index.ts index 0593af366c..ced3b4cb32 100644 --- a/locales/index.ts +++ b/locales/index.ts @@ -11,7 +11,7 @@ const loadLang = lang => yaml.safeLoad( const native = loadLang('ja'); const langs = { - 'en': loadLang('en'), + //'en': loadLang('en'), 'ja': native }; diff --git a/package.json b/package.json index bd51144807..4521b0ceb5 100644 --- a/package.json +++ b/package.json @@ -81,9 +81,9 @@ "accesses": "2.5.0", "animejs": "2.2.0", "autwh": "0.0.1", - "awesome-typescript-loader": "3.4.1", "bcryptjs": "2.4.3", "body-parser": "1.18.2", + "cache-loader": "^1.2.0", "cafy": "3.2.1", "chai": "4.1.2", "chai-http": "3.0.0", @@ -99,6 +99,8 @@ "diskusage": "0.2.4", "elasticsearch": "14.1.0", "escape-regexp": "0.0.1", + "eslint": "^4.18.0", + "eslint-plugin-vue": "^4.2.2", "eventemitter3": "3.0.0", "exif-js": "2.3.0", "express": "4.16.2", @@ -118,12 +120,15 @@ "gulp-typescript": "3.2.4", "gulp-uglify": "3.0.0", "gulp-util": "3.0.8", + "hard-source-webpack-plugin": "0.6.0-alpha.8", "highlight.js": "9.12.0", + "html-minifier": "^3.5.9", "inquirer": "5.0.1", "is-root": "1.0.0", "is-url": "1.2.2", "js-yaml": "3.10.0", "license-checker": "16.0.0", + "loader-utils": "^1.1.0", "mecab-async": "0.1.2", "mkdirp": "0.5.1", "mocha": "5.0.0", @@ -145,6 +150,7 @@ "recaptcha-promise": "0.1.3", "reconnecting-websocket": "3.2.2", "redis": "2.8.0", + "replace-string-loader": "0.0.7", "request": "2.83.0", "rimraf": "2.6.2", "riot": "3.8.1", @@ -155,6 +161,7 @@ "serve-favicon": "2.4.5", "sortablejs": "1.7.0", "speakeasy": "2.0.0", + "string-replace-loader": "^1.3.0", "string-replace-webpack-plugin": "0.1.3", "style-loader": "0.20.1", "stylus": "0.54.5", @@ -165,15 +172,25 @@ "tcp-port-used": "0.1.2", "textarea-caret": "3.0.2", "tmp": "0.0.33", + "ts-loader": "^3.5.0", "ts-node": "4.1.0", "tslint": "5.9.1", "typescript": "2.7.1", + "typescript-eslint-parser": "^13.0.0", "uglify-es": "3.3.9", "uglifyjs-webpack-plugin": "1.1.8", "uuid": "3.2.1", "vhost": "3.0.2", + "vue": "^2.5.13", + "vue-cropperjs": "^2.2.0", + "vue-js-modal": "^1.3.9", + "vue-loader": "^14.1.1", + "vue-router": "^3.0.1", + "vue-template-compiler": "^2.5.13", + "vuedraggable": "^2.16.0", "web-push": "3.2.5", "webpack": "3.10.0", + "webpack-replace-loader": "^1.3.0", "websocket": "1.0.25", "xev": "2.0.0" } diff --git a/src/api/bot/core.ts b/src/api/bot/core.ts index ddae6405f5..0a073a3127 100644 --- a/src/api/bot/core.ts +++ b/src/api/bot/core.ts @@ -305,7 +305,7 @@ class TlContext extends Context { private async getTl() { const tl = await require('../endpoints/posts/timeline')({ limit: 5, - max_id: this.next ? this.next : undefined + until_id: this.next ? this.next : undefined }, this.bot.user); if (tl.length > 0) { @@ -357,7 +357,7 @@ class NotificationsContext extends Context { private async getNotifications() { const notifications = await require('../endpoints/i/notifications')({ limit: 5, - max_id: this.next ? this.next : undefined + until_id: this.next ? this.next : undefined }, this.bot.user); if (notifications.length > 0) { diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index e846381578..ff214c3004 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -194,6 +194,11 @@ const endpoints: Endpoint[] = [ withCredential: true, secure: true }, + { + name: 'i/update_client_setting', + withCredential: true, + secure: true + }, { name: 'i/pin', kind: 'account-write' diff --git a/src/api/endpoints/i/update.ts b/src/api/endpoints/i/update.ts index 7bbbf95900..43c5245044 100644 --- a/src/api/endpoints/i/update.ts +++ b/src/api/endpoints/i/update.ts @@ -46,19 +46,13 @@ module.exports = async (params, user, _, isSecure) => new Promise(async (res, re if (bannerIdErr) return rej('invalid banner_id param'); if (bannerId) user.banner_id = bannerId; - // Get 'show_donation' parameter - const [showDonation, showDonationErr] = $(params.show_donation).optional.boolean().$; - if (showDonationErr) return rej('invalid show_donation param'); - if (showDonation) user.client_settings.show_donation = showDonation; - await User.update(user._id, { $set: { name: user.name, description: user.description, avatar_id: user.avatar_id, banner_id: user.banner_id, - profile: user.profile, - 'client_settings.show_donation': user.client_settings.show_donation + profile: user.profile } }); diff --git a/src/api/endpoints/i/update_client_setting.ts b/src/api/endpoints/i/update_client_setting.ts new file mode 100644 index 0000000000..b817ff354c --- /dev/null +++ b/src/api/endpoints/i/update_client_setting.ts @@ -0,0 +1,43 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { pack } from '../../models/user'; +import event from '../../event'; + +/** + * Update myself + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'name' parameter + const [name, nameErr] = $(params.name).string().$; + if (nameErr) return rej('invalid name param'); + + // Get 'value' parameter + const [value, valueErr] = $(params.value).nullable.any().$; + if (valueErr) return rej('invalid value param'); + + const x = {}; + x[`client_settings.${name}`] = value; + + await User.update(user._id, { + $set: x + }); + + // Serialize + user.client_settings[name] = value; + const iObj = await pack(user, user, { + detail: true, + includeSecrets: true + }); + + // Send response + res(iObj); + + // Publish i updated event + event(user._id, 'i_updated', iObj); +}); diff --git a/src/api/private/signup.ts b/src/api/private/signup.ts index 8efdb6db47..19e3314756 100644 --- a/src/api/private/signup.ts +++ b/src/api/private/signup.ts @@ -15,7 +15,7 @@ const home = { 'profile', 'calendar', 'activity', - 'rss-reader', + 'rss', 'trends', 'photo-stream', 'version' @@ -23,8 +23,8 @@ const home = { right: [ 'broadcast', 'notifications', - 'user-recommendation', - 'recommended-polls', + 'users', + 'polls', 'server', 'donation', 'nav', diff --git a/src/common/build/i18n.ts b/src/common/build/i18n.ts index 1ae22147c4..5e3c0381a9 100644 --- a/src/common/build/i18n.ts +++ b/src/common/build/i18n.ts @@ -17,7 +17,14 @@ export default class Replacer { } private get(key: string) { - let text = locale[this.lang]; + const texts = locale[this.lang]; + + if (texts == null) { + console.warn(`lang '${this.lang}' is not supported`); + return key; // Fallback + } + + let text = texts; // Check the key existance const error = key.split('.').some(k => { diff --git a/src/web/app/app.vue b/src/web/app/app.vue new file mode 100644 index 0000000000..321e003930 --- /dev/null +++ b/src/web/app/app.vue @@ -0,0 +1,3 @@ +<template> + <router-view id="app"></router-view> +</template> diff --git a/src/web/app/auth/tags/form.tag b/src/web/app/auth/tags/form.tag deleted file mode 100644 index 4a236f7594..0000000000 --- a/src/web/app/auth/tags/form.tag +++ /dev/null @@ -1,130 +0,0 @@ -<mk-form> - <header> - <h1><i>{ app.name }</i>があなたの<b>アカウント</b>に<b>アクセス</b>することを<b>許可</b>しますか?</h1><img src={ app.icon_url + '?thumbnail&size=64' }/> - </header> - <div class="app"> - <section> - <h2>{ app.name }</h2> - <p class="nid">{ app.name_id }</p> - <p class="description">{ app.description }</p> - </section> - <section> - <h2>このアプリは次の権限を要求しています:</h2> - <ul> - <virtual each={ p in app.permission }> - <li if={ p == 'account-read' }>アカウントの情報を見る。</li> - <li if={ p == 'account-write' }>アカウントの情報を操作する。</li> - <li if={ p == 'post-write' }>投稿する。</li> - <li if={ p == 'like-write' }>いいねしたりいいね解除する。</li> - <li if={ p == 'following-write' }>フォローしたりフォロー解除する。</li> - <li if={ p == 'drive-read' }>ドライブを見る。</li> - <li if={ p == 'drive-write' }>ドライブを操作する。</li> - <li if={ p == 'notification-read' }>通知を見る。</li> - <li if={ p == 'notification-write' }>通知を操作する。</li> - </virtual> - </ul> - </section> - </div> - <div class="action"> - <button onclick={ cancel }>キャンセル</button> - <button onclick={ accept }>アクセスを許可</button> - </div> - <style> - :scope - display block - - > 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 - - @media (max-width 600px) - > header - > img - box-shadow none - - > .app - box-shadow none - - @media (max-width 500px) - > header - > h1 - font-size 16px - - </style> - <script> - this.mixin('api'); - - this.session = this.opts.session; - this.app = this.session.app; - - this.cancel = () => { - this.api('auth/deny', { - token: this.session.token - }).then(() => { - this.trigger('denied'); - }); - }; - - this.accept = () => { - this.api('auth/accept', { - token: this.session.token - }).then(() => { - this.trigger('accepted'); - }); - }; - </script> -</mk-form> diff --git a/src/web/app/auth/tags/index.tag b/src/web/app/auth/tags/index.tag deleted file mode 100644 index e71214f4a3..0000000000 --- a/src/web/app/auth/tags/index.tag +++ /dev/null @@ -1,143 +0,0 @@ -<mk-index> - <main if={ SIGNIN }> - <p class="fetching" if={ fetching }>読み込み中<mk-ellipsis/></p> - <mk-form ref="form" if={ state == 'waiting' } session={ session }/> - <div class="denied" if={ state == 'denied' }> - <h1>アプリケーションの連携をキャンセルしました。</h1> - <p>このアプリがあなたのアカウントにアクセスすることはありません。</p> - </div> - <div class="accepted" if={ state == 'accepted' }> - <h1>{ session.app.is_authorized ? 'このアプリは既に連携済みです' : 'アプリケーションの連携を許可しました'}</h1> - <p if={ session.app.callback_url }>アプリケーションに戻っています<mk-ellipsis/></p> - <p if={ !session.app.callback_url }>アプリケーションに戻って、やっていってください。</p> - </div> - <div class="error" if={ state == 'fetch-session-error' }> - <p>セッションが存在しません。</p> - </div> - </main> - <main class="signin" if={ !SIGNIN }> - <h1>サインインしてください</h1> - <mk-signin/> - </main> - <footer><img src="/assets/auth/logo.svg" alt="Misskey"/></footer> - <style> - :scope - display block - - > 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> - <script> - this.mixin('i'); - this.mixin('api'); - - this.state = null; - this.fetching = true; - - this.token = window.location.href.split('/').pop(); - - this.on('mount', () => { - if (!this.SIGNIN) return; - - // Fetch session - this.api('auth/session/show', { - token: this.token - }).then(session => { - this.session = session; - this.fetching = false; - - // 既に連携していた場合 - if (this.session.app.is_authorized) { - this.api('auth/accept', { - token: this.session.token - }).then(() => { - this.accepted(); - }); - } else { - this.update({ - state: 'waiting' - }); - - this.refs.form.on('denied', () => { - this.update({ - state: 'denied' - }); - }); - - this.refs.form.on('accepted', this.accepted); - } - }).catch(error => { - this.update({ - fetching: false, - state: 'fetch-session-error' - }); - }); - }); - - this.accepted = () => { - this.update({ - state: 'accepted' - }); - - if (this.session.app.callback_url) { - location.href = this.session.app.callback_url + '?token=' + this.session.token; - } - }; - </script> -</mk-index> diff --git a/src/web/app/auth/tags/index.ts b/src/web/app/auth/tags/index.ts deleted file mode 100644 index 42dffe67d9..0000000000 --- a/src/web/app/auth/tags/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -require('./index.tag'); -require('./form.tag'); diff --git a/src/web/app/auth/views/form.vue b/src/web/app/auth/views/form.vue new file mode 100644 index 0000000000..30ad64ed2d --- /dev/null +++ b/src/web/app/auth/views/form.vue @@ -0,0 +1,140 @@ +<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.name_id }}</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 + + @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/web/app/auth/views/index.vue b/src/web/app/auth/views/index.vue new file mode 100644 index 0000000000..1e372c0bde --- /dev/null +++ b/src/web/app/auth/views/index.vue @@ -0,0 +1,145 @@ +<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.is_authorized ? 'このアプリは既に連携済みです' : 'アプリケーションの連携を許可しました'}}</h1> + <p v-if="session.app.callback_url">アプリケーションに戻っています<mk-ellipsis/></p> + <p v-if="!session.app.callback_url">アプリケーションに戻って、やっていってください。</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, + token: window.location.href.split('/').pop() + }; + }, + 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.is_authorized) { + 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.callback_url) { + location.href = this.session.app.callback_url + '?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/web/app/ch/tags/channel.tag b/src/web/app/ch/tags/channel.tag index cc8ce1ed9e..b5c6ce1e69 100644 --- a/src/web/app/ch/tags/channel.tag +++ b/src/web/app/ch/tags/channel.tag @@ -1,12 +1,12 @@ <mk-channel> <mk-header/> <hr> - <main if={ !fetching }> + <main v-if="!fetching"> <h1>{ channel.title }</h1> - <div if={ SIGNIN }> - <p if={ channel.is_watching }>このチャンネルをウォッチしています <a onclick={ unwatch }>ウォッチ解除</a></p> - <p if={ !channel.is_watching }><a onclick={ watch }>このチャンネルをウォッチする</a></p> + <div v-if="$root.$data.os.isSignedIn"> + <p v-if="channel.is_watching">このチャンネルをウォッチしています <a @click="unwatch">ウォッチ解除</a></p> + <p v-if="!channel.is_watching"><a @click="watch">このチャンネルをウォッチする</a></p> </div> <div class="share"> @@ -15,17 +15,17 @@ </div> <div class="body"> - <p if={ postsFetching }>読み込み中<mk-ellipsis/></p> - <div if={ !postsFetching }> - <p if={ posts == null || posts.length == 0 }>まだ投稿がありません</p> - <virtual if={ posts != null }> + <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 }/> - </virtual> + </template> </div> </div> <hr> - <mk-channel-form if={ SIGNIN } channel={ channel } ref="form"/> - <div if={ !SIGNIN }> + <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> @@ -33,7 +33,7 @@ <small><a href={ _URL_ }>Misskey</a> ver { _VERSION_ } (葵 aoi)</small> </footer> </main> - <style> + <style lang="stylus" scoped> :scope display block @@ -53,7 +53,7 @@ max-width 500px </style> - <script> + <script lang="typescript"> import Progress from '../../common/scripts/loading'; import ChannelStream from '../../common/scripts/streaming/channel-stream'; @@ -76,7 +76,7 @@ let fetched = false; // チャンネル概要読み込み - this.api('channels/show', { + this.$root.$data.os.api('channels/show', { channel_id: this.id }).then(channel => { if (fetched) { @@ -95,7 +95,7 @@ }); // 投稿読み込み - this.api('channels/posts', { + this.$root.$data.os.api('channels/posts', { channel_id: this.id }).then(posts => { if (fetched) { @@ -125,7 +125,7 @@ this.posts.unshift(post); this.update(); - if (document.hidden && this.SIGNIN && post.user_id !== this.I.id) { + if (document.hidden && this.$root.$data.os.isSignedIn && post.user_id !== this.$root.$data.os.i.id) { this.unreadCount++; document.title = `(${this.unreadCount}) ${this.channel.title} | Misskey`; } @@ -139,7 +139,7 @@ }; this.watch = () => { - this.api('channels/watch', { + this.$root.$data.os.api('channels/watch', { channel_id: this.id }).then(() => { this.channel.is_watching = true; @@ -150,7 +150,7 @@ }; this.unwatch = () => { - this.api('channels/unwatch', { + this.$root.$data.os.api('channels/unwatch', { channel_id: this.id }).then(() => { this.channel.is_watching = false; @@ -164,24 +164,24 @@ <mk-channel-post> <header> - <a class="index" onclick={ reply }>{ post.index }:</a> + <a class="index" @click="reply">{ post.index }:</a> <a class="name" href={ _URL_ + '/' + post.user.username }><b>{ post.user.name }</b></a> <mk-time time={ post.created_at }/> <mk-time time={ post.created_at } mode="detail"/> <span>ID:<i>{ post.user.username }</i></span> </header> <div> - <a if={ post.reply }>>>{ post.reply.index }</a> + <a v-if="post.reply">>>{ post.reply.index }</a> { post.text } - <div class="media" if={ post.media }> - <virtual each={ file in post.media }> + <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> - </virtual> + </template> </div> </div> - <style> + <style lang="stylus" scoped> :scope display block margin 0 @@ -228,7 +228,7 @@ vertical-align bottom </style> - <script> + <script lang="typescript"> this.post = this.opts.post; this.form = this.opts.form; @@ -241,21 +241,21 @@ </mk-channel-post> <mk-channel-form> - <p if={ reply }><b>>>{ reply.index }</b> ({ reply.user.name }): <a onclick={ clearReply }>[x]</a></p> + <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 onclick={ selectFile }>%fa:upload%%i18n:ch.tags.mk-channel-form.upload%</button> - <button onclick={ drive }>%fa:cloud%%i18n:ch.tags.mk-channel-form.drive%</button> - <button class={ wait: wait } ref="submit" disabled={ wait || (refs.text.value.length == 0) } onclick={ post }> - <virtual if={ !wait }>%fa:paper-plane%</virtual>{ wait ? '%i18n:ch.tags.mk-channel-form.posting%' : '%i18n:ch.tags.mk-channel-form.post%' }<mk-ellipsis if={ wait }/> + <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 if={ files }> + <ol v-if="files"> <li each={ files }>{ name }</li> </ol> <input ref="file" type="file" accept="image/*" multiple="multiple" onchange={ changeFile }/> - <style> + <style lang="stylus" scoped> :scope display block @@ -282,14 +282,14 @@ display none </style> - <script> + <script lang="typescript"> this.mixin('api'); this.channel = this.opts.channel; this.files = null; this.on('mount', () => { - this.refs.uploader.on('uploaded', file => { + this.$refs.uploader.on('uploaded', file => { this.update({ files: [file] }); @@ -297,7 +297,7 @@ }); this.upload = file => { - this.refs.uploader.upload(file); + this.$refs.uploader.upload(file); }; this.clearReply = () => { @@ -311,7 +311,7 @@ this.update({ files: null }); - this.refs.text.value = ''; + this.$refs.text.value = ''; }; this.post = () => { @@ -323,8 +323,8 @@ ? this.files.map(f => f.id) : undefined; - this.api('posts/create', { - text: this.refs.text.value == '' ? undefined : this.refs.text.value, + this.$root.$data.os.api('posts/create', { + text: this.$refs.text.value == '' ? undefined : this.$refs.text.value, media_ids: files, reply_id: this.reply ? this.reply.id : undefined, channel_id: this.channel.id @@ -340,11 +340,11 @@ }; this.changeFile = () => { - Array.from(this.refs.file.files).forEach(this.upload); + Array.from(this.$refs.file.files).forEach(this.upload); }; this.selectFile = () => { - this.refs.file.click(); + this.$refs.file.click(); }; this.drive = () => { @@ -375,7 +375,7 @@ <mk-twitter-button> <a href="https://twitter.com/share?ref_src=twsrc%5Etfw" class="twitter-share-button" data-show-count="false">Tweet</a> - <script> + <script lang="typescript"> this.on('mount', () => { const head = document.getElementsByTagName('head')[0]; const script = document.createElement('script'); @@ -388,7 +388,7 @@ <mk-line-button> <div class="line-it-button" data-lang="ja" data-type="share-a" data-url={ _CH_URL_ } style="display: none;"></div> - <script> + <script lang="typescript"> this.on('mount', () => { const head = document.getElementsByTagName('head')[0]; const script = document.createElement('script'); diff --git a/src/web/app/ch/tags/header.tag b/src/web/app/ch/tags/header.tag index dec83c9a5b..747bec357b 100644 --- a/src/web/app/ch/tags/header.tag +++ b/src/web/app/ch/tags/header.tag @@ -3,10 +3,10 @@ <a href={ _CH_URL_ }>Index</a> | <a href={ _URL_ }>Misskey</a> </div> <div> - <a if={ !SIGNIN } href={ _URL_ }>ログイン(新規登録)</a> - <a if={ SIGNIN } href={ _URL_ + '/' + I.username }>{ I.username }</a> + <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> + <style lang="stylus" scoped> :scope display flex @@ -14,7 +14,7 @@ margin-left auto </style> - <script> + <script lang="typescript"> this.mixin('i'); </script> </mk-header> diff --git a/src/web/app/ch/tags/index.tag b/src/web/app/ch/tags/index.tag index 5f3871802a..88df2ec45d 100644 --- a/src/web/app/ch/tags/index.tag +++ b/src/web/app/ch/tags/index.tag @@ -1,21 +1,21 @@ <mk-index> <mk-header/> <hr> - <button onclick={ n }>%i18n:ch.tags.mk-index.new%</button> + <button @click="n">%i18n:ch.tags.mk-index.new%</button> <hr> - <ul if={ channels }> + <ul v-if="channels"> <li each={ channels }><a href={ '/' + this.id }>{ this.title }</a></li> </ul> - <style> + <style lang="stylus" scoped> :scope display block </style> - <script> + <script lang="typescript"> this.mixin('api'); this.on('mount', () => { - this.api('channels', { + this.$root.$data.os.api('channels', { limit: 100 }).then(channels => { this.update({ @@ -27,7 +27,7 @@ this.n = () => { const title = window.prompt('%i18n:ch.tags.mk-index.channel-title%'); - this.api('channels/create', { + this.$root.$data.os.api('channels/create', { title: title }).then(channel => { location.href = '/' + channel.id; diff --git a/src/web/app/common/tags/authorized-apps.tag b/src/web/app/common/-tags/authorized-apps.tag similarity index 69% rename from src/web/app/common/tags/authorized-apps.tag rename to src/web/app/common/-tags/authorized-apps.tag index 0594032de6..ed1570650a 100644 --- a/src/web/app/common/tags/authorized-apps.tag +++ b/src/web/app/common/-tags/authorized-apps.tag @@ -1,14 +1,14 @@ <mk-authorized-apps> - <div class="none ui info" if={ !fetching && apps.length == 0 }> + <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" if={ apps.length != 0 }> + <div class="apps" v-if="apps.length != 0"> <div each={ app in apps }> <p><b>{ app.name }</b></p> <p>{ app.description }</p> </div> </div> - <style> + <style lang="stylus" scoped> :scope display block @@ -18,17 +18,16 @@ border-bottom solid 1px #eee </style> - <script> + <script lang="typescript"> this.mixin('api'); this.apps = []; this.fetching = true; this.on('mount', () => { - this.api('i/authorized_apps').then(apps => { + this.$root.$data.os.api('i/authorized_apps').then(apps => { this.apps = apps; this.fetching = false; - this.update(); }); }); </script> diff --git a/src/web/app/common/tags/signin-history.tag b/src/web/app/common/-tags/signin-history.tag similarity index 82% rename from src/web/app/common/tags/signin-history.tag rename to src/web/app/common/-tags/signin-history.tag index cdd58c4c67..a347c7c235 100644 --- a/src/web/app/common/tags/signin-history.tag +++ b/src/web/app/common/-tags/signin-history.tag @@ -1,13 +1,13 @@ <mk-signin-history> - <div class="records" if={ history.length != 0 }> + <div class="records" v-if="history.length != 0"> <mk-signin-record each={ rec in history } rec={ rec }/> </div> - <style> + <style lang="stylus" scoped> :scope display block </style> - <script> + <script lang="typescript"> this.mixin('i'); this.mixin('api'); @@ -19,7 +19,7 @@ this.fetching = true; this.on('mount', () => { - this.api('i/signin_history').then(history => { + this.$root.$data.os.api('i/signin_history').then(history => { this.update({ fetching: false, history: history @@ -42,15 +42,15 @@ </mk-signin-history> <mk-signin-record> - <header onclick={ toggle }> - <virtual if={ rec.success }>%fa:check%</virtual> - <virtual if={ !rec.success }>%fa:times%</virtual> + <header @click="toggle"> + <template v-if="rec.success">%fa:check%</template> + <template v-if="!rec.success">%fa:times%</template> <span class="ip">{ rec.ip }</span> <mk-time time={ rec.created_at }/> </header> <pre ref="headers" class="json" show={ show }>{ JSON.stringify(rec.headers, null, 2) }</pre> - <style> + <style lang="stylus" scoped> :scope display block border-bottom solid 1px #eee @@ -97,14 +97,14 @@ </style> - <script> + <script lang="typescript"> import hljs from 'highlight.js'; this.rec = this.opts.rec; this.show = false; this.on('mount', () => { - hljs.highlightBlock(this.refs.headers); + hljs.highlightBlock(this.$refs.headers); }); this.toggle = () => { diff --git a/src/web/app/common/define-widget.ts b/src/web/app/common/define-widget.ts new file mode 100644 index 0000000000..fd13a3395b --- /dev/null +++ b/src/web/app/common/define-widget.ts @@ -0,0 +1,44 @@ +import Vue from 'vue'; + +export default function<T extends object>(data: { + name: string; + props?: () => T; +}) { + return Vue.extend({ + props: { + widget: { + type: Object + } + }, + computed: { + id(): string { + return this.widget.id; + } + }, + data() { + return { + props: data.props ? data.props() : {} as T + }; + }, + created() { + if (this.props) { + Object.keys(this.props).forEach(prop => { + if (this.widget.data.hasOwnProperty(prop)) { + this.props[prop] = this.widget.data[prop]; + } + }); + } + + this.$watch('props', newProps => { + (this as any).api('i/update_home', { + id: this.id, + data: newProps + }).then(() => { + (this as any).os.i.client_settings.home.find(w => w.id == this.id).data = newProps; + }); + }, { + deep: true + }); + } + }); +} diff --git a/src/web/app/common/filters/bytes.ts b/src/web/app/common/filters/bytes.ts new file mode 100644 index 0000000000..3afb11e9ae --- /dev/null +++ b/src/web/app/common/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/web/app/common/filters/index.ts b/src/web/app/common/filters/index.ts new file mode 100644 index 0000000000..16ff8c87a4 --- /dev/null +++ b/src/web/app/common/filters/index.ts @@ -0,0 +1 @@ +require('./bytes'); diff --git a/src/web/app/common/mios.ts b/src/web/app/common/mios.ts index 6ee42ea8a7..e20f4bfe4e 100644 --- a/src/web/app/common/mios.ts +++ b/src/web/app/common/mios.ts @@ -1,9 +1,15 @@ +import Vue from 'vue'; import { EventEmitter } from 'eventemitter3'; -import * as riot from 'riot'; +import api from './scripts/api'; import signout from './scripts/signout'; import Progress from './scripts/loading'; import HomeStreamManager from './scripts/streaming/home-stream-manager'; -import api from './scripts/api'; +import DriveStreamManager from './scripts/streaming/drive-stream-manager'; +import ServerStreamManager from './scripts/streaming/server-stream-manager'; +import RequestsStreamManager from './scripts/streaming/requests-stream-manager'; +import MessagingIndexStreamManager from './scripts/streaming/messaging-index-stream-manager'; + +import Err from '../common/views/components/connect-failed.vue'; //#region environment variables declare const _VERSION_: string; @@ -12,6 +18,41 @@ declare const _API_URL_: string; declare const _SW_PUBLICKEY_: string; //#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 */ @@ -26,6 +67,16 @@ export default class MiOS extends EventEmitter { 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 */ @@ -34,7 +85,7 @@ export default class MiOS extends EventEmitter { /** * Whether signed in */ - public get isSignedin() { + public get isSignedIn() { return this.i != null; } @@ -45,11 +96,28 @@ export default class MiOS extends EventEmitter { return localStorage.getItem('debug') == '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; + } = { + driveStream: null, + serverStream: null, + requestsStream: null, + messagingIndexStream: null + }; + /** * A registration of service worker */ @@ -60,6 +128,11 @@ export default class MiOS extends EventEmitter { */ private shouldRegisterSw: boolean; + /** + * ウィンドウシステム + */ + public windows = new WindowSystem(); + /** * MiOSインスタンスを作成します * @param shouldRegisterSw ServiceWorkerを登録するかどうか @@ -69,6 +142,9 @@ export default class MiOS extends EventEmitter { this.shouldRegisterSw = shouldRegisterSw; + this.streams.serverStream = new ServerStreamManager(); + this.streams.requestsStream = new RequestsStreamManager(); + //#region BIND this.log = this.log.bind(this); this.logInfo = this.logInfo.bind(this); @@ -79,6 +155,18 @@ export default class MiOS extends EventEmitter { this.getMeta = this.getMeta.bind(this); this.registerSw = this.registerSw.bind(this); //#endregion + + this.once('signedin', () => { + // Init home stream manager + this.stream = new HomeStreamManager(this.i); + + // Init other stream manager + this.streams.driveStream = new DriveStreamManager(this.i); + this.streams.messagingIndexStream = new MessagingIndexStreamManager(this.i); + }); + + // TODO: this global export is for debugging. so disable this if production build + (window as any).os = this; } public log(...args) { @@ -139,8 +227,10 @@ export default class MiOS extends EventEmitter { // When failure .catch(() => { // Render the error screen - document.body.innerHTML = '<mk-error />'; - riot.mount('*'); + document.body.innerHTML = '<div id="err"></div>'; + new Vue({ + render: createEl => createEl(Err) + }).$mount('#err'); Progress.done(); }); @@ -153,30 +243,13 @@ export default class MiOS extends EventEmitter { // フェッチが完了したとき const fetched = me => { if (me) { - riot.observable(me); - - // この me オブジェクトを更新するメソッド - me.update = data => { - if (data) Object.assign(me, data); - me.trigger('updated'); - }; - // ローカルストレージにキャッシュ localStorage.setItem('me', JSON.stringify(me)); - - // 自分の情報が更新されたとき - me.on('updated', () => { - // キャッシュ更新 - localStorage.setItem('me', JSON.stringify(me)); - }); } this.i = me; - // Init home stream manager - this.stream = this.isSignedin - ? new HomeStreamManager(this.i) - : null; + this.emit('signedin'); // Finish init callback(); @@ -200,8 +273,6 @@ export default class MiOS extends EventEmitter { // 後から新鮮なデータをフェッチ fetchme(cachedMe.token, freshData => { Object.assign(cachedMe, freshData); - cachedMe.trigger('updated'); - cachedMe.trigger('refreshed'); }); } else { // Get token from cookie @@ -223,7 +294,7 @@ export default class MiOS extends EventEmitter { if (!isSwSupported) return; // Reject when not signed in to Misskey - if (!this.isSignedin) return; + if (!this.isSignedIn) return; // When service worker activated navigator.serviceWorker.ready.then(registration => { @@ -331,6 +402,22 @@ export default class MiOS extends EventEmitter { } } +class WindowSystem { + private windows = new Set(); + + public add(window) { + this.windows.add(window); + } + + public remove(window) { + this.windows.delete(window); + } + + public getAll() { + return this.windows; + } +} + /** * Convert the URL safe base64 string to a Uint8Array * @param base64String base64 string diff --git a/src/web/app/common/mixins.ts b/src/web/app/common/mixins.ts deleted file mode 100644 index e9c3625937..0000000000 --- a/src/web/app/common/mixins.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as riot from 'riot'; - -import MiOS from './mios'; -import ServerStreamManager from './scripts/streaming/server-stream-manager'; -import RequestsStreamManager from './scripts/streaming/requests-stream-manager'; -import MessagingIndexStreamManager from './scripts/streaming/messaging-index-stream-manager'; -import DriveStreamManager from './scripts/streaming/drive-stream-manager'; - -export default (mios: MiOS) => { - (riot as any).mixin('os', { - mios: mios - }); - - (riot as any).mixin('i', { - init: function() { - this.I = mios.i; - this.SIGNIN = mios.isSignedin; - - if (this.SIGNIN) { - this.on('mount', () => { - mios.i.on('updated', this.update); - }); - this.on('unmount', () => { - mios.i.off('updated', this.update); - }); - } - }, - me: mios.i - }); - - (riot as any).mixin('api', { - api: mios.api - }); - - (riot as any).mixin('stream', { stream: mios.stream }); - (riot as any).mixin('drive-stream', { driveStream: new DriveStreamManager(mios.i) }); - (riot as any).mixin('server-stream', { serverStream: new ServerStreamManager() }); - (riot as any).mixin('requests-stream', { requestsStream: new RequestsStreamManager() }); - (riot as any).mixin('messaging-index-stream', { messagingIndexStream: new MessagingIndexStreamManager(mios.i) }); -}; diff --git a/src/web/app/common/scripts/bytes-to-size.ts b/src/web/app/common/scripts/bytes-to-size.ts deleted file mode 100644 index 1d2b1e7ce3..0000000000 --- a/src/web/app/common/scripts/bytes-to-size.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default (bytes, digits = 0) => { - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - if (bytes == 0) return '0Byte'; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return (bytes / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i]; -}; diff --git a/src/web/app/common/scripts/fuck-ad-block.ts b/src/web/app/common/scripts/fuck-ad-block.ts new file mode 100644 index 0000000000..9bcf7deeff --- /dev/null +++ b/src/web/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/web/app/common/scripts/is-promise.ts b/src/web/app/common/scripts/is-promise.ts deleted file mode 100644 index 3b4cd70b49..0000000000 --- a/src/web/app/common/scripts/is-promise.ts +++ /dev/null @@ -1 +0,0 @@ -export default x => typeof x.then == 'function'; diff --git a/src/web/app/common/scripts/streaming/home-stream.ts b/src/web/app/common/scripts/streaming/home-stream.ts index 11ad754ef0..a92b61caed 100644 --- a/src/web/app/common/scripts/streaming/home-stream.ts +++ b/src/web/app/common/scripts/streaming/home-stream.ts @@ -16,7 +16,9 @@ export default class Connection extends Stream { }, 1000 * 60); // 自分の情報が更新されたとき - this.on('i_updated', me.update); + this.on('i_updated', i => { + Object.assign(me, i); + }); // トークンが再生成されたとき // このままではAPIが利用できないので強制的にサインアウトさせる diff --git a/src/web/app/common/scripts/text-compiler.ts b/src/web/app/common/scripts/text-compiler.ts deleted file mode 100644 index e0ea47df26..0000000000 --- a/src/web/app/common/scripts/text-compiler.ts +++ /dev/null @@ -1,48 +0,0 @@ -declare const _URL_: string; - -import * as riot from 'riot'; -import * as pictograph from 'pictograph'; - -const escape = text => - text - .replace(/>/g, '>') - .replace(/</g, '<'); - -export default (tokens, shouldBreak) => { - if (shouldBreak == null) { - shouldBreak = true; - } - - const me = (riot as any).mixin('i').me; - - let text = tokens.map(token => { - switch (token.type) { - case 'text': - return escape(token.content) - .replace(/(\r\n|\n|\r)/g, shouldBreak ? '<br>' : ' '); - case 'bold': - return `<strong>${escape(token.bold)}</strong>`; - case 'url': - return `<mk-url href="${escape(token.content)}" target="_blank"></mk-url>`; - case 'link': - return `<a class="link" href="${escape(token.url)}" target="_blank" title="${escape(token.url)}">${escape(token.title)}</a>`; - case 'mention': - return `<a href="${_URL_ + '/' + escape(token.username)}" target="_blank" data-user-preview="${token.content}" ${me && me.username == token.username ? 'data-is-me' : ''}>${token.content}</a>`; - case 'hashtag': // TODO - return `<a>${escape(token.content)}</a>`; - case 'code': - return `<pre><code>${token.html}</code></pre>`; - case 'inline-code': - return `<code>${token.html}</code>`; - case 'emoji': - return pictograph.dic[token.emoji] || token.content; - } - }).join(''); - - // Remove needless whitespaces - text = text - .replace(/ <code>/g, '<code>').replace(/<\/code> /g, '</code>') - .replace(/<br><code><pre>/g, '<code><pre>').replace(/<\/code><\/pre><br>/g, '</code></pre>'); - - return text; -}; diff --git a/src/web/app/common/tags/activity-table.tag b/src/web/app/common/tags/activity-table.tag deleted file mode 100644 index 1d26d1788a..0000000000 --- a/src/web/app/common/tags/activity-table.tag +++ /dev/null @@ -1,57 +0,0 @@ -<mk-activity-table> - <svg if={ data } ref="canvas" viewBox="0 0 53 7" preserveAspectRatio="none"> - <rect each={ data } width="1" height="1" - riot-x={ x } riot-y={ date.weekday } - rx="1" ry="1" - fill={ color } - style="transform: scale({ v });"/> - <rect class="today" width="1" height="1" - riot-x={ data[data.length - 1].x } riot-y={ data[data.length - 1].date.weekday } - rx="1" ry="1" - fill="none" - stroke-width="0.1" - stroke="#f73520"/> - </svg> - <style> - :scope - display block - max-width 600px - margin 0 auto - - > svg - display block - - > rect - transform-origin center - - </style> - <script> - this.mixin('api'); - - this.user = this.opts.user; - - this.on('mount', () => { - this.api('aggregation/users/activity', { - user_id: this.user.id - }).then(data => { - data.forEach(d => d.total = d.posts + d.replies + d.reposts); - this.peak = Math.max.apply(null, data.map(d => d.total)) / 2; - let x = 0; - 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 = d.total / this.peak; - 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++; - }); - this.update({ data }); - }); - }); - </script> -</mk-activity-table> diff --git a/src/web/app/common/tags/ellipsis.tag b/src/web/app/common/tags/ellipsis.tag deleted file mode 100644 index 97ef745d02..0000000000 --- a/src/web/app/common/tags/ellipsis.tag +++ /dev/null @@ -1,24 +0,0 @@ -<mk-ellipsis><span>.</span><span>.</span><span>.</span> - <style> - :scope - display inline - - > 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> -</mk-ellipsis> diff --git a/src/web/app/common/tags/error.tag b/src/web/app/common/tags/error.tag deleted file mode 100644 index a5b8d14898..0000000000 --- a/src/web/app/common/tags/error.tag +++ /dev/null @@ -1,215 +0,0 @@ -<mk-error> - <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 onclick={ 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 if={ !troubleshooting } onclick={ troubleshoot }>%i18n:common.tags.mk-error.troubleshoot%</button> - <mk-troubleshooter if={ troubleshooting }/> - <p class="thanks">%i18n:common.tags.mk-error.thanks%</p> - <style> - :scope - display block - 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%) - - > mk-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> - <script> - this.troubleshooting = false; - - this.on('mount', () => { - document.title = 'Oops!'; - document.documentElement.style.background = '#f8f8f8'; - }); - - this.reload = () => { - location.reload(); - }; - - this.troubleshoot = () => { - this.update({ - troubleshooting: true - }); - }; - </script> -</mk-error> - -<mk-troubleshooter> - <h1>%fa:wrench%%i18n:common.tags.mk-error.troubleshooter.title%</h1> - <div> - <p data-wip={ network == null }><virtual if={ network != null }><virtual if={ network }>%fa:check%</virtual><virtual if={ !network }>%fa:times%</virtual></virtual>{ network == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-network%' : '%i18n:common.tags.mk-error.troubleshooter.network%' }<mk-ellipsis if={ network == null }/></p> - <p if={ network == true } data-wip={ internet == null }><virtual if={ internet != null }><virtual if={ internet }>%fa:check%</virtual><virtual if={ !internet }>%fa:times%</virtual></virtual>{ internet == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-internet%' : '%i18n:common.tags.mk-error.troubleshooter.internet%' }<mk-ellipsis if={ internet == null }/></p> - <p if={ internet == true } data-wip={ server == null }><virtual if={ server != null }><virtual if={ server }>%fa:check%</virtual><virtual if={ !server }>%fa:times%</virtual></virtual>{ server == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-server%' : '%i18n:common.tags.mk-error.troubleshooter.server%' }<mk-ellipsis if={ server == null }/></p> - </div> - <p if={ !end }>%i18n:common.tags.mk-error.troubleshooter.finding%<mk-ellipsis/></p> - <p 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 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 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 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> - - <style> - :scope - display block - 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.6em 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> - <script> - this.on('mount', () => { - this.update({ - network: navigator.onLine - }); - - if (!this.network) { - this.update({ - end: true - }); - return; - } - - // Check internet connection - fetch('https://google.com?rand=' + Math.random(), { - mode: 'no-cors' - }).then(() => { - this.update({ - internet: true - }); - - // Check misskey server is available - fetch(`${_API_URL_}/meta`).then(() => { - this.update({ - end: true, - server: true - }); - }) - .catch(() => { - this.update({ - end: true, - server: false - }); - }); - }) - .catch(() => { - this.update({ - end: true, - internet: false - }); - }); - }); - </script> -</mk-troubleshooter> diff --git a/src/web/app/common/tags/file-type-icon.tag b/src/web/app/common/tags/file-type-icon.tag deleted file mode 100644 index dba2ae44d6..0000000000 --- a/src/web/app/common/tags/file-type-icon.tag +++ /dev/null @@ -1,10 +0,0 @@ -<mk-file-type-icon> - <virtual if={ kind == 'image' }>%fa:file-image%</virtual> - <style> - :scope - display inline - </style> - <script> - this.kind = this.opts.type.split('/')[0]; - </script> -</mk-file-type-icon> diff --git a/src/web/app/common/tags/forkit.tag b/src/web/app/common/tags/forkit.tag deleted file mode 100644 index 55d5731081..0000000000 --- a/src/web/app/common/tags/forkit.tag +++ /dev/null @@ -1,40 +0,0 @@ -<mk-forkit><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> - <style> - :scope - display block - position absolute - top 0 - right 0 - - > a - display block - - > 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> -</mk-forkit> diff --git a/src/web/app/common/tags/index.ts b/src/web/app/common/tags/index.ts deleted file mode 100644 index df99d93cc5..0000000000 --- a/src/web/app/common/tags/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -require('./error.tag'); -require('./url.tag'); -require('./url-preview.tag'); -require('./time.tag'); -require('./file-type-icon.tag'); -require('./uploader.tag'); -require('./ellipsis.tag'); -require('./raw.tag'); -require('./number.tag'); -require('./special-message.tag'); -require('./signin.tag'); -require('./signup.tag'); -require('./forkit.tag'); -require('./introduction.tag'); -require('./signin-history.tag'); -require('./twitter-setting.tag'); -require('./authorized-apps.tag'); -require('./poll.tag'); -require('./poll-editor.tag'); -require('./messaging/room.tag'); -require('./messaging/message.tag'); -require('./messaging/index.tag'); -require('./messaging/form.tag'); -require('./stream-indicator.tag'); -require('./activity-table.tag'); -require('./reaction-picker.tag'); -require('./reactions-viewer.tag'); -require('./reaction-icon.tag'); -require('./post-menu.tag'); -require('./nav-links.tag'); diff --git a/src/web/app/common/tags/introduction.tag b/src/web/app/common/tags/introduction.tag deleted file mode 100644 index 28afc6fa46..0000000000 --- a/src/web/app/common/tags/introduction.tag +++ /dev/null @@ -1,25 +0,0 @@ -<mk-introduction> - <article> - <h1>Misskeyとは?</h1> - <p><ruby>Misskey<rt>みすきー</rt></ruby>は、<a href="http://syuilo.com" target="_blank">syuilo</a>が2014年くらいから<a href="https://github.com/syuilo/misskey" target="_blank">オープンソースで</a>開発・運営を行っている、ミニブログベースのSNSです。</p> - <p>無料で誰でも利用でき、広告も掲載していません。</p> - <p><a href={ _DOCS_URL_ } target="_blank">もっと知りたい方はこちら</a></p> - </article> - <style> - :scope - display block - - h1 - margin 0 - text-align center - font-size 1.2em - - p - margin 16px 0 - - &:last-child - margin 0 - text-align center - - </style> -</mk-introduction> diff --git a/src/web/app/common/tags/messaging/form.tag b/src/web/app/common/tags/messaging/form.tag deleted file mode 100644 index 7b133a71c4..0000000000 --- a/src/web/app/common/tags/messaging/form.tag +++ /dev/null @@ -1,175 +0,0 @@ -<mk-messaging-form> - <textarea ref="text" onkeypress={ onkeypress } onpaste={ onpaste } placeholder="%i18n:common.input-message-here%"></textarea> - <div class="files"></div> - <mk-uploader ref="uploader"/> - <button class="send" onclick={ send } disabled={ sending } title="%i18n:common.send%"> - <virtual if={ !sending }>%fa:paper-plane%</virtual><virtual if={ sending }>%fa:spinner .spin%</virtual> - </button> - <button class="attach-from-local" type="button" title="%i18n:common.tags.mk-messaging-form.attach-from-local%"> - %fa:upload% - </button> - <button class="attach-from-drive" type="button" title="%i18n:common.tags.mk-messaging-form.attach-from-drive%"> - %fa:R folder-open% - </button> - <input name="file" type="file" accept="image/*"/> - <style> - :scope - display block - - > textarea - cursor auto - display block - width 100% - min-width 100% - max-width 100% - height 64px - margin 0 - padding 8px - font-size 1em - color #000 - outline none - border none - border-top solid 1px #eee - border-radius 0 - box-shadow none - background transparent - - > .send - position absolute - bottom 0 - right 0 - margin 0 - padding 10px 14px - line-height 1em - 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 - line-height 1em - 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> - <script> - this.mixin('api'); - - this.onpaste = e => { - const data = e.clipboardData; - const items = data.items; - for (const item of items) { - if (item.kind == 'file') { - this.upload(item.getAsFile()); - } - } - }; - - this.onkeypress = e => { - if ((e.which == 10 || e.which == 13) && e.ctrlKey) { - this.send(); - } - }; - - this.selectFile = () => { - this.refs.file.click(); - }; - - this.selectFileFromDrive = () => { - const browser = document.body.appendChild(document.createElement('mk-select-file-from-drive-window')); - const event = riot.observable(); - riot.mount(browser, { - multiple: true, - event: event - }); - event.one('selected', files => { - files.forEach(this.addFile); - }); - }; - - this.send = () => { - this.sending = true; - this.api('messaging/messages/create', { - user_id: this.opts.user.id, - text: this.refs.text.value - }).then(message => { - this.clear(); - }).catch(err => { - console.error(err); - }).then(() => { - this.sending = false; - this.update(); - }); - }; - - this.clear = () => { - this.refs.text.value = ''; - this.files = []; - this.update(); - }; - </script> -</mk-messaging-form> diff --git a/src/web/app/common/tags/messaging/index.tag b/src/web/app/common/tags/messaging/index.tag deleted file mode 100644 index d26cec6cdf..0000000000 --- a/src/web/app/common/tags/messaging/index.tag +++ /dev/null @@ -1,456 +0,0 @@ -<mk-messaging data-compact={ opts.compact }> - <div class="search" if={ !opts.compact }> - <div class="form"> - <label for="search-input">%fa:search%</label> - <input ref="search" type="search" oninput={ search } onkeydown={ onSearchKeydown } placeholder="%i18n:common.tags.mk-messaging.search-user%"/> - </div> - <div class="result"> - <ol class="users" if={ searchResult.length > 0 } ref="searchResult"> - <li each={ user, i in searchResult } onkeydown={ parent.onSearchResultKeydown.bind(null, i) } onclick={ user._click } tabindex="-1"> - <img class="avatar" src={ user.avatar_url + '?thumbnail&size=32' } alt=""/> - <span class="name">{ user.name }</span> - <span class="username">@{ user.username }</span> - </li> - </ol> - </div> - </div> - <div class="history" if={ history.length > 0 }> - <virtual each={ history }> - <a class="user" data-is-me={ is_me } data-is-read={ is_read } onclick={ _click }> - <div> - <img class="avatar" src={ (is_me ? recipient.avatar_url : user.avatar_url) + '?thumbnail&size=64' } alt=""/> - <header> - <span class="name">{ is_me ? recipient.name : user.name }</span> - <span class="username">{ '@' + (is_me ? recipient.username : user.username ) }</span> - <mk-time time={ created_at }/> - </header> - <div class="body"> - <p class="text"><span class="me" if={ is_me }>%i18n:common.tags.mk-messaging.you%:</span>{ text }</p> - </div> - </div> - </a> - </virtual> - </div> - <p class="no-history" if={ !fetching && history.length == 0 }>%i18n:common.tags.mk-messaging.no-history%</p> - <p class="fetching" if={ fetching }>%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <style> - :scope - display block - - &[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 - height 1em - margin auto - color #555 - - > input - margin 0 - padding 0 0 0 38px - 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 - margin-bottom 2px - white-space nowrap - overflow hidden - - > .name - text-align left - display inline - margin 0 - padding 0 - font-size 1em - color rgba(0, 0, 0, 0.9) - font-weight bold - transition all 0.1s ease - - > .username - text-align left - margin 0 0 0 8px - color rgba(0, 0, 0, 0.5) - - > mk-time - position absolute - top 0 - right 0 - display inline - 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> - <script> - this.mixin('i'); - this.mixin('api'); - - this.mixin('messaging-index-stream'); - this.connection = this.messagingIndexStream.getConnection(); - this.connectionId = this.messagingIndexStream.use(); - - this.searchResult = []; - this.history = []; - this.fetching = true; - - this.registerMessage = message => { - message.is_me = message.user_id == this.I.id; - message._click = () => { - this.trigger('navigate-user', message.is_me ? message.recipient : message.user); - }; - }; - - this.on('mount', () => { - this.connection.on('message', this.onMessage); - this.connection.on('read', this.onRead); - - this.api('messaging/history').then(history => { - this.fetching = false; - history.forEach(message => { - this.registerMessage(message); - }); - this.history = history; - this.update(); - }); - }); - - this.on('unmount', () => { - this.connection.off('message', this.onMessage); - this.connection.off('read', this.onRead); - this.messagingIndexStream.dispose(this.connectionId); - }); - - this.onMessage = message => { - this.history = this.history.filter(m => !( - (m.recipient_id == message.recipient_id && m.user_id == message.user_id) || - (m.recipient_id == message.user_id && m.user_id == message.recipient_id))); - - this.registerMessage(message); - - this.history.unshift(message); - this.update(); - }; - - this.onRead = ids => { - ids.forEach(id => { - const found = this.history.find(m => m.id == id); - if (found) found.is_read = true; - }); - - this.update(); - }; - - this.search = () => { - const q = this.refs.search.value; - if (q == '') { - this.searchResult = []; - return; - } - this.api('users/search', { - query: q, - max: 5 - }).then(users => { - users.forEach(user => { - user._click = () => { - this.trigger('navigate-user', user); - this.searchResult = []; - }; - }); - this.update({ - searchResult: users - }); - }); - }; - - this.onSearchKeydown = e => { - switch (e.which) { - case 9: // [TAB] - case 40: // [↓] - e.preventDefault(); - e.stopPropagation(); - this.refs.searchResult.childNodes[0].focus(); - break; - } - }; - - this.onSearchResultKeydown = (i, e) => { - const cancel = () => { - e.preventDefault(); - e.stopPropagation(); - }; - switch (true) { - case e.which == 10: // [ENTER] - case e.which == 13: // [ENTER] - cancel(); - this.searchResult[i]._click(); - break; - - case e.which == 27: // [ESC] - cancel(); - this.refs.search.focus(); - break; - - case e.which == 9 && e.shiftKey: // [TAB] + [Shift] - case e.which == 38: // [↑] - cancel(); - (this.refs.searchResult.childNodes[i].previousElementSibling || this.refs.searchResult.childNodes[this.searchResult.length - 1]).focus(); - break; - - case e.which == 9: // [TAB] - case e.which == 40: // [↓] - cancel(); - (this.refs.searchResult.childNodes[i].nextElementSibling || this.refs.searchResult.childNodes[0]).focus(); - break; - } - }; - - </script> -</mk-messaging> diff --git a/src/web/app/common/tags/messaging/message.tag b/src/web/app/common/tags/messaging/message.tag deleted file mode 100644 index 354022d7df..0000000000 --- a/src/web/app/common/tags/messaging/message.tag +++ /dev/null @@ -1,238 +0,0 @@ -<mk-messaging-message data-is-me={ message.is_me }> - <a class="avatar-anchor" href={ '/' + message.user.username } title={ message.user.username } target="_blank"> - <img class="avatar" src={ message.user.avatar_url + '?thumbnail&size=80' } alt=""/> - </a> - <div class="content-container"> - <div class="balloon"> - <p class="read" if={ message.is_me && message.is_read }>%i18n:common.tags.mk-messaging-message.is-read%</p> - <button class="delete-button" if={ message.is_me } title="%i18n:common.delete%"><img src="/assets/desktop/messaging/delete.png" alt="Delete"/></button> - <div class="content" if={ !message.is_deleted }> - <div ref="text"></div> - <div class="image" if={ message.file }><img src={ message.file.url } alt="image" title={ message.file.name }/></div> - </div> - <div class="content" if={ message.is_deleted }> - <p class="is-deleted">%i18n:common.tags.mk-messaging-message.deleted%</p> - </div> - </div> - <footer> - <mk-time time={ message.created_at }/><virtual if={ message.is_edited }>%fa:pencil-alt%</virtual> - </footer> - </div> - <style> - :scope - $me-balloon-color = #23A7B6 - - display block - padding 10px 12px 10px 12px - background-color transparent - - &:after - content "" - display block - clear both - - > .avatar-anchor - display block - - > .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-container - display block - margin 0 12px - padding 0 - max-width calc(100% - 78px) - - > .balloon - display block - float inherit - margin 0 - padding 0 - max-width 100% - min-height 38px - border-radius 16px - - &:before - content "" - pointer-events none - display block - position absolute - top 12px - - &: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) - - > [ref='text'] - display block - margin 0 - padding 8px 16px - overflow hidden - overflow-wrap break-word - font-size 1em - color rgba(0, 0, 0, 0.8) - - &, * - user-select text - cursor auto - - & + .file - &.image - > img - border-radius 0 0 16px 16px - - > .file - &.image - > img - display block - max-width 100% - max-height 512px - border-radius 16px - - > footer - display block - clear both - margin 0 - padding 2px - font-size 10px - color rgba(0, 0, 0, 0.4) - - > [data-fa] - margin-left 4px - - &:not([data-is-me='true']) - > .avatar-anchor - float left - - > .content-container - float left - - > .balloon - background #eee - - &: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='true'] - > .avatar-anchor - float right - - > .content-container - float right - - > .balloon - background $me-balloon-color - - &: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) - - > [ref='text'] - &, * - color #fff !important - - > footer - text-align right - - &[data-is-deleted='true'] - > .content-container - opacity 0.5 - - </style> - <script> - import compile from '../../../common/scripts/text-compiler'; - - this.mixin('i'); - - this.message = this.opts.message; - this.message.is_me = this.message.user.id == this.I.id; - - this.on('mount', () => { - if (this.message.text) { - const tokens = this.message.ast; - - this.refs.text.innerHTML = compile(tokens); - - Array.from(this.refs.text.children).forEach(e => { - if (e.tagName == 'MK-URL') riot.mount(e); - }); - - // URLをプレビュー - tokens - .filter(t => t.type == 'link') - .map(t => { - const el = this.refs.text.appendChild(document.createElement('mk-url-preview')); - riot.mount(el, { - url: t.content - }); - }); - } - }); - </script> -</mk-messaging-message> diff --git a/src/web/app/common/tags/messaging/room.tag b/src/web/app/common/tags/messaging/room.tag deleted file mode 100644 index 7b4d1be569..0000000000 --- a/src/web/app/common/tags/messaging/room.tag +++ /dev/null @@ -1,319 +0,0 @@ -<mk-messaging-room> - <div class="stream"> - <p class="init" if={ init }>%fa:spinner .spin%%i18n:common.loading%</p> - <p class="empty" if={ !init && messages.length == 0 }>%fa:info-circle%%i18n:common.tags.mk-messaging-room.empty%</p> - <p class="no-history" if={ !init && messages.length > 0 && !moreMessagesIsInStock }>%fa:flag%%i18n:common.tags.mk-messaging-room.no-history%</p> - <button class="more { fetching: fetchingMoreMessages }" if={ moreMessagesIsInStock } onclick={ fetchMoreMessages } disabled={ fetchingMoreMessages }> - <virtual if={ fetchingMoreMessages }>%fa:spinner .pulse .fw%</virtual>{ fetchingMoreMessages ? '%i18n:common.loading%' : '%i18n:common.tags.mk-messaging-room.more%' } - </button> - <virtual each={ message, i in messages }> - <mk-messaging-message message={ message }/> - <p class="date" if={ i != messages.length - 1 && message._date != messages[i + 1]._date }><span>{ messages[i + 1]._datetext }</span></p> - </virtual> - </div> - <footer> - <div ref="notifications"></div> - <div class="grippie" title="%i18n:common.tags.mk-messaging-room.resize-form%"></div> - <mk-messaging-form user={ user }/> - </footer> - <style> - :scope - display block - - > .stream - max-width 600px - margin 0 auto - - > .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 - - > [ref='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 - - > .grippie - height 10px - margin-top -10px - background transparent - cursor ns-resize - - &:hover - //background rgba(0, 0, 0, 0.1) - - &:active - //background rgba(0, 0, 0, 0.2) - - </style> - <script> - import MessagingStreamConnection from '../../scripts/streaming/messaging-stream'; - - this.mixin('i'); - this.mixin('api'); - - this.user = this.opts.user; - this.init = true; - this.sending = false; - this.messages = []; - this.isNaked = this.opts.isNaked; - - this.connection = new MessagingStreamConnection(this.I, this.user.id); - - this.on('mount', () => { - this.connection.on('message', this.onMessage); - this.connection.on('read', this.onRead); - - document.addEventListener('visibilitychange', this.onVisibilitychange); - - this.fetchMessages().then(() => { - this.init = false; - this.update(); - this.scrollToBottom(); - }); - }); - - this.on('unmount', () => { - this.connection.off('message', this.onMessage); - this.connection.off('read', this.onRead); - this.connection.close(); - - document.removeEventListener('visibilitychange', this.onVisibilitychange); - }); - - this.on('update', () => { - this.messages.forEach(message => { - const date = (new Date(message.created_at)).getDate(); - const month = (new Date(message.created_at)).getMonth() + 1; - message._date = date; - message._datetext = month + '月 ' + date + '日'; - }); - }); - - this.onMessage = (message) => { - const isBottom = this.isBottom(); - - this.messages.push(message); - if (message.user_id != this.I.id && !document.hidden) { - this.connection.send({ - type: 'read', - id: message.id - }); - } - this.update(); - - if (isBottom) { - // Scroll to bottom - this.scrollToBottom(); - } else if (message.user_id != this.I.id) { - // Notify - this.notify('%i18n:common.tags.mk-messaging-room.new-message%'); - } - }; - - this.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].is_read = true; - this.update(); - } - }); - }; - - this.fetchMoreMessages = () => { - this.update({ - fetchingMoreMessages: true - }); - this.fetchMessages().then(() => { - this.update({ - fetchingMoreMessages: false - }); - }); - }; - - this.fetchMessages = () => new Promise((resolve, reject) => { - const max = this.moreMessagesIsInStock ? 20 : 10; - - this.api('messaging/messages', { - user_id: this.user.id, - limit: max + 1, - until_id: this.moreMessagesIsInStock ? this.messages[0].id : undefined - }).then(messages => { - if (messages.length == max + 1) { - this.moreMessagesIsInStock = true; - messages.pop(); - } else { - this.moreMessagesIsInStock = false; - } - - this.messages.unshift.apply(this.messages, messages.reverse()); - this.update(); - - resolve(); - }); - }); - - this.isBottom = () => { - const asobi = 32; - const current = this.isNaked - ? window.scrollY + window.innerHeight - : this.root.scrollTop + this.root.offsetHeight; - const max = this.isNaked - ? document.body.offsetHeight - : this.root.scrollHeight; - return current > (max - asobi); - }; - - this.scrollToBottom = () => { - if (this.isNaked) { - window.scroll(0, document.body.offsetHeight); - } else { - this.root.scrollTop = this.root.scrollHeight; - } - }; - - this.notify = message => { - const n = document.createElement('p'); - n.innerHTML = '%fa:arrow-circle-down%' + message; - n.onclick = () => { - this.scrollToBottom(); - n.parentNode.removeChild(n); - }; - this.refs.notifications.appendChild(n); - - setTimeout(() => { - n.style.opacity = 0; - setTimeout(() => n.parentNode.removeChild(n), 1000); - }, 4000); - }; - - this.onVisibilitychange = () => { - if (document.hidden) return; - this.messages.forEach(message => { - if (message.user_id !== this.I.id && !message.is_read) { - this.connection.send({ - type: 'read', - id: message.id - }); - } - }); - }; - </script> -</mk-messaging-room> diff --git a/src/web/app/common/tags/nav-links.tag b/src/web/app/common/tags/nav-links.tag deleted file mode 100644 index ea122575aa..0000000000 --- a/src/web/app/common/tags/nav-links.tag +++ /dev/null @@ -1,10 +0,0 @@ -<mk-nav-links> - <a href={ aboutUrl }>%i18n:common.tags.mk-nav-links.about%</a><i>・</i><a href={ _STATS_URL_ }>%i18n:common.tags.mk-nav-links.stats%</a><i>・</i><a href={ _STATUS_URL_ }>%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={ _DEV_URL_ }>%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> - <style> - :scope - display inline - </style> - <script> - this.aboutUrl = `${_DOCS_URL_}/${_LANG_}/about`; - </script> -</mk-nav-links> diff --git a/src/web/app/common/tags/number.tag b/src/web/app/common/tags/number.tag deleted file mode 100644 index 7afb8b3983..0000000000 --- a/src/web/app/common/tags/number.tag +++ /dev/null @@ -1,16 +0,0 @@ -<mk-number> - <style> - :scope - display inline - </style> - <script> - this.on('mount', () => { - let value = this.opts.value; - const max = this.opts.max; - - if (max != null && value > max) value = max; - - this.root.innerHTML = value.toLocaleString(); - }); - </script> -</mk-number> diff --git a/src/web/app/common/tags/poll-editor.tag b/src/web/app/common/tags/poll-editor.tag deleted file mode 100644 index e79209e9b4..0000000000 --- a/src/web/app/common/tags/poll-editor.tag +++ /dev/null @@ -1,121 +0,0 @@ -<mk-poll-editor> - <p class="caution" if={ choices.length < 2 }> - %fa:exclamation-triangle%%i18n:common.tags.mk-poll-editor.no-only-one-choice% - </p> - <ul ref="choices"> - <li each={ choice, i in choices }> - <input value={ choice } oninput={ oninput.bind(null, i) } placeholder={ '%i18n:common.tags.mk-poll-editor.choice-n%'.replace('{}', i + 1) }> - <button onclick={ remove.bind(null, i) } title="%i18n:common.tags.mk-poll-editor.remove%"> - %fa:times% - </button> - </li> - </ul> - <button class="add" if={ choices.length < 10 } onclick={ add }>%i18n:common.tags.mk-poll-editor.add%</button> - <button class="destroy" onclick={ destroy } title="%i18n:common.tags.mk-poll-editor.destroy%"> - %fa:times% - </button> - <style> - :scope - display block - 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 - 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> - <script> - this.choices = ['', '']; - - this.oninput = (i, e) => { - this.choices[i] = e.target.value; - }; - - this.add = () => { - this.choices.push(''); - this.update(); - this.refs.choices.childNodes[this.choices.length - 1].childNodes[0].focus(); - }; - - this.remove = (i) => { - this.choices = this.choices.filter((_, _i) => _i != i); - this.update(); - }; - - this.destroy = () => { - this.opts.ondestroy(); - }; - - this.get = () => { - return { - choices: this.choices.filter(choice => choice != '') - } - }; - - this.set = data => { - if (data.choices.length == 0) return; - this.choices = data.choices; - }; - </script> -</mk-poll-editor> diff --git a/src/web/app/common/tags/poll.tag b/src/web/app/common/tags/poll.tag deleted file mode 100644 index 32542418aa..0000000000 --- a/src/web/app/common/tags/poll.tag +++ /dev/null @@ -1,109 +0,0 @@ -<mk-poll data-is-voted={ isVoted }> - <ul> - <li each={ poll.choices } onclick={ vote.bind(null, id) } class={ voted: voted } title={ !parent.isVoted ? '%i18n:common.tags.mk-poll.vote-to%'.replace('{}', text) : '' }> - <div class="backdrop" style={ 'width:' + (parent.result ? (votes / parent.total * 100) : 0) + '%' }></div> - <span> - <virtual if={ is_voted }>%fa:check%</virtual> - { text } - <span class="votes" if={ parent.result }>({ '%i18n:common.tags.mk-poll.vote-count%'.replace('{}', votes) })</span> - </span> - </li> - </ul> - <p if={ total > 0 }> - <span>{ '%i18n:common.tags.mk-poll.total-users%'.replace('{}', total) }</span> - ・ - <a if={ !isVoted } onclick={ toggleResult }>{ result ? '%i18n:common.tags.mk-poll.vote%' : '%i18n:common.tags.mk-poll.show-result%' }</a> - <span if={ isVoted }>%i18n:common.tags.mk-poll.voted%</span> - </p> - <style> - :scope - display block - - > 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 - - > .votes - margin-left 4px - - > p - a - color inherit - - &[data-is-voted] - > ul > li - cursor default - - &:hover - background transparent - - &:active - background transparent - - </style> - <script> - this.mixin('api'); - - this.init = post => { - this.post = post; - this.poll = this.post.poll; - this.total = this.poll.choices.reduce((a, b) => a + b.votes, 0); - this.isVoted = this.poll.choices.some(c => c.is_voted); - this.result = this.isVoted; - this.update(); - }; - - this.init(this.opts.post); - - this.toggleResult = () => { - this.result = !this.result; - }; - - this.vote = id => { - if (this.poll.choices.some(c => c.is_voted)) return; - this.api('posts/polls/vote', { - post_id: this.post.id, - choice: id - }).then(() => { - this.poll.choices.forEach(c => { - if (c.id == id) { - c.votes++; - c.is_voted = true; - } - }); - this.update({ - poll: this.poll, - isVoted: true, - result: true, - total: this.total + 1 - }); - }); - }; - </script> -</mk-poll> diff --git a/src/web/app/common/tags/post-menu.tag b/src/web/app/common/tags/post-menu.tag deleted file mode 100644 index be4468a214..0000000000 --- a/src/web/app/common/tags/post-menu.tag +++ /dev/null @@ -1,157 +0,0 @@ -<mk-post-menu> - <div class="backdrop" ref="backdrop" onclick={ close }></div> - <div class="popover { compact: opts.compact }" ref="popover"> - <button if={ post.user_id === I.id } onclick={ pin }>%i18n:common.tags.mk-post-menu.pin%</button> - <div if={ I.is_pro && !post.is_category_verified }> - <select ref="categorySelect"> - <option value="">%i18n:common.tags.mk-post-menu.select%</option> - <option value="music">%i18n:common.post_categories.music%</option> - <option value="game">%i18n:common.post_categories.game%</option> - <option value="anime">%i18n:common.post_categories.anime%</option> - <option value="it">%i18n:common.post_categories.it%</option> - <option value="gadgets">%i18n:common.post_categories.gadgets%</option> - <option value="photography">%i18n:common.post_categories.photography%</option> - </select> - <button onclick={ categorize }>%i18n:common.tags.mk-post-menu.categorize%</button> - </div> - </div> - <style> - $border-color = rgba(27, 31, 35, 0.15) - - :scope - display block - 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 - - </style> - <script> - import anime from 'animejs'; - - this.mixin('i'); - this.mixin('api'); - - this.post = this.opts.post; - this.source = this.opts.source; - - this.on('mount', () => { - const rect = this.source.getBoundingClientRect(); - const width = this.refs.popover.offsetWidth; - const height = this.refs.popover.offsetHeight; - if (this.opts.compact) { - const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); - const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); - this.refs.popover.style.left = (x - (width / 2)) + 'px'; - this.refs.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; - this.refs.popover.style.left = (x - (width / 2)) + 'px'; - this.refs.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 - }); - }); - - this.pin = () => { - this.api('i/pin', { - post_id: this.post.id - }).then(() => { - if (this.opts.cb) this.opts.cb('pinned', '%i18n:common.tags.mk-post-menu.pinned%'); - this.unmount(); - }); - }; - - this.categorize = () => { - const category = this.refs.categorySelect.options[this.refs.categorySelect.selectedIndex].value; - this.api('posts/categorize', { - post_id: this.post.id, - category: category - }).then(() => { - if (this.opts.cb) this.opts.cb('categorized', '%i18n:common.tags.mk-post-menu.categorized%'); - this.unmount(); - }); - }; - - this.close = () => { - this.refs.backdrop.style.pointerEvents = 'none'; - anime({ - targets: this.refs.backdrop, - opacity: 0, - duration: 200, - easing: 'linear' - }); - - this.refs.popover.style.pointerEvents = 'none'; - anime({ - targets: this.refs.popover, - opacity: 0, - scale: 0.5, - duration: 200, - easing: 'easeInBack', - complete: () => this.unmount() - }); - }; - </script> -</mk-post-menu> diff --git a/src/web/app/common/tags/raw.tag b/src/web/app/common/tags/raw.tag deleted file mode 100644 index adc6de5a3b..0000000000 --- a/src/web/app/common/tags/raw.tag +++ /dev/null @@ -1,13 +0,0 @@ -<mk-raw> - <style> - :scope - display inline - </style> - <script> - this.root.innerHTML = this.opts.content; - - this.on('updated', () => { - this.root.innerHTML = this.opts.content; - }); - </script> -</mk-raw> diff --git a/src/web/app/common/tags/reaction-icon.tag b/src/web/app/common/tags/reaction-icon.tag deleted file mode 100644 index 0127293917..0000000000 --- a/src/web/app/common/tags/reaction-icon.tag +++ /dev/null @@ -1,21 +0,0 @@ -<mk-reaction-icon> - <virtual if={ opts.reaction == 'like' }><img src="/assets/reactions/like.png" alt="%i18n:common.reactions.like%"></virtual> - <virtual if={ opts.reaction == 'love' }><img src="/assets/reactions/love.png" alt="%i18n:common.reactions.love%"></virtual> - <virtual if={ opts.reaction == 'laugh' }><img src="/assets/reactions/laugh.png" alt="%i18n:common.reactions.laugh%"></virtual> - <virtual if={ opts.reaction == 'hmm' }><img src="/assets/reactions/hmm.png" alt="%i18n:common.reactions.hmm%"></virtual> - <virtual if={ opts.reaction == 'surprise' }><img src="/assets/reactions/surprise.png" alt="%i18n:common.reactions.surprise%"></virtual> - <virtual if={ opts.reaction == 'congrats' }><img src="/assets/reactions/congrats.png" alt="%i18n:common.reactions.congrats%"></virtual> - <virtual if={ opts.reaction == 'angry' }><img src="/assets/reactions/angry.png" alt="%i18n:common.reactions.angry%"></virtual> - <virtual if={ opts.reaction == 'confused' }><img src="/assets/reactions/confused.png" alt="%i18n:common.reactions.confused%"></virtual> - <virtual if={ opts.reaction == 'pudding' }><img src="/assets/reactions/pudding.png" alt="%i18n:common.reactions.pudding%"></virtual> - - <style> - :scope - display inline - - img - vertical-align middle - width 1em - height 1em - </style> -</mk-reaction-icon> diff --git a/src/web/app/common/tags/reaction-picker.tag b/src/web/app/common/tags/reaction-picker.tag deleted file mode 100644 index 458d16ec71..0000000000 --- a/src/web/app/common/tags/reaction-picker.tag +++ /dev/null @@ -1,184 +0,0 @@ -<mk-reaction-picker> - <div class="backdrop" ref="backdrop" onclick={ close }></div> - <div class="popover { compact: opts.compact }" ref="popover"> - <p if={ !opts.compact }>{ title }</p> - <div> - <button onclick={ react.bind(null, 'like') } onmouseover={ onmouseover } onmouseout={ onmouseout } tabindex="1" title="%i18n:common.reactions.like%"><mk-reaction-icon reaction='like'/></button> - <button onclick={ react.bind(null, 'love') } onmouseover={ onmouseover } onmouseout={ onmouseout } tabindex="2" title="%i18n:common.reactions.love%"><mk-reaction-icon reaction='love'/></button> - <button onclick={ react.bind(null, 'laugh') } onmouseover={ onmouseover } onmouseout={ onmouseout } tabindex="3" title="%i18n:common.reactions.laugh%"><mk-reaction-icon reaction='laugh'/></button> - <button onclick={ react.bind(null, 'hmm') } onmouseover={ onmouseover } onmouseout={ onmouseout } tabindex="4" title="%i18n:common.reactions.hmm%"><mk-reaction-icon reaction='hmm'/></button> - <button onclick={ react.bind(null, 'surprise') } onmouseover={ onmouseover } onmouseout={ onmouseout } tabindex="5" title="%i18n:common.reactions.surprise%"><mk-reaction-icon reaction='surprise'/></button> - <button onclick={ react.bind(null, 'congrats') } onmouseover={ onmouseover } onmouseout={ onmouseout } tabindex="6" title="%i18n:common.reactions.congrats%"><mk-reaction-icon reaction='congrats'/></button> - <button onclick={ react.bind(null, 'angry') } onmouseover={ onmouseover } onmouseout={ onmouseout } tabindex="4" title="%i18n:common.reactions.angry%"><mk-reaction-icon reaction='angry'/></button> - <button onclick={ react.bind(null, 'confused') } onmouseover={ onmouseover } onmouseout={ onmouseout } tabindex="5" title="%i18n:common.reactions.confused%"><mk-reaction-icon reaction='confused'/></button> - <button onclick={ react.bind(null, 'pudding') } onmouseover={ onmouseover } onmouseout={ onmouseout } tabindex="6" title="%i18n:common.reactions.pudding%"><mk-reaction-icon reaction='pudding'/></button> - </div> - </div> - <style> - $border-color = rgba(27, 31, 35, 0.15) - - :scope - display block - 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 - 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> - <script> - import anime from 'animejs'; - - this.mixin('api'); - - this.post = this.opts.post; - this.source = this.opts.source; - - const placeholder = '%i18n:common.tags.mk-reaction-picker.choose-reaction%'; - - this.title = placeholder; - - this.onmouseover = e => { - this.update({ - title: e.target.title - }); - }; - - this.onmouseout = () => { - this.update({ - title: placeholder - }); - }; - - this.on('mount', () => { - const rect = this.source.getBoundingClientRect(); - const width = this.refs.popover.offsetWidth; - const height = this.refs.popover.offsetHeight; - if (this.opts.compact) { - const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); - const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); - this.refs.popover.style.left = (x - (width / 2)) + 'px'; - this.refs.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; - this.refs.popover.style.left = (x - (width / 2)) + 'px'; - this.refs.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 - }); - }); - - this.react = reaction => { - this.api('posts/reactions/create', { - post_id: this.post.id, - reaction: reaction - }).then(() => { - if (this.opts.cb) this.opts.cb(); - this.unmount(); - }); - }; - - this.close = () => { - this.refs.backdrop.style.pointerEvents = 'none'; - anime({ - targets: this.refs.backdrop, - opacity: 0, - duration: 200, - easing: 'linear' - }); - - this.refs.popover.style.pointerEvents = 'none'; - anime({ - targets: this.refs.popover, - opacity: 0, - scale: 0.5, - duration: 200, - easing: 'easeInBack', - complete: () => this.unmount() - }); - }; - </script> -</mk-reaction-picker> diff --git a/src/web/app/common/tags/reactions-viewer.tag b/src/web/app/common/tags/reactions-viewer.tag deleted file mode 100644 index 50fb023f70..0000000000 --- a/src/web/app/common/tags/reactions-viewer.tag +++ /dev/null @@ -1,46 +0,0 @@ -<mk-reactions-viewer> - <virtual if={ reactions }> - <span if={ reactions.like }><mk-reaction-icon reaction='like'/><span>{ reactions.like }</span></span> - <span if={ reactions.love }><mk-reaction-icon reaction='love'/><span>{ reactions.love }</span></span> - <span if={ reactions.laugh }><mk-reaction-icon reaction='laugh'/><span>{ reactions.laugh }</span></span> - <span if={ reactions.hmm }><mk-reaction-icon reaction='hmm'/><span>{ reactions.hmm }</span></span> - <span if={ reactions.surprise }><mk-reaction-icon reaction='surprise'/><span>{ reactions.surprise }</span></span> - <span if={ reactions.congrats }><mk-reaction-icon reaction='congrats'/><span>{ reactions.congrats }</span></span> - <span if={ reactions.angry }><mk-reaction-icon reaction='angry'/><span>{ reactions.angry }</span></span> - <span if={ reactions.confused }><mk-reaction-icon reaction='confused'/><span>{ reactions.confused }</span></span> - <span if={ reactions.pudding }><mk-reaction-icon reaction='pudding'/><span>{ reactions.pudding }</span></span> - </virtual> - <style> - :scope - display block - 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> - <script> - this.post = this.opts.post; - - this.on('mount', () => { - this.update(); - }); - - this.on('update', () => { - this.reactions = this.post.reaction_counts; - }); - </script> -</mk-reactions-viewer> diff --git a/src/web/app/common/tags/signin.tag b/src/web/app/common/tags/signin.tag deleted file mode 100644 index f5a2be94ed..0000000000 --- a/src/web/app/common/tags/signin.tag +++ /dev/null @@ -1,155 +0,0 @@ -<mk-signin> - <form class={ signing: signing } onsubmit={ onsubmit }> - <label class="user-name"> - <input ref="username" type="text" pattern="^[a-zA-Z0-9-]+$" placeholder="%i18n:common.tags.mk-signin.username%" autofocus="autofocus" required="required" oninput={ oninput }/>%fa:at% - </label> - <label class="password"> - <input ref="password" type="password" placeholder="%i18n:common.tags.mk-signin.password%" required="required"/>%fa:lock% - </label> - <label class="token" if={ user && user.two_factor_enabled }> - <input ref="token" type="number" placeholder="%i18n:common.tags.mk-signin.token%" required="required"/>%fa:lock% - </label> - <button type="submit" disabled={ signing }>{ signing ? '%i18n:common.tags.mk-signin.signing-in%' : '%i18n:common.tags.mk-signin.signin%' }</button> - </form> - <style> - :scope - display block - - > form - display block - z-index 2 - - &.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> - <script> - this.mixin('api'); - - this.user = null; - this.signing = false; - - this.oninput = () => { - this.api('users/show', { - username: this.refs.username.value - }).then(user => { - this.user = user; - this.trigger('user', user); - this.update(); - }); - }; - - this.onsubmit = e => { - e.preventDefault(); - - if (this.refs.username.value == '') { - this.refs.username.focus(); - return false; - } - if (this.refs.password.value == '') { - this.refs.password.focus(); - return false; - } - if (this.user && this.user.two_factor_enabled && this.refs.token.value == '') { - this.refs.token.focus(); - return false; - } - - this.update({ - signing: true - }); - - this.api('signin', { - username: this.refs.username.value, - password: this.refs.password.value, - token: this.user && this.user.two_factor_enabled ? this.refs.token.value : undefined - }).then(() => { - location.reload(); - }).catch(() => { - alert('something happened'); - this.update({ - signing: false - }); - }); - - return false; - }; - </script> -</mk-signin> diff --git a/src/web/app/common/tags/signup.tag b/src/web/app/common/tags/signup.tag deleted file mode 100644 index b488efb927..0000000000 --- a/src/web/app/common/tags/signup.tag +++ /dev/null @@ -1,307 +0,0 @@ -<mk-signup> - <form onsubmit={ onsubmit } autocomplete="off"> - <label class="username"> - <p class="caption">%fa:at%%i18n:common.tags.mk-signup.username%</p> - <input ref="username" type="text" pattern="^[a-zA-Z0-9-]{3,20}$" placeholder="a~z、A~Z、0~9、-" autocomplete="off" required="required" onkeyup={ onChangeUsername }/> - <p class="profile-page-url-preview" if={ refs.username.value != '' && username-state != 'invalidFormat' && username-state != 'minRange' && username-state != 'maxRange' }>{ _URL_ + '/' + refs.username.value }</p> - <p class="info" if={ usernameState == 'wait' } style="color:#999">%fa:spinner .pulse .fw%%i18n:common.tags.mk-signup.checking%</p> - <p class="info" if={ usernameState == 'ok' } style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.available%</p> - <p class="info" if={ usernameState == 'unavailable' } style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.unavailable%</p> - <p class="info" if={ usernameState == 'error' } style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.error%</p> - <p class="info" if={ usernameState == 'invalid-format' } style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.invalid-format%</p> - <p class="info" if={ usernameState == 'min-range' } style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.too-short%</p> - <p class="info" 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 ref="password" type="password" placeholder="%i18n:common.tags.mk-signup.password-placeholder%" autocomplete="off" required="required" onkeyup={ onChangePassword }/> - <div class="meter" if={ passwordStrength != '' } data-strength={ passwordStrength }> - <div class="value" ref="passwordMetar"></div> - </div> - <p class="info" if={ passwordStrength == 'low' } style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.weak-password%</p> - <p class="info" if={ passwordStrength == 'medium' } style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.normal-password%</p> - <p class="info" 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 ref="passwordRetype" type="password" placeholder="%i18n:common.tags.mk-signup.retype-placeholder%" autocomplete="off" required="required" onkeyup={ onChangePasswordRetype }/> - <p class="info" if={ passwordRetypeState == 'match' } style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.password-matched%</p> - <p class="info" 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"><virtual if={ recaptchaed }>%fa:toggle-on%</virtual><virtual if={ !recaptchaed }>%fa:toggle-off%</virtual>%i18n:common.tags.mk-signup.recaptcha%</p> - <div if={ recaptcha } class="g-recaptcha" data-callback="onRecaptchaed" data-expired-callback="onRecaptchaExpired" data-sitekey={ recaptcha.site_key }></div> - </label> - <label class="agree-tou"> - <input name="agree-tou" type="checkbox" autocomplete="off" required="required"/> - <p><a href={ touUrl } target="_blank">利用規約</a>に同意する</p> - </label> - <button onclick={ onsubmit }>%i18n:common.tags.mk-signup.create%</button> - </form> - <style> - :scope - display block - min-width 302px - overflow hidden - - > form - - label - display block - margin 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 0 32px 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> - <script> - this.mixin('api'); - const getPasswordStrength = require('syuilo-password-strength'); - - this.usernameState = null; - this.passwordStrength = ''; - this.passwordRetypeState = null; - this.recaptchaed = false; - - this.aboutUrl = `${_DOCS_URL_}/${_LANG_}/tou`; - - window.onRecaptchaed = () => { - this.recaptchaed = true; - this.update(); - }; - - window.onRecaptchaExpired = () => { - this.recaptchaed = false; - this.update(); - }; - - this.on('mount', () => { - this.update({ - recaptcha: { - site_key: _RECAPTCHA_SITEKEY_ - } - }); - - const head = document.getElementsByTagName('head')[0]; - const script = document.createElement('script'); - script.setAttribute('src', 'https://www.google.com/recaptcha/api.js'); - head.appendChild(script); - }); - - this.onChangeUsername = () => { - const username = this.refs.username.value; - - if (username == '') { - this.update({ - usernameState: null - }); - return; - } - - const err = - !username.match(/^[a-zA-Z0-9\-]+$/) ? 'invalid-format' : - username.length < 3 ? 'min-range' : - username.length > 20 ? 'max-range' : - null; - - if (err) { - this.update({ - usernameState: err - }); - return; - } - - this.update({ - usernameState: 'wait' - }); - - this.api('username/available', { - username: username - }).then(result => { - this.update({ - usernameState: result.available ? 'ok' : 'unavailable' - }); - }).catch(err => { - this.update({ - usernameState: 'error' - }); - }); - }; - - this.onChangePassword = () => { - const password = this.refs.password.value; - - if (password == '') { - this.passwordStrength = ''; - return; - } - - const strength = getPasswordStrength(password); - this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; - this.update(); - this.refs.passwordMetar.style.width = `${strength * 100}%`; - }; - - this.onChangePasswordRetype = () => { - const password = this.refs.password.value; - const retypedPassword = this.refs.passwordRetype.value; - - if (retypedPassword == '') { - this.passwordRetypeState = null; - return; - } - - this.passwordRetypeState = password == retypedPassword ? 'match' : 'not-match'; - }; - - this.onsubmit = e => { - e.preventDefault(); - - const username = this.refs.username.value; - const password = this.refs.password.value; - - const locker = document.body.appendChild(document.createElement('mk-locker')); - - this.api('signup', { - username: username, - password: password, - 'g-recaptcha-response': grecaptcha.getResponse() - }).then(() => { - this.api('signin', { - username: username, - password: password - }).then(() => { - location.href = '/'; - }); - }).catch(() => { - alert('%i18n:common.tags.mk-signup.some-error%'); - - grecaptcha.reset(); - this.recaptchaed = false; - - locker.parentNode.removeChild(locker); - }); - - return false; - }; - </script> -</mk-signup> diff --git a/src/web/app/common/tags/special-message.tag b/src/web/app/common/tags/special-message.tag deleted file mode 100644 index 6643b1324a..0000000000 --- a/src/web/app/common/tags/special-message.tag +++ /dev/null @@ -1,27 +0,0 @@ -<mk-special-message> - <p if={ m == 1 && d == 1 }>%i18n:common.tags.mk-special-message.new-year%</p> - <p if={ m == 12 && d == 25 }>%i18n:common.tags.mk-special-message.christmas%</p> - <style> - :scope - display block - - &: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> - <script> - const now = new Date(); - this.d = now.getDate(); - this.m = now.getMonth() + 1; - </script> -</mk-special-message> diff --git a/src/web/app/common/tags/stream-indicator.tag b/src/web/app/common/tags/stream-indicator.tag deleted file mode 100644 index 0eb6196b6d..0000000000 --- a/src/web/app/common/tags/stream-indicator.tag +++ /dev/null @@ -1,78 +0,0 @@ -<mk-stream-indicator> - <p if={ connection.state == 'initializing' }> - %fa:spinner .pulse% - <span>%i18n:common.tags.mk-stream-indicator.connecting%<mk-ellipsis/></span> - </p> - <p if={ connection.state == 'reconnecting' }> - %fa:spinner .pulse% - <span>%i18n:common.tags.mk-stream-indicator.reconnecting%<mk-ellipsis/></span> - </p> - <p if={ connection.state == 'connected' }> - %fa:check% - <span>%i18n:common.tags.mk-stream-indicator.connected%</span> - </p> - <style> - :scope - display block - 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> - <script> - import anime from 'animejs'; - - this.mixin('i'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.on('before-mount', () => { - if (this.connection.state == 'connected') { - this.root.style.opacity = 0; - } - - this.connection.on('_connected_', () => { - this.update(); - setTimeout(() => { - anime({ - targets: this.root, - opacity: 0, - easing: 'linear', - duration: 200 - }); - }, 1000); - }); - - this.connection.on('_closed_', () => { - this.update(); - anime({ - targets: this.root, - opacity: 1, - easing: 'linear', - duration: 100 - }); - }); - }); - - this.on('unmount', () => { - this.stream.dispose(this.connectionId); - }); - </script> -</mk-stream-indicator> diff --git a/src/web/app/common/tags/time.tag b/src/web/app/common/tags/time.tag deleted file mode 100644 index b0d7d24533..0000000000 --- a/src/web/app/common/tags/time.tag +++ /dev/null @@ -1,50 +0,0 @@ -<mk-time> - <time datetime={ opts.time }> - <span if={ mode == 'relative' }>{ relative }</span> - <span if={ mode == 'absolute' }>{ absolute }</span> - <span if={ mode == 'detail' }>{ absolute } ({ relative })</span> - </time> - <script> - this.time = new Date(this.opts.time); - this.mode = this.opts.mode || 'relative'; - this.tickid = null; - - this.absolute = - this.time.getFullYear() + '年' + - (this.time.getMonth() + 1) + '月' + - this.time.getDate() + '日' + - ' ' + - this.time.getHours() + '時' + - this.time.getMinutes() + '分'; - - this.on('mount', () => { - if (this.mode == 'relative' || this.mode == 'detail') { - this.tick(); - this.tickid = setInterval(this.tick, 1000); - } - }); - - this.on('unmount', () => { - if (this.mode === 'relative' || this.mode === 'detail') { - clearInterval(this.tickid); - } - }); - - this.tick = () => { - const now = new Date(); - const ago = (now - this.time) / 1000/*ms*/; - this.relative = - ago >= 31536000 ? '%i18n:common.time.years_ago%' .replace('{}', ~~(ago / 31536000)) : - ago >= 2592000 ? '%i18n:common.time.months_ago%' .replace('{}', ~~(ago / 2592000)) : - ago >= 604800 ? '%i18n:common.time.weeks_ago%' .replace('{}', ~~(ago / 604800)) : - ago >= 86400 ? '%i18n:common.time.days_ago%' .replace('{}', ~~(ago / 86400)) : - ago >= 3600 ? '%i18n:common.time.hours_ago%' .replace('{}', ~~(ago / 3600)) : - ago >= 60 ? '%i18n:common.time.minutes_ago%'.replace('{}', ~~(ago / 60)) : - ago >= 10 ? '%i18n:common.time.seconds_ago%'.replace('{}', ~~(ago % 60)) : - ago >= 0 ? '%i18n:common.time.just_now%' : - ago < 0 ? '%i18n:common.time.future%' : - '%i18n:common.time.unknown%'; - this.update(); - }; - </script> -</mk-time> diff --git a/src/web/app/common/tags/twitter-setting.tag b/src/web/app/common/tags/twitter-setting.tag deleted file mode 100644 index 4d57cfa55a..0000000000 --- a/src/web/app/common/tags/twitter-setting.tag +++ /dev/null @@ -1,62 +0,0 @@ -<mk-twitter-setting> - <p>%i18n:common.tags.mk-twitter-setting.description%<a href={ _DOCS_URL_ + '/link-to-twitter' } target="_blank">%i18n:common.tags.mk-twitter-setting.detail%</a></p> - <p class="account" if={ I.twitter } title={ 'Twitter ID: ' + I.twitter.user_id }>%i18n:common.tags.mk-twitter-setting.connected-to%: <a href={ 'https://twitter.com/' + I.twitter.screen_name } target="_blank">@{ I.twitter.screen_name }</a></p> - <p> - <a href={ _API_URL_ + '/connect/twitter' } target="_blank" onclick={ connect }>{ I.twitter ? '%i18n:common.tags.mk-twitter-setting.reconnect%' : '%i18n:common.tags.mk-twitter-setting.connect%' }</a> - <span if={ I.twitter }> or </span> - <a href={ _API_URL_ + '/disconnect/twitter' } target="_blank" if={ I.twitter } onclick={ disconnect }>%i18n:common.tags.mk-twitter-setting.disconnect%</a> - </p> - <p class="id" if={ I.twitter }>Twitter ID: { I.twitter.user_id }</p> - <style> - :scope - display block - color #4a535a - - .account - border solid 1px #e1e8ed - border-radius 4px - padding 16px - - a - font-weight bold - color inherit - - .id - color #8899a6 - </style> - <script> - this.mixin('i'); - - this.form = null; - - this.on('mount', () => { - this.I.on('updated', this.onMeUpdated); - }); - - this.on('unmount', () => { - this.I.off('updated', this.onMeUpdated); - }); - - this.onMeUpdated = () => { - if (this.I.twitter) { - if (this.form) this.form.close(); - } - }; - - this.connect = e => { - e.preventDefault(); - this.form = window.open(_API_URL_ + '/connect/twitter', - 'twitter_connect_window', - 'height=570,width=520'); - return false; - }; - - this.disconnect = e => { - e.preventDefault(); - window.open(_API_URL_ + '/disconnect/twitter', - 'twitter_disconnect_window', - 'height=570,width=520'); - return false; - }; - </script> -</mk-twitter-setting> diff --git a/src/web/app/common/tags/uploader.tag b/src/web/app/common/tags/uploader.tag deleted file mode 100644 index a95004b46d..0000000000 --- a/src/web/app/common/tags/uploader.tag +++ /dev/null @@ -1,199 +0,0 @@ -<mk-uploader> - <ol if={ uploads.length > 0 }> - <li each={ uploads }> - <div class="img" style="background-image: url({ img })"></div> - <p class="name">%fa:spinner .pulse%{ name }</p> - <p class="status"><span class="initing" if={ progress == undefined }>%i18n:common.tags.mk-uploader.waiting%<mk-ellipsis/></span><span class="kb" if={ progress != undefined }>{ String(Math.floor(progress.value / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }<i>KB</i> / { String(Math.floor(progress.max / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }<i>KB</i></span><span class="percentage" if={ progress != undefined }>{ Math.floor((progress.value / progress.max) * 100) }</span></p> - <progress if={ progress != undefined && progress.value != progress.max } value={ progress.value } max={ progress.max }></progress> - <div class="progress initing" if={ progress == undefined }></div> - <div class="progress waiting" if={ progress != undefined && progress.value == progress.max }></div> - </li> - </ol> - <style> - :scope - display block - 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> - <script> - this.mixin('i'); - - this.uploads = []; - - this.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 - }; - - this.uploads.push(ctx); - this.trigger('change-uploads', this.uploads); - this.update(); - - const reader = new FileReader(); - reader.onload = e => { - ctx.img = e.target.result; - this.update(); - }; - reader.readAsDataURL(file); - - const data = new FormData(); - data.append('i', this.I.token); - data.append('file', file); - - if (folder) data.append('folder_id', folder); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', _API_URL_ + '/drive/files/create', true); - xhr.onload = e => { - const driveFile = JSON.parse(e.target.response); - - this.trigger('uploaded', driveFile); - - this.uploads = this.uploads.filter(x => x.id != id); - this.trigger('change-uploads', this.uploads); - - this.update(); - }; - - xhr.upload.onprogress = e => { - if (e.lengthComputable) { - if (ctx.progress == undefined) ctx.progress = {}; - ctx.progress.max = e.total; - ctx.progress.value = e.loaded; - this.update(); - } - }; - - xhr.send(data); - }; - </script> -</mk-uploader> diff --git a/src/web/app/common/tags/url-preview.tag b/src/web/app/common/tags/url-preview.tag deleted file mode 100644 index 7dbdd8fea2..0000000000 --- a/src/web/app/common/tags/url-preview.tag +++ /dev/null @@ -1,117 +0,0 @@ -<mk-url-preview> - <a href={ url } target="_blank" title={ url } if={ !loading }> - <div class="thumbnail" if={ thumbnail } style={ 'background-image: url(' + thumbnail + ')' }></div> - <article> - <header> - <h1>{ title }</h1> - </header> - <p>{ description }</p> - <footer> - <img class="icon" if={ icon } src={ icon }/> - <p>{ sitename }</p> - </footer> - </article> - </a> - <style> - :scope - display block - font-size 16px - - > a - display block - 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 - - > a - border none - - > .thumbnail - width 70px - - & + article - left 70px - width calc(100% - 70px) - - > article - padding 8px - - </style> - <script> - this.mixin('api'); - - this.url = this.opts.url; - this.loading = true; - - this.on('mount', () => { - 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.loading = false; - this.update(); - }); - }); - }); - </script> -</mk-url-preview> diff --git a/src/web/app/common/tags/url.tag b/src/web/app/common/tags/url.tag deleted file mode 100644 index 2690afc5da..0000000000 --- a/src/web/app/common/tags/url.tag +++ /dev/null @@ -1,54 +0,0 @@ -<mk-url> - <a href={ url } target={ opts.target }> - <span class="schema">{ schema }//</span> - <span class="hostname">{ hostname }</span> - <span class="port" if={ port != '' }>:{ port }</span> - <span class="pathname" if={ pathname != '' }>{ pathname }</span> - <span class="query">{ query }</span> - <span class="hash">{ hash }</span> - %fa:external-link-square-alt% - </a> - <style> - :scope - word-break break-all - - > a - > [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> - <script> - this.url = this.opts.href; - - this.on('before-mount', () => { - 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; - - this.update(); - }); - </script> -</mk-url> diff --git a/src/web/app/common/views/components/connect-failed.troubleshooter.vue b/src/web/app/common/views/components/connect-failed.troubleshooter.vue new file mode 100644 index 0000000000..bede504b58 --- /dev/null +++ b/src/web/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.6em 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/web/app/common/views/components/connect-failed.vue b/src/web/app/common/views/components/connect-failed.vue new file mode 100644 index 0000000000..b48f7cecb9 --- /dev/null +++ b/src/web/app/common/views/components/connect-failed.vue @@ -0,0 +1,104 @@ +<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> +.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/web/app/common/views/components/ellipsis.vue b/src/web/app/common/views/components/ellipsis.vue new file mode 100644 index 0000000000..07349902de --- /dev/null +++ b/src/web/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/web/app/common/views/components/file-type-icon.vue b/src/web/app/common/views/components/file-type-icon.vue new file mode 100644 index 0000000000..aa2f0ed519 --- /dev/null +++ b/src/web/app/common/views/components/file-type-icon.vue @@ -0,0 +1,17 @@ +<template> +<span> + <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/web/app/common/views/components/forkit.vue b/src/web/app/common/views/components/forkit.vue new file mode 100644 index 0000000000..54fc011d16 --- /dev/null +++ b/src/web/app/common/views/components/forkit.vue @@ -0,0 +1,40 @@ +<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> + .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/web/app/common/views/components/images.vue b/src/web/app/common/views/components/images.vue new file mode 100644 index 0000000000..dc802a0180 --- /dev/null +++ b/src/web/app/common/views/components/images.vue @@ -0,0 +1,63 @@ +<template> +<div class="mk-images"> + <mk-images-image v-for="image in images" ref="image" :image="image" :key="image.id"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['images'], + mounted() { + const tags = this.$refs.image as Vue[]; + + if (this.images.length == 1) { + (this.$el.style as any).gridTemplateRows = '1fr'; + + (tags[0].$el.style as any).gridColumn = '1 / 2'; + (tags[0].$el.style as any).gridRow = '1 / 2'; + } else if (this.images.length == 2) { + (this.$el.style as any).gridTemplateColumns = '1fr 1fr'; + (this.$el.style as any).gridTemplateRows = '1fr'; + + (tags[0].$el.style as any).gridColumn = '1 / 2'; + (tags[0].$el.style as any).gridRow = '1 / 2'; + (tags[1].$el.style as any).gridColumn = '2 / 3'; + (tags[1].$el.style as any).gridRow = '1 / 2'; + } else if (this.images.length == 3) { + (this.$el.style as any).gridTemplateColumns = '1fr 0.5fr'; + (this.$el.style as any).gridTemplateRows = '1fr 1fr'; + + (tags[0].$el.style as any).gridColumn = '1 / 2'; + (tags[0].$el.style as any).gridRow = '1 / 3'; + (tags[1].$el.style as any).gridColumn = '2 / 3'; + (tags[1].$el.style as any).gridRow = '1 / 2'; + (tags[2].$el.style as any).gridColumn = '2 / 3'; + (tags[2].$el.style as any).gridRow = '2 / 3'; + } else if (this.images.length == 4) { + (this.$el.style as any).gridTemplateColumns = '1fr 1fr'; + (this.$el.style as any).gridTemplateRows = '1fr 1fr'; + + (tags[0].$el.style as any).gridColumn = '1 / 2'; + (tags[0].$el.style as any).gridRow = '1 / 2'; + (tags[1].$el.style as any).gridColumn = '2 / 3'; + (tags[1].$el.style as any).gridRow = '1 / 2'; + (tags[2].$el.style as any).gridColumn = '1 / 2'; + (tags[2].$el.style as any).gridRow = '2 / 3'; + (tags[3].$el.style as any).gridColumn = '2 / 3'; + (tags[3].$el.style as any).gridRow = '2 / 3'; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-images + display grid + grid-gap 4px + height 256px + + @media (max-width 500px) + height 192px +</style> diff --git a/src/web/app/common/views/components/index.ts b/src/web/app/common/views/components/index.ts new file mode 100644 index 0000000000..ab0f1767d4 --- /dev/null +++ b/src/web/app/common/views/components/index.ts @@ -0,0 +1,43 @@ +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 images from './images.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'; + +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-images', images); +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); diff --git a/src/web/app/common/views/components/messaging-room.form.vue b/src/web/app/common/views/components/messaging-room.form.vue new file mode 100644 index 0000000000..b89365a5d8 --- /dev/null +++ b/src/web/app/common/views/components/messaging-room.form.vue @@ -0,0 +1,186 @@ +<template> +<div class="mk-messaging-form"> + <textarea v-model="text" @keypress="onKeypress" @paste="onPaste" placeholder="%i18n:common.input-message-here%"></textarea> + <div class="file" v-if="file">{{ file.name }}</div> + <mk-uploader ref="uploader"/> + <button class="send" @click="send" :disabled="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" 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 name="file" type="file" accept="image/*"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + text: null, + file: null, + sending: false + }; + }, + methods: { + onPaste(e) { + const data = e.clipboardData; + const items = data.items; + for (const item of items) { + if (item.kind == 'file') { + //this.upload(item.getAsFile()); + } + } + }, + + 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; + }); + }, + + upload() { + // TODO + }, + + send() { + this.sending = true; + (this as any).api('messaging/messages/create', { + user_id: this.user.id, + text: this.text + }).then(message => { + this.clear(); + }).catch(err => { + console.error(err); + }).then(() => { + this.sending = false; + }); + }, + + clear() { + this.text = ''; + this.file = null; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-messaging-form + > textarea + cursor auto + display block + width 100% + min-width 100% + max-width 100% + height 64px + margin 0 + padding 8px + font-size 1em + color #000 + outline none + border none + border-top solid 1px #eee + border-radius 0 + box-shadow none + background transparent + + > .send + position absolute + bottom 0 + right 0 + margin 0 + padding 10px 14px + line-height 1em + 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 + line-height 1em + 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/web/app/common/views/components/messaging-room.message.vue b/src/web/app/common/views/components/messaging-room.message.vue new file mode 100644 index 0000000000..2464eceb7f --- /dev/null +++ b/src/web/app/common/views/components/messaging-room.message.vue @@ -0,0 +1,238 @@ +<template> +<div class="message" :data-is-me="isMe"> + <a class="avatar-anchor" :href="`/${message.user.username}`" :title="message.user.username" target="_blank"> + <img class="avatar" :src="`${message.user.avatar_url}?thumbnail&size=80`" alt=""/> + </a> + <div class="content-container"> + <div class="balloon"> + <p class="read" v-if="isMe && message.is_read">%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.is_deleted"> + <mk-post-html class="text" v-if="message.ast" :ast="message.ast" :i="os.i"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <div class="image" v-if="message.file"> + <img :src="message.file.url" alt="image" :title="message.file.name"/> + </div> + </div> + <div class="content" v-if="message.is_deleted"> + <p class="is-deleted">%i18n:common.tags.mk-messaging-message.deleted%</p> + </div> + </div> + <footer> + <mk-time :time="message.created_at"/> + <template v-if="message.is_edited">%fa:pencil-alt%</template> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['message'], + computed: { + isMe(): boolean { + return this.message.user_id == (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 + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + + > .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-container + display block + margin 0 12px + padding 0 + max-width calc(100% - 78px) + + > .balloon + display block + float inherit + margin 0 + padding 0 + max-width 100% + min-height 38px + border-radius 16px + + &:before + content "" + pointer-events none + display block + position absolute + top 12px + + &: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) + + &, * + user-select text + cursor auto + + & + .file + &.image + > img + border-radius 0 0 16px 16px + + > .file + &.image + > img + display block + max-width 100% + max-height 512px + border-radius 16px + + > footer + display block + clear both + margin 0 + padding 2px + font-size 10px + color rgba(0, 0, 0, 0.4) + + > [data-fa] + margin-left 4px + + &:not([data-is-me]) + > .avatar-anchor + float left + + > .content-container + float left + + > .balloon + background #eee + + &: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 + float right + + > .content-container + float right + + > .balloon + background $me-balloon-color + + &: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] + > .content-container + opacity 0.5 + +</style> diff --git a/src/web/app/common/views/components/messaging-room.vue b/src/web/app/common/views/components/messaging-room.vue new file mode 100644 index 0000000000..cfb1e23acf --- /dev/null +++ b/src/web/app/common/views/components/messaging-room.vue @@ -0,0 +1,322 @@ +<template> +<div class="mk-messaging-room"> + <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> + <div class="grippie" title="%i18n:common.tags.mk-messaging-room.resize-form%"></div> + <x-form :user="user"/> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MessagingStreamConnection from '../../scripts/streaming/messaging-stream'; +import XMessage from './messaging-room.message.vue'; +import XForm from './messaging-room.form.vue'; + +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.created_at).getDate(); + const month = new Date(message.created_at).getMonth() + 1; + message._date = date; + message._datetext = `${month}月 ${date}日`; + return message; + }); + } + }, + + mounted() { + this.connection = new MessagingStreamConnection((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: { + fetchMessages() { + return new Promise((resolve, reject) => { + const max = this.existMoreMessages ? 20 : 10; + + (this as any).api('messaging/messages', { + user_id: this.user.id, + limit: max + 1, + until_id: 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) { + const isBottom = this.isBottom(); + + this.messages.push(message); + if (message.user_id != (this as any).os.i.id && !document.hidden) { + this.connection.send({ + type: 'read', + id: message.id + }); + } + + if (isBottom) { + // Scroll to bottom + this.scrollToBottom(); + } else if (message.user_id != (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].is_read = true; + } + }); + }, + isBottom() { + const asobi = 32; + 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.user_id !== (this as any).os.i.id && !message.is_read) { + this.connection.send({ + type: 'read', + id: message.id + }); + } + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-messaging-room + > .stream + max-width 600px + margin 0 auto + + > .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 + + > .grippie + height 10px + margin-top -10px + background transparent + cursor ns-resize + + &:hover + //background rgba(0, 0, 0, 0.1) + + &:active + //background rgba(0, 0, 0, 0.2) + +</style> diff --git a/src/web/app/common/views/components/messaging.vue b/src/web/app/common/views/components/messaging.vue new file mode 100644 index 0000000000..6dc19b8741 --- /dev/null +++ b/src/web/app/common/views/components/messaging.vue @@ -0,0 +1,457 @@ +<template> +<div class="mk-messaging" :data-compact="compact"> + <div class="search" v-if="!compact"> + <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.avatar_url}?thumbnail&size=32`" alt=""/> + <span class="name">{{ user.name }}</span> + <span class="username">@{{ user.username }}</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/${isMe(message) ? message.recipient.username : message.user.username}`" + :data-is-me="isMe(message)" + :data-is-read="message.is_read" + @click.prevent="navigate(isMe(message) ? message.recipient : message.user)" + :key="message.id" + > + <div> + <img class="avatar" :src="`${isMe(message) ? message.recipient.avatar_url : message.user.avatar_url}?thumbnail&size=64`" alt=""/> + <header> + <span class="name">{{ isMe(message) ? message.recipient.name : message.user.name }}</span> + <span class="username">@{{ isMe(message) ? message.recipient.username : message.user.username }}</span> + <mk-time :time="message.created_at"/> + </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'; + +export default Vue.extend({ + props: { + compact: { + type: Boolean, + default: false + } + }, + 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.stream.dispose(this.connectionId); + }, + methods: { + isMe(message) { + return message.user_id == (this as any).os.i.id; + }, + onMessage(message) { + this.messages = this.messages.filter(m => !( + (m.recipient_id == message.recipient_id && m.user_id == message.user_id) || + (m.recipient_id == message.user_id && m.user_id == message.recipient_id))); + + this.messages.unshift(message); + }, + onRead(ids) { + ids.forEach(id => { + const found = this.messages.find(m => m.id == id); + if (found) found.is_read = 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> +.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 + margin-bottom 2px + white-space nowrap + overflow hidden + + > .name + text-align left + display inline + margin 0 + padding 0 + font-size 1em + color rgba(0, 0, 0, 0.9) + font-weight bold + transition all 0.1s ease + + > .username + text-align left + margin 0 0 0 8px + color rgba(0, 0, 0, 0.5) + + > .mk-time + position absolute + top 0 + right 0 + display inline + 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/web/app/common/views/components/nav.vue b/src/web/app/common/views/components/nav.vue new file mode 100644 index 0000000000..8ce75d3529 --- /dev/null +++ b/src/web/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/web/app/common/views/components/poll-editor.vue b/src/web/app/common/views/components/poll-editor.vue new file mode 100644 index 0000000000..065e919660 --- /dev/null +++ b/src/web/app/common/views/components/poll-editor.vue @@ -0,0 +1,138 @@ +<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> +.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 + 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/web/app/common/views/components/poll.vue b/src/web/app/common/views/components/poll.vue new file mode 100644 index 0000000000..7ed5bc6b1e --- /dev/null +++ b/src/web/app/common/views/components/poll.vue @@ -0,0 +1,118 @@ +<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.is_voted">%fa:check%</template> + {{ choice.text }} + <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> + ・ + <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.is_voted); + } + }, + created() { + this.showResult = this.isVoted; + }, + methods: { + toggleShowResult() { + this.showResult = !this.showResult; + }, + vote(id) { + if (this.poll.choices.some(c => c.is_voted)) return; + (this as any).api('posts/polls/vote', { + post_id: this.post.id, + choice: id + }).then(() => { + this.poll.choices.forEach(c => { + if (c.id == id) { + c.votes++; + Vue.set(c, 'is_voted', true); + } + }); + this.showResult = true; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.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 + + > .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/web/app/common/views/components/post-html.ts b/src/web/app/common/views/components/post-html.ts new file mode 100644 index 0000000000..16d670e851 --- /dev/null +++ b/src/web/app/common/views/components/post-html.ts @@ -0,0 +1,102 @@ +declare const _URL_: string; + +import Vue from 'vue'; +import * as pictograph from 'pictograph'; + +import MkUrl from './url.vue'; + +const escape = text => + text + .replace(/>/g, '>') + .replace(/</g, '<'); + +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 = [].concat.apply([], (this as any).ast.map(token => { + switch (token.type) { + case 'text': + const text = escape(token.content) + .replace(/(\r\n|\n|\r)/g, '\n'); + + if ((this as any).shouldBreak) { + if (text.indexOf('\n') != -1) { + return text.split('\n').map(t => [createElement('span', t), createElement('br')]); + } else { + return createElement('span', text); + } + } else { + return createElement('span', text.replace(/\n/g, ' ')); + } + + case 'bold': + return createElement('strong', escape(token.bold)); + + case 'url': + return createElement(MkUrl, { + props: { + url: escape(token.content), + target: '_blank' + } + }); + + case 'link': + return createElement('a', { + attrs: { + class: 'link', + href: escape(token.url), + target: '_blank', + title: escape(token.url) + } + }, escape(token.title)); + + case 'mention': + return (createElement as any)('a', { + attrs: { + href: `${_URL_}/${escape(token.username)}`, + target: '_blank', + dataIsMe: (this as any).i && (this as any).i.username == token.username + }, + directives: [{ + name: 'user-preview', + value: token.content + }] + }, token.content); + + case 'hashtag': + return createElement('a', { + attrs: { + href: `${_URL_}/search?q=${escape(token.content)}`, + target: '_blank' + } + }, escape(token.content)); + + case 'code': + return createElement('pre', [ + createElement('code', token.html) + ]); + + case 'inline-code': + return createElement('code', token.html); + + case 'emoji': + return createElement('span', pictograph.dic[token.emoji] || token.content); + } + })); + + return createElement('span', els); + } +}); diff --git a/src/web/app/common/views/components/post-menu.vue b/src/web/app/common/views/components/post-menu.vue new file mode 100644 index 0000000000..a53680e55a --- /dev/null +++ b/src/web/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.user_id == 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', { + post_id: 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/web/app/common/views/components/reaction-icon.vue b/src/web/app/common/views/components/reaction-icon.vue new file mode 100644 index 0000000000..7d24f4f9e9 --- /dev/null +++ b/src/web/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/web/app/common/views/components/reaction-picker.vue b/src/web/app/common/views/components/reaction-picker.vue new file mode 100644 index 0000000000..f3731cd632 --- /dev/null +++ b/src/web/app/common/views/components/reaction-picker.vue @@ -0,0 +1,188 @@ +<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', { + post_id: 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> +$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 + 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/web/app/common/views/components/reactions-viewer.vue b/src/web/app/common/views/components/reactions-viewer.vue new file mode 100644 index 0000000000..f6a27d9139 --- /dev/null +++ b/src/web/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.reaction_counts; + } + } +}); +</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/web/app/common/views/components/signin.vue b/src/web/app/common/views/components/signin.vue new file mode 100644 index 0000000000..31243e99a1 --- /dev/null +++ b/src/web/app/common/views/components/signin.vue @@ -0,0 +1,137 @@ +<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.two_factor_enabled"> + <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> +</form> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + signing: false, + user: null, + username: '', + password: '', + token: '' + }; + }, + 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.two_factor_enabled ? this.token : undefined + }).then(() => { + location.reload(); + }).catch(() => { + alert('something happened'); + this.signing = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.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/web/app/common/views/components/signup.vue b/src/web/app/common/views/components/signup.vue new file mode 100644 index 0000000000..1fdc49a181 --- /dev/null +++ b/src/web/app/common/views/components/signup.vue @@ -0,0 +1,285 @@ +<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> +.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/web/app/common/views/components/special-message.vue b/src/web/app/common/views/components/special-message.vue new file mode 100644 index 0000000000..2fd4d6515e --- /dev/null +++ b/src/web/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/web/app/common/views/components/stream-indicator.vue b/src/web/app/common/views/components/stream-indicator.vue new file mode 100644 index 0000000000..c1c0672e48 --- /dev/null +++ b/src/web/app/common/views/components/stream-indicator.vue @@ -0,0 +1,92 @@ +<template> +<div class="mk-stream-indicator" v-if="stream"> + <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({ + data() { + return { + stream: null + }; + }, + created() { + this.stream = (this as any).os.stream.borrow(); + + (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() { + this.stream = (this as any).os.stream.borrow(); + + setTimeout(() => { + anime({ + targets: this.$el, + opacity: 0, + easing: 'linear', + duration: 200 + }); + }, 1000); + }, + onDisconnected() { + this.stream = null; + + 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/web/app/common/views/components/time.vue b/src/web/app/common/views/components/time.vue new file mode 100644 index 0000000000..6e0d2b0dcb --- /dev/null +++ b/src/web/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/web/app/common/views/components/twitter-setting.vue b/src/web/app/common/views/components/twitter-setting.vue new file mode 100644 index 0000000000..aaca6ccddd --- /dev/null +++ b/src/web/app/common/views/components/twitter-setting.vue @@ -0,0 +1,64 @@ +<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.twitter" :title="`Twitter ID: ${os.i.twitter.user_id}`">%i18n:common.tags.mk-twitter-setting.connected-to%: <a :href="`https://twitter.com/${os.i.twitter.screen_name}`" target="_blank">@{{ os.i.twitter.screen_name }}</a></p> + <p> + <a :href="`${apiUrl}/connect/twitter`" target="_blank" @click.prevent="connect">{{ os.i.twitter ? '%i18n:common.tags.mk-twitter-setting.reconnect%' : '%i18n:common.tags.mk-twitter-setting.connect%' }}</a> + <span v-if="os.i.twitter"> or </span> + <a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="os.i.twitter" @click.prevent="disconnect">%i18n:common.tags.mk-twitter-setting.disconnect%</a> + </p> + <p class="id" v-if="os.i.twitter">Twitter ID: {{ os.i.twitter.user_id }}</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 + }; + }, + watch: { + 'os.i'() { + if ((this as any).os.i.twitter) { + if (this.form) this.form.close(); + } + } + }, + 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/web/app/common/views/components/uploader.vue b/src/web/app/common/views/components/uploader.vue new file mode 100644 index 0000000000..6367b69973 --- /dev/null +++ b/src/web/app/common/views/components/uploader.vue @@ -0,0 +1,210 @@ +<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.token); + data.append('file', file); + + if (folder) data.append('folder_id', 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> +.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/web/app/common/views/components/url-preview.vue b/src/web/app/common/views/components/url-preview.vue new file mode 100644 index 0000000000..b846346179 --- /dev/null +++ b/src/web/app/common/views/components/url-preview.vue @@ -0,0 +1,123 @@ +<template> +<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> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['url'], + data() { + return { + fetching: true, + title: null, + description: null, + thumbnail: null, + icon: null, + sitename: null + }; + }, + created() { + 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> +.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/web/app/common/views/components/url.vue b/src/web/app/common/views/components/url.vue new file mode 100644 index 0000000000..14d4fc82f3 --- /dev/null +++ b/src/web/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/web/app/common/views/directives/focus.ts b/src/web/app/common/views/directives/focus.ts new file mode 100644 index 0000000000..b4fbcb6a87 --- /dev/null +++ b/src/web/app/common/views/directives/focus.ts @@ -0,0 +1,5 @@ +export default { + inserted(el) { + el.focus(); + } +}; diff --git a/src/web/app/common/views/directives/index.ts b/src/web/app/common/views/directives/index.ts new file mode 100644 index 0000000000..358866f500 --- /dev/null +++ b/src/web/app/common/views/directives/index.ts @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +import focus from './focus'; + +Vue.directive('focus', focus); diff --git a/src/web/app/config.ts b/src/web/app/config.ts new file mode 100644 index 0000000000..2461b22158 --- /dev/null +++ b/src/web/app/config.ts @@ -0,0 +1,29 @@ +declare const _HOST_: string; +declare const _URL_: string; +declare const _API_URL_: string; +declare const _DOCS_URL_: string; +declare const _STATS_URL_: string; +declare const _STATUS_URL_: string; +declare const _DEV_URL_: string; +declare const _CH_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; + +export const host = _HOST_; +export const url = _URL_; +export const apiUrl = _API_URL_; +export const docsUrl = _DOCS_URL_; +export const statsUrl = _STATS_URL_; +export const statusUrl = _STATUS_URL_; +export const devUrl = _DEV_URL_; +export const chUrl = _CH_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_; diff --git a/src/web/app/desktop/api/choose-drive-file.ts b/src/web/app/desktop/api/choose-drive-file.ts new file mode 100644 index 0000000000..8920362445 --- /dev/null +++ b/src/web/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', + 'drive_window', + 'height=500, width=800'); + } + }); +} diff --git a/src/web/app/desktop/api/choose-drive-folder.ts b/src/web/app/desktop/api/choose-drive-folder.ts new file mode 100644 index 0000000000..9b33a20d9a --- /dev/null +++ b/src/web/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/web/app/desktop/api/contextmenu.ts b/src/web/app/desktop/api/contextmenu.ts new file mode 100644 index 0000000000..b70d7122d3 --- /dev/null +++ b/src/web/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/web/app/desktop/api/dialog.ts b/src/web/app/desktop/api/dialog.ts new file mode 100644 index 0000000000..07935485b0 --- /dev/null +++ b/src/web/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/web/app/desktop/api/input.ts b/src/web/app/desktop/api/input.ts new file mode 100644 index 0000000000..ce26a8112f --- /dev/null +++ b/src/web/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/web/app/desktop/api/notify.ts b/src/web/app/desktop/api/notify.ts new file mode 100644 index 0000000000..1f89f40ce6 --- /dev/null +++ b/src/web/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/web/app/desktop/api/post.ts b/src/web/app/desktop/api/post.ts new file mode 100644 index 0000000000..cf49615df3 --- /dev/null +++ b/src/web/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/web/app/desktop/api/update-avatar.ts b/src/web/app/desktop/api/update-avatar.ts new file mode 100644 index 0000000000..c3e0ce14c7 --- /dev/null +++ b/src/web/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.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('folder_id', 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', { + avatar_id: file.id + }).then(i => { + os.i.avatar_id = i.avatar_id; + os.i.avatar_url = i.avatar_url; + + 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/web/app/desktop/api/update-banner.ts b/src/web/app/desktop/api/update-banner.ts new file mode 100644 index 0000000000..9e94dc423b --- /dev/null +++ b/src/web/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.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('folder_id', 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', { + banner_id: file.id + }).then(i => { + os.i.banner_id = i.banner_id; + os.i.banner_url = i.banner_url; + + 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/web/app/desktop/mixins/index.ts b/src/web/app/desktop/mixins/index.ts deleted file mode 100644 index e0c94ec5ee..0000000000 --- a/src/web/app/desktop/mixins/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -require('./user-preview'); -require('./widget'); diff --git a/src/web/app/desktop/mixins/user-preview.ts b/src/web/app/desktop/mixins/user-preview.ts deleted file mode 100644 index 614de72bea..0000000000 --- a/src/web/app/desktop/mixins/user-preview.ts +++ /dev/null @@ -1,66 +0,0 @@ -import * as riot from 'riot'; - -riot.mixin('user-preview', { - init: function() { - const scan = () => { - this.root.querySelectorAll('[data-user-preview]:not([data-user-preview-attached])') - .forEach(attach.bind(this)); - }; - this.on('mount', scan); - this.on('updated', scan); - } -}); - -function attach(el) { - el.setAttribute('data-user-preview-attached', true); - - const user = el.getAttribute('data-user-preview'); - let tag = null; - let showTimer = null; - let hideTimer = null; - - el.addEventListener('mouseover', () => { - clearTimeout(showTimer); - clearTimeout(hideTimer); - showTimer = setTimeout(show, 500); - }); - - el.addEventListener('mouseleave', () => { - clearTimeout(showTimer); - clearTimeout(hideTimer); - hideTimer = setTimeout(close, 500); - }); - - this.on('unmount', () => { - clearTimeout(showTimer); - clearTimeout(hideTimer); - close(); - }); - - const show = () => { - if (tag) return; - const preview = document.createElement('mk-user-preview'); - 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(hideTimer); - }); - preview.addEventListener('mouseleave', () => { - clearTimeout(showTimer); - hideTimer = setTimeout(close, 500); - }); - tag = (riot as any).mount(document.body.appendChild(preview), { - user: user - })[0]; - }; - - const close = () => { - if (tag) { - tag.close(); - tag = null; - } - }; -} diff --git a/src/web/app/desktop/mixins/widget.ts b/src/web/app/desktop/mixins/widget.ts deleted file mode 100644 index 04131cd8f0..0000000000 --- a/src/web/app/desktop/mixins/widget.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as riot from 'riot'; - -// ミックスインにオプションを渡せないのアレ -// SEE: https://github.com/riot/riot/issues/2434 - -(riot as any).mixin('widget', { - init: function() { - this.mixin('i'); - this.mixin('api'); - - this.id = this.opts.id; - this.place = this.opts.place; - - if (this.data) { - Object.keys(this.data).forEach(prop => { - this.data[prop] = this.opts.data.hasOwnProperty(prop) ? this.opts.data[prop] : this.data[prop]; - }); - } - }, - - save: function() { - this.update(); - this.api('i/update_home', { - id: this.id, - data: this.data - }).then(() => { - this.I.client_settings.home.find(w => w.id == this.id).data = this.data; - this.I.update(); - }); - } -}); diff --git a/src/web/app/desktop/router.ts b/src/web/app/desktop/router.ts deleted file mode 100644 index ce68c4f2d1..0000000000 --- a/src/web/app/desktop/router.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Desktop App Router - */ - -import * as riot from 'riot'; -import * as route from 'page'; -import MiOS from '../common/mios'; -let page = null; - -export default (mios: MiOS) => { - route('/', index); - route('/selectdrive', selectDrive); - route('/i/customize-home', customizeHome); - route('/i/drive', drive); - route('/i/drive/folder/:folder', drive); - route('/i/messaging/:user', messaging); - route('/i/mentions', mentions); - route('/post::post', post); - route('/search', search); - route('/:user', user.bind(null, 'home')); - route('/:user/graphs', user.bind(null, 'graphs')); - route('/:user/:post', post); - route('*', notFound); - - function index() { - mios.isSignedin ? home() : entrance(); - } - - function home() { - mount(document.createElement('mk-home-page')); - } - - function customizeHome() { - mount(document.createElement('mk-home-customize-page')); - } - - function entrance() { - mount(document.createElement('mk-entrance')); - document.documentElement.setAttribute('data-page', 'entrance'); - } - - function mentions() { - const el = document.createElement('mk-home-page'); - el.setAttribute('mode', 'mentions'); - mount(el); - } - - function search(ctx) { - const el = document.createElement('mk-search-page'); - el.setAttribute('query', ctx.querystring.substr(2)); - mount(el); - } - - function user(page, ctx) { - const el = document.createElement('mk-user-page'); - el.setAttribute('user', ctx.params.user); - el.setAttribute('page', page); - mount(el); - } - - function post(ctx) { - const el = document.createElement('mk-post-page'); - el.setAttribute('post', ctx.params.post); - mount(el); - } - - function selectDrive() { - mount(document.createElement('mk-selectdrive-page')); - } - - function drive(ctx) { - const el = document.createElement('mk-drive-page'); - if (ctx.params.folder) el.setAttribute('folder', ctx.params.folder); - mount(el); - } - - function messaging(ctx) { - const el = document.createElement('mk-messaging-room-page'); - el.setAttribute('user', ctx.params.user); - mount(el); - } - - function notFound() { - mount(document.createElement('mk-not-found')); - } - - (riot as any).mixin('page', { - page: route - }); - - // EXEC - (route as any)(); -}; - -function mount(content) { - document.documentElement.removeAttribute('data-page'); - if (page) page.unmount(); - const body = document.getElementById('app'); - page = riot.mount(body.appendChild(content))[0]; -} diff --git a/src/web/app/desktop/script.ts b/src/web/app/desktop/script.ts index b06cb180e1..e7c8f8e492 100644 --- a/src/web/app/desktop/script.ts +++ b/src/web/app/desktop/script.ts @@ -5,24 +5,55 @@ // Style import './style.styl'; -require('./tags'); -require('./mixins'); -import * as riot from 'riot'; import init from '../init'; -import route from './router'; -import fuckAdBlock from './scripts/fuck-ad-block'; -import MiOS from '../common/mios'; +import fuckAdBlock from '../common/scripts/fuck-ad-block'; import HomeStreamManager from '../common/scripts/streaming/home-stream-manager'; 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'; + /** * init */ -init(async (mios: MiOS) => { +init(async (launch) => { + // Register directives + require('./views/directives'); + + // Register components + require('./views/components'); + + // Launch the app + const [app, os] = launch(os => ({ + chooseDriveFolder, + chooseDriveFile, + dialog, + input, + post, + notify, + updateAvatar: updateAvatar(os), + updateBanner: updateBanner(os) + })); + /** * Fuck AD Block */ - fuckAdBlock(); + fuckAdBlock(os); /** * Init Notification @@ -34,12 +65,22 @@ init(async (mios: MiOS) => { } if ((Notification as any).permission == 'granted') { - registerNotifications(mios.stream); + registerNotifications(app.$data.os.stream); } } - // Start routing - route(mios); + // Routing + app.$router.addRoutes([ + { path: '/', name: 'index', component: MkIndex }, + { path: '/i/customize-home', component: MkHomeCustomize }, + { path: '/i/messaging/:username', component: MkMessagingRoom }, + { path: '/i/drive', component: MkDrive }, + { path: '/i/drive/folder/:folder', component: MkDrive }, + { path: '/selectdrive', component: MkSelectDrive }, + { path: '/search', component: MkSearch }, + { path: '/:user', component: MkUser }, + { path: '/:user/:post', component: MkPost } + ]); }, true); function registerNotifications(stream: HomeStreamManager) { @@ -98,9 +139,9 @@ function registerNotifications(stream: HomeStreamManager) { }); n.onclick = () => { n.close(); - (riot as any).mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), { + /*(riot as any).mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), { user: message.user - }); + });*/ }; setTimeout(n.close.bind(n), 7000); }); diff --git a/src/web/app/desktop/scripts/dialog.ts b/src/web/app/desktop/scripts/dialog.ts deleted file mode 100644 index 816ba4b5f5..0000000000 --- a/src/web/app/desktop/scripts/dialog.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as riot from 'riot'; - -export default (title, text, buttons, canThrough?, onThrough?) => { - const dialog = document.body.appendChild(document.createElement('mk-dialog')); - const controller = riot.observable(); - (riot as any).mount(dialog, { - controller: controller, - title: title, - text: text, - buttons: buttons, - canThrough: canThrough, - onThrough: onThrough - }); - controller.trigger('open'); - return controller; -}; diff --git a/src/web/app/desktop/scripts/fuck-ad-block.ts b/src/web/app/desktop/scripts/fuck-ad-block.ts deleted file mode 100644 index ddeb600b6e..0000000000 --- a/src/web/app/desktop/scripts/fuck-ad-block.ts +++ /dev/null @@ -1,20 +0,0 @@ -require('fuckadblock'); -import dialog from './dialog'; - -declare const fuckAdBlock: any; - -export default () => { - if (fuckAdBlock === undefined) { - adBlockDetected(); - } else { - fuckAdBlock.onDetected(adBlockDetected); - } -}; - -function adBlockDetected() { - dialog('%fa:exclamation-triangle%広告ブロッカーを無効にしてください', - '<strong>Misskeyは広告を掲載していません</strong>が、広告をブロックする機能が有効だと一部の機能が利用できなかったり、不具合が発生する場合があります。', - [{ - text: 'OK' - }]); -} diff --git a/src/web/app/desktop/scripts/input-dialog.ts b/src/web/app/desktop/scripts/input-dialog.ts deleted file mode 100644 index b06d011c6b..0000000000 --- a/src/web/app/desktop/scripts/input-dialog.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as riot from 'riot'; - -export default (title, placeholder, defaultValue, onOk, onCancel) => { - const dialog = document.body.appendChild(document.createElement('mk-input-dialog')); - return (riot as any).mount(dialog, { - title: title, - placeholder: placeholder, - 'default': defaultValue, - onOk: onOk, - onCancel: onCancel - }); -}; diff --git a/src/web/app/desktop/scripts/not-implemented-exception.ts b/src/web/app/desktop/scripts/not-implemented-exception.ts deleted file mode 100644 index b4660fa62f..0000000000 --- a/src/web/app/desktop/scripts/not-implemented-exception.ts +++ /dev/null @@ -1,8 +0,0 @@ -import dialog from './dialog'; - -export default () => { - dialog('%fa:exclamation-triangle%Not implemented yet', - '要求された操作は実装されていません。<br>→<a href="https://github.com/syuilo/misskey" target="_blank">Misskeyの開発に参加する</a>', [{ - text: 'OK' - }]); -}; diff --git a/src/web/app/desktop/scripts/notify.ts b/src/web/app/desktop/scripts/notify.ts deleted file mode 100644 index 2e6cbdeed8..0000000000 --- a/src/web/app/desktop/scripts/notify.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as riot from 'riot'; - -export default message => { - const notification = document.body.appendChild(document.createElement('mk-ui-notification')); - (riot as any).mount(notification, { - message: message - }); -}; diff --git a/src/web/app/desktop/scripts/password-dialog.ts b/src/web/app/desktop/scripts/password-dialog.ts deleted file mode 100644 index 39d7f3db7a..0000000000 --- a/src/web/app/desktop/scripts/password-dialog.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as riot from 'riot'; - -export default (title, onOk, onCancel) => { - const dialog = document.body.appendChild(document.createElement('mk-input-dialog')); - return (riot as any).mount(dialog, { - title: title, - type: 'password', - onOk: onOk, - onCancel: onCancel - }); -}; diff --git a/src/web/app/desktop/scripts/scroll-follower.ts b/src/web/app/desktop/scripts/scroll-follower.ts deleted file mode 100644 index 05072958ce..0000000000 --- a/src/web/app/desktop/scripts/scroll-follower.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * 要素をスクロールに追従させる - */ -export default class ScrollFollower { - private follower: Element; - private containerTop: number; - private topPadding: number; - - constructor(follower: Element, topPadding: number) { - //#region - this.follow = this.follow.bind(this); - //#endregion - - this.follower = follower; - this.containerTop = follower.getBoundingClientRect().top; - this.topPadding = topPadding; - - window.addEventListener('scroll', this.follow); - window.addEventListener('resize', this.follow); - } - - /** - * 追従解除 - */ - public dispose() { - window.removeEventListener('scroll', this.follow); - window.removeEventListener('resize', this.follow); - } - - private follow() { - const windowBottom = window.scrollY + window.innerHeight; - const windowTop = window.scrollY + this.topPadding; - - const rect = this.follower.getBoundingClientRect(); - const followerBottom = (rect.top + window.scrollY) + rect.height; - const screenHeight = window.innerHeight - this.topPadding; - - // スクロールの上部(+余白)がフォロワーコンテナの上部よりも上方にある - if (window.scrollY + this.topPadding < this.containerTop) { - // フォロワーをコンテナの最上部に合わせる - (this.follower.parentNode as any).style.marginTop = '0px'; - return; - } - - // スクロールの下部がフォロワーの下部よりも下方にある かつ 表示領域の縦幅がフォロワーの縦幅よりも狭い - if (windowBottom > followerBottom && rect.height > screenHeight) { - // フォロワーの下部をスクロール下部に合わせる - const top = (windowBottom - rect.height) - this.containerTop; - (this.follower.parentNode as any).style.marginTop = `${top}px`; - return; - } - - // スクロールの上部(+余白)がフォロワーの上部よりも上方にある または 表示領域の縦幅がフォロワーの縦幅よりも広い - if (windowTop < rect.top + window.scrollY || rect.height < screenHeight) { - // フォロワーの上部をスクロール上部(+余白)に合わせる - const top = windowTop - this.containerTop; - (this.follower.parentNode as any).style.marginTop = `${top}px`; - return; - } - } -} diff --git a/src/web/app/desktop/scripts/update-avatar.ts b/src/web/app/desktop/scripts/update-avatar.ts deleted file mode 100644 index fea5db80bb..0000000000 --- a/src/web/app/desktop/scripts/update-avatar.ts +++ /dev/null @@ -1,88 +0,0 @@ -declare const _API_URL_: string; - -import * as riot from 'riot'; -import dialog from './dialog'; -import api from '../../common/scripts/api'; - -export default (I, cb, file = null) => { - const fileSelected = file => { - const cropper = (riot as any).mount(document.body.appendChild(document.createElement('mk-crop-window')), { - file: file, - title: 'アバターとして表示する部分を選択', - aspectRatio: 1 / 1 - })[0]; - - cropper.on('cropped', blob => { - const data = new FormData(); - data.append('i', I.token); - data.append('file', blob, file.name + '.cropped.png'); - - api(I, 'drive/folders/find', { - name: 'アイコン' - }).then(iconFolder => { - if (iconFolder.length === 0) { - api(I, 'drive/folders/create', { - name: 'アイコン' - }).then(iconFolder => { - upload(data, iconFolder); - }); - } else { - upload(data, iconFolder[0]); - } - }); - }); - - cropper.on('skipped', () => { - set(file); - }); - }; - - const upload = (data, folder) => { - const progress = (riot as any).mount(document.body.appendChild(document.createElement('mk-progress-dialog')), { - title: '新しいアバターをアップロードしています' - })[0]; - - if (folder) data.append('folder_id', folder.id); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', _API_URL_ + '/drive/files/create', true); - xhr.onload = e => { - const file = JSON.parse((e.target as any).response); - progress.close(); - set(file); - }; - - xhr.upload.onprogress = e => { - if (e.lengthComputable) progress.updateProgress(e.loaded, e.total); - }; - - xhr.send(data); - }; - - const set = file => { - api(I, 'i/update', { - avatar_id: file.id - }).then(i => { - dialog('%fa:info-circle%アバターを更新しました', - '新しいアバターが反映されるまで時間がかかる場合があります。', - [{ - text: 'わかった' - }]); - - if (cb) cb(i); - }); - }; - - if (file) { - fileSelected(file); - } else { - const browser = (riot as any).mount(document.body.appendChild(document.createElement('mk-select-file-from-drive-window')), { - multiple: false, - title: '%fa:image%アバターにする画像を選択' - })[0]; - - browser.one('selected', file => { - fileSelected(file); - }); - } -}; diff --git a/src/web/app/desktop/scripts/update-banner.ts b/src/web/app/desktop/scripts/update-banner.ts deleted file mode 100644 index 325775622d..0000000000 --- a/src/web/app/desktop/scripts/update-banner.ts +++ /dev/null @@ -1,88 +0,0 @@ -declare const _API_URL_: string; - -import * as riot from 'riot'; -import dialog from './dialog'; -import api from '../../common/scripts/api'; - -export default (I, cb, file = null) => { - const fileSelected = file => { - const cropper = (riot as any).mount(document.body.appendChild(document.createElement('mk-crop-window')), { - file: file, - title: 'バナーとして表示する部分を選択', - aspectRatio: 16 / 9 - })[0]; - - cropper.on('cropped', blob => { - const data = new FormData(); - data.append('i', I.token); - data.append('file', blob, file.name + '.cropped.png'); - - api(I, 'drive/folders/find', { - name: 'バナー' - }).then(iconFolder => { - if (iconFolder.length === 0) { - api(I, 'drive/folders/create', { - name: 'バナー' - }).then(iconFolder => { - upload(data, iconFolder); - }); - } else { - upload(data, iconFolder[0]); - } - }); - }); - - cropper.on('skipped', () => { - set(file); - }); - }; - - const upload = (data, folder) => { - const progress = (riot as any).mount(document.body.appendChild(document.createElement('mk-progress-dialog')), { - title: '新しいバナーをアップロードしています' - })[0]; - - if (folder) data.append('folder_id', folder.id); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', _API_URL_ + '/drive/files/create', true); - xhr.onload = e => { - const file = JSON.parse((e.target as any).response); - progress.close(); - set(file); - }; - - xhr.upload.onprogress = e => { - if (e.lengthComputable) progress.updateProgress(e.loaded, e.total); - }; - - xhr.send(data); - }; - - const set = file => { - api(I, 'i/update', { - banner_id: file.id - }).then(i => { - dialog('%fa:info-circle%バナーを更新しました', - '新しいバナーが反映されるまで時間がかかる場合があります。', - [{ - text: 'わかりました。' - }]); - - if (cb) cb(i); - }); - }; - - if (file) { - fileSelected(file); - } else { - const browser = (riot as any).mount(document.body.appendChild(document.createElement('mk-select-file-from-drive-window')), { - multiple: false, - title: '%fa:image%バナーにする画像を選択' - })[0]; - - browser.one('selected', file => { - fileSelected(file); - }); - } -}; diff --git a/src/web/app/desktop/style.styl b/src/web/app/desktop/style.styl index c893e2ed67..4d295035f7 100644 --- a/src/web/app/desktop/style.styl +++ b/src/web/app/desktop/style.styl @@ -42,10 +42,10 @@ background rgba(0, 0, 0, 0.2) html + height 100% background #f7f7f7 - // ↓ workaround of https://github.com/riot/riot/issues/2134 - &[data-page='entrance'] - #wait - right auto - left 15px +body + display flex + flex-direction column + min-height 100% diff --git a/src/web/app/desktop/tags/autocomplete-suggestion.tag b/src/web/app/desktop/tags/autocomplete-suggestion.tag deleted file mode 100644 index 7311606694..0000000000 --- a/src/web/app/desktop/tags/autocomplete-suggestion.tag +++ /dev/null @@ -1,197 +0,0 @@ -<mk-autocomplete-suggestion> - <ol class="users" ref="users" if={ users.length > 0 }> - <li each={ users } onclick={ parent.onClick } onkeydown={ parent.onKeydown } tabindex="-1"> - <img class="avatar" src={ avatar_url + '?thumbnail&size=32' } alt=""/> - <span class="name">{ name }</span> - <span class="username">@{ username }</span> - </li> - </ol> - <style> - :scope - display block - position absolute - z-index 65535 - margin-top calc(1em + 8px) - overflow hidden - background #fff - border solid 1px rgba(0, 0, 0, 0.1) - border-radius 4px - - > .users - 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'] - 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 28px - min-height 28px - max-width 28px - max-height 28px - margin 0 8px 0 0 - border-radius 100% - - .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) - - </style> - <script> - import contains from '../../common/scripts/contains'; - - this.mixin('api'); - - this.q = this.opts.q; - this.textarea = this.opts.textarea; - this.fetching = true; - this.users = []; - this.select = -1; - - this.on('mount', () => { - this.textarea.addEventListener('keydown', this.onKeydown); - - document.querySelectorAll('body *').forEach(el => { - el.addEventListener('mousedown', this.mousedown); - }); - - this.api('users/search_by_username', { - query: this.q, - limit: 30 - }).then(users => { - this.update({ - fetching: false, - users: users - }); - }); - }); - - this.on('unmount', () => { - this.textarea.removeEventListener('keydown', this.onKeydown); - - document.querySelectorAll('body *').forEach(el => { - el.removeEventListener('mousedown', this.mousedown); - }); - }); - - this.mousedown = e => { - if (!contains(this.root, e.target) && (this.root != e.target)) this.close(); - }; - - this.onClick = e => { - this.complete(e.item); - }; - - this.onKeydown = e => { - const cancel = () => { - e.preventDefault(); - e.stopPropagation(); - }; - - switch (e.which) { - case 10: // [ENTER] - case 13: // [ENTER] - if (this.select !== -1) { - cancel(); - this.complete(this.users[this.select]); - } 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: - this.close(); - } - }; - - this.selectNext = () => { - if (++this.select >= this.users.length) this.select = 0; - this.applySelect(); - }; - - this.selectPrev = () => { - if (--this.select < 0) this.select = this.users.length - 1; - this.applySelect(); - }; - - this.applySelect = () => { - Array.from(this.refs.users.children).forEach(el => { - el.removeAttribute('data-selected'); - }); - - this.refs.users.children[this.select].setAttribute('data-selected', 'true'); - this.refs.users.children[this.select].focus(); - }; - - this.complete = user => { - this.opts.complete(user); - }; - - this.close = () => { - this.opts.close(); - }; - - </script> -</mk-autocomplete-suggestion> diff --git a/src/web/app/desktop/tags/big-follow-button.tag b/src/web/app/desktop/tags/big-follow-button.tag deleted file mode 100644 index 7634043b20..0000000000 --- a/src/web/app/desktop/tags/big-follow-button.tag +++ /dev/null @@ -1,153 +0,0 @@ -<mk-big-follow-button> - <button class={ wait: wait, follow: !user.is_following, unfollow: user.is_following } if={ !init } onclick={ onclick } disabled={ wait } title={ user.is_following ? 'フォロー解除' : 'フォローする' }> - <span if={ !wait && user.is_following }>%fa:minus%フォロー解除</span> - <span if={ !wait && !user.is_following }>%fa:plus%フォロー</span> - <virtual if={ wait }>%fa:spinner .pulse .fw%</virtual> - </button> - <div class="init" if={ init }>%fa:spinner .pulse .fw%</div> - <style> - :scope - display block - - > button - > .init - display block - cursor pointer - padding 0 - margin 0 - width 100% - line-height 38px - font-size 1em - outline none - border-radius 4px - - * - pointer-events none - - i - margin-right 8px - - &: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 - - </style> - <script> - import isPromise from '../../common/scripts/is-promise'; - - this.mixin('i'); - this.mixin('api'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.user = null; - this.userPromise = isPromise(this.opts.user) - ? this.opts.user - : Promise.resolve(this.opts.user); - this.init = true; - this.wait = false; - - this.on('mount', () => { - this.userPromise.then(user => { - this.update({ - init: false, - user: user - }); - this.connection.on('follow', this.onStreamFollow); - this.connection.on('unfollow', this.onStreamUnfollow); - }); - }); - - this.on('unmount', () => { - this.connection.off('follow', this.onStreamFollow); - this.connection.off('unfollow', this.onStreamUnfollow); - this.stream.dispose(this.connectionId); - }); - - this.onStreamFollow = user => { - if (user.id == this.user.id) { - this.update({ - user: user - }); - } - }; - - this.onStreamUnfollow = user => { - if (user.id == this.user.id) { - this.update({ - user: user - }); - } - }; - - this.onclick = () => { - this.wait = true; - if (this.user.is_following) { - this.api('following/delete', { - user_id: this.user.id - }).then(() => { - this.user.is_following = false; - }).catch(err => { - console.error(err); - }).then(() => { - this.wait = false; - this.update(); - }); - } else { - this.api('following/create', { - user_id: this.user.id - }).then(() => { - this.user.is_following = true; - }).catch(err => { - console.error(err); - }).then(() => { - this.wait = false; - this.update(); - }); - } - }; - </script> -</mk-big-follow-button> diff --git a/src/web/app/desktop/tags/contextmenu.tag b/src/web/app/desktop/tags/contextmenu.tag deleted file mode 100644 index 2a3b2a7726..0000000000 --- a/src/web/app/desktop/tags/contextmenu.tag +++ /dev/null @@ -1,138 +0,0 @@ -<mk-contextmenu> - <yield /> - <style> - :scope - $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 - - ul - display block - margin 0 - padding $padding 0 - list-style none - - li - display block - - &.separator - margin-top $padding - padding-top $padding - border-top solid 1px #eee - - &.has-child - > p - cursor default - - > [data-fa]:last-child - position absolute - top 0 - right 8px - line-height $item-height - - &: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 - - > i - width 28px - margin-left -28px - text-align center - - &: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> - <script> - import anime from 'animejs'; - import contains from '../../common/scripts/contains'; - - this.root.addEventListener('contextmenu', e => { - e.preventDefault(); - }); - - this.mousedown = e => { - e.preventDefault(); - if (!contains(this.root, e.target) && (this.root != e.target)) this.close(); - return false; - }; - - this.open = pos => { - document.querySelectorAll('body *').forEach(el => { - el.addEventListener('mousedown', this.mousedown); - }); - - this.root.style.display = 'block'; - this.root.style.left = pos.x + 'px'; - this.root.style.top = pos.y + 'px'; - - anime({ - targets: this.root, - opacity: [0, 1], - duration: 100, - easing: 'linear' - }); - }; - - this.close = () => { - document.querySelectorAll('body *').forEach(el => { - el.removeEventListener('mousedown', this.mousedown); - }); - - this.trigger('closed'); - this.unmount(); - }; - </script> -</mk-contextmenu> diff --git a/src/web/app/desktop/tags/crop-window.tag b/src/web/app/desktop/tags/crop-window.tag deleted file mode 100644 index 4845b669d2..0000000000 --- a/src/web/app/desktop/tags/crop-window.tag +++ /dev/null @@ -1,196 +0,0 @@ -<mk-crop-window> - <mk-window ref="window" is-modal={ true } width={ '800px' }> - <yield to="header">%fa:crop%{ parent.title }</yield> - <yield to="content"> - <div class="body"><img ref="img" src={ parent.image.url + '?thumbnail&quality=80' } alt=""/></div> - <div class="action"> - <button class="skip" onclick={ parent.skip }>クロップをスキップ</button> - <button class="cancel" onclick={ parent.cancel }>キャンセル</button> - <button class="ok" onclick={ parent.ok }>決定</button> - </div> - </yield> - </mk-window> - <style> - :scope - display block - - > mk-window - [data-yield='header'] - > [data-fa] - margin-right 4px - - [data-yield='content'] - - > .body - > img - width 100% - max-height 400px - - .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; - } - - @-webkit-keyframes cropper-bg { - 0% { - background-position: 0 0; - } - - 100% { - background-position: -8px -8px; - } - } - - @-moz-keyframes cropper-bg { - 0% { - background-position: 0 0; - } - - 100% { - background-position: -8px -8px; - } - } - - @-ms-keyframes cropper-bg { - 0% { - background-position: 0 0; - } - - 100% { - background-position: -8px -8px; - } - } - - @keyframes cropper-bg { - 0% { - background-position: 0 0; - } - - 100% { - background-position: -8px -8px; - } - } - - > .action - 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> - <script> - const Cropper = require('cropperjs'); - - this.image = this.opts.file; - this.title = this.opts.title; - this.aspectRatio = this.opts.aspectRatio; - this.cropper = null; - - this.on('mount', () => { - this.img = this.refs.window.refs.img; - this.cropper = new Cropper(this.img, { - aspectRatio: this.aspectRatio, - highlight: false, - viewMode: 1 - }); - }); - - this.ok = () => { - this.cropper.getCroppedCanvas().toBlob(blob => { - this.trigger('cropped', blob); - this.refs.window.close(); - }); - }; - - this.skip = () => { - this.trigger('skipped'); - this.refs.window.close(); - }; - - this.cancel = () => { - this.trigger('canceled'); - this.refs.window.close(); - }; - </script> -</mk-crop-window> diff --git a/src/web/app/desktop/tags/detailed-post-window.tag b/src/web/app/desktop/tags/detailed-post-window.tag deleted file mode 100644 index 04f9acf974..0000000000 --- a/src/web/app/desktop/tags/detailed-post-window.tag +++ /dev/null @@ -1,80 +0,0 @@ -<mk-detailed-post-window> - <div class="bg" ref="bg" onclick={ bgClick }></div> - <div class="main" ref="main" if={ !fetching }> - <mk-post-detail ref="detail" post={ post }/> - </div> - <style> - :scope - display block - opacity 0 - - > .bg - display block - position fixed - z-index 1000 - top 0 - left 0 - width 100% - height 100% - background rgba(0, 0, 0, 0.7) - - > .main - display block - position fixed - z-index 1000 - top 20% - left 0 - right 0 - margin 0 auto 0 auto - padding 0 - width 638px - text-align center - - > mk-post-detail - margin 0 auto - - </style> - <script> - import anime from 'animejs'; - - this.mixin('api'); - - this.fetching = true; - this.post = null; - - this.on('mount', () => { - anime({ - targets: this.root, - opacity: 1, - duration: 100, - easing: 'linear' - }); - - this.api('posts/show', { - post_id: this.opts.post - }).then(post => { - - this.update({ - fetching: false, - post: post - }); - }); - }); - - this.close = () => { - this.refs.bg.style.pointerEvents = 'none'; - this.refs.main.style.pointerEvents = 'none'; - anime({ - targets: this.root, - opacity: 0, - duration: 300, - easing: 'linear', - complete: () => this.unmount() - }); - }; - - this.bgClick = () => { - this.close(); - }; - </script> -</mk-detailed-post-window> diff --git a/src/web/app/desktop/tags/dialog.tag b/src/web/app/desktop/tags/dialog.tag deleted file mode 100644 index 743fd63942..0000000000 --- a/src/web/app/desktop/tags/dialog.tag +++ /dev/null @@ -1,144 +0,0 @@ -<mk-dialog> - <div class="bg" ref="bg" onclick={ bgClick }></div> - <div class="main" ref="main"> - <header ref="header"></header> - <div class="body" ref="body"></div> - <div class="buttons"> - <virtual each={ opts.buttons }> - <button onclick={ _onclick }>{ text }</button> - </virtual> - </div> - </div> - <style> - :scope - display block - - > .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 - - > header - margin 1em 0 - color $theme-color - // color #43A4EC - font-weight bold - - &:empty - display none - - > i - margin-right 0.5em - - > .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> - <script> - import anime from 'animejs'; - - this.canThrough = opts.canThrough != null ? opts.canThrough : true; - this.opts.buttons.forEach(button => { - button._onclick = () => { - if (button.onclick) button.onclick(); - this.close(); - }; - }); - - this.on('mount', () => { - this.refs.header.innerHTML = this.opts.title; - this.refs.body.innerHTML = this.opts.text; - - this.refs.bg.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 ] - }); - }); - - this.close = () => { - this.refs.bg.style.pointerEvents = 'none'; - anime({ - targets: this.refs.bg, - opacity: 0, - duration: 300, - easing: 'linear' - }); - - this.refs.main.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.unmount() - }); - }; - - this.bgClick = () => { - if (this.canThrough) { - if (this.opts.onThrough) this.opts.onThrough(); - this.close(); - } - }; - </script> -</mk-dialog> diff --git a/src/web/app/desktop/tags/donation.tag b/src/web/app/desktop/tags/donation.tag deleted file mode 100644 index 1c19fac1f5..0000000000 --- a/src/web/app/desktop/tags/donation.tag +++ /dev/null @@ -1,66 +0,0 @@ -<mk-donation> - <button class="close" onclick={ close }>閉じる x</button> - <div class="message"> - <p>利用者の皆さま、</p> - <p> - 今日は、日本の皆さまにお知らせがあります。 - Misskeyの援助をお願いいたします。 - 私は独立性を守るため、一切の広告を掲載いたしません。 - 平均で約¥1,500の寄付をいただき、運営しております。 - 援助をしてくださる利用者はほんの少数です。 - お願いいたします。 - 今日、利用者の皆さまが¥300ご援助くだされば、募金活動を一時間で終了することができます。 - コーヒー1杯ほどの金額です。 - Misskeyを活用しておられるのでしたら、広告を掲載せずにもう1年活動できるよう、どうか1分だけお時間をください。 - 私は小さな非営利個人ですが、サーバー、プログラム、人件費など、世界でトップクラスのウェブサイト同等のコストがかかります。 - 利用者は何億人といますが、他の大きなサイトに比べてほんの少額の費用で運営しているのです。 - 人間の可能性、自由、そして機会。知識こそ、これらの基盤を成すものです。 - 私は、誰もが無料かつ制限なく知識に触れられるべきだと信じています。 - 募金活動を終了し、Misskeyの改善に戻れるようご援助ください。 - よろしくお願いいたします。 - </p> - </div> - <style> - :scope - display block - color #fff - background #03072C - - > .close - position absolute - top 16px - right 16px - z-index 1 - - > .message - padding 32px - font-size 1.4em - font-family serif - - > p - display block - margin 0 auto - max-width 1200px - - > p:first-child - margin-bottom 16px - - </style> - <script> - this.mixin('i'); - this.mixin('api'); - - this.close = e => { - e.preventDefault(); - e.stopPropagation(); - - this.I.client_settings.show_donation = false; - this.I.update(); - this.api('i/update', { - show_donation: false - }); - - this.unmount(); - }; - </script> -</mk-donation> diff --git a/src/web/app/desktop/tags/drive/base-contextmenu.tag b/src/web/app/desktop/tags/drive/base-contextmenu.tag deleted file mode 100644 index b16dbf55d6..0000000000 --- a/src/web/app/desktop/tags/drive/base-contextmenu.tag +++ /dev/null @@ -1,44 +0,0 @@ -<mk-drive-browser-base-contextmenu> - <mk-contextmenu ref="ctx"> - <ul> - <li onclick={ parent.createFolder }> - <p>%fa:R folder%%i18n:desktop.tags.mk-drive-browser-base-contextmenu.create-folder%</p> - </li> - <li onclick={ parent.upload }> - <p>%fa:upload%%i18n:desktop.tags.mk-drive-browser-base-contextmenu.upload%</p> - </li> - <li onclick={ parent.urlUpload }> - <p>%fa:cloud-upload-alt%%i18n:desktop.tags.mk-drive-browser-base-contextmenu.url-upload%</p> - </li> - </ul> - </mk-contextmenu> - <script> - this.browser = this.opts.browser; - - this.on('mount', () => { - this.refs.ctx.on('closed', () => { - this.trigger('closed'); - this.unmount(); - }); - }); - - this.open = pos => { - this.refs.ctx.open(pos); - }; - - this.createFolder = () => { - this.browser.createFolder(); - this.refs.ctx.close(); - }; - - this.upload = () => { - this.browser.selectLocalFile(); - this.refs.ctx.close(); - }; - - this.urlUpload = () => { - this.browser.urlUpload(); - this.refs.ctx.close(); - }; - </script> -</mk-drive-browser-base-contextmenu> diff --git a/src/web/app/desktop/tags/drive/browser-window.tag b/src/web/app/desktop/tags/drive/browser-window.tag deleted file mode 100644 index 57042f0163..0000000000 --- a/src/web/app/desktop/tags/drive/browser-window.tag +++ /dev/null @@ -1,60 +0,0 @@ -<mk-drive-browser-window> - <mk-window ref="window" is-modal={ false } width={ '800px' } height={ '500px' } popout={ popout }> - <yield to="header"> - <p class="info" if={ parent.usage }><b>{ parent.usage.toFixed(1) }%</b> %i18n:desktop.tags.mk-drive-browser-window.used%</p> - %fa:cloud%%i18n:desktop.tags.mk-drive-browser-window.drive% - </yield> - <yield to="content"> - <mk-drive-browser multiple={ true } folder={ parent.folder } ref="browser"/> - </yield> - </mk-window> - <style> - :scope - > mk-window - [data-yield='header'] - > .info - position absolute - top 0 - left 16px - margin 0 - font-size 80% - - > [data-fa] - margin-right 4px - - [data-yield='content'] - > mk-drive-browser - height 100% - - </style> - <script> - this.mixin('api'); - - this.folder = this.opts.folder ? this.opts.folder : null; - - this.popout = () => { - const folder = this.refs.window.refs.browser.folder; - if (folder) { - return `${_URL_}/i/drive/folder/${folder.id}`; - } else { - return `${_URL_}/i/drive`; - } - }; - - this.on('mount', () => { - this.refs.window.on('closed', () => { - this.unmount(); - }); - - this.api('drive').then(info => { - this.update({ - usage: info.usage / info.capacity * 100 - }); - }); - }); - - this.close = () => { - this.refs.window.close(); - }; - </script> -</mk-drive-browser-window> diff --git a/src/web/app/desktop/tags/drive/browser.tag b/src/web/app/desktop/tags/drive/browser.tag deleted file mode 100644 index a60a46b790..0000000000 --- a/src/web/app/desktop/tags/drive/browser.tag +++ /dev/null @@ -1,736 +0,0 @@ -<mk-drive-browser> - <nav> - <div class="path" oncontextmenu={ pathOncontextmenu }> - <mk-drive-browser-nav-folder class={ current: folder == null } folder={ null }/> - <virtual each={ folder in hierarchyFolders }> - <span class="separator">%fa:angle-right%</span> - <mk-drive-browser-nav-folder folder={ folder }/> - </virtual> - <span class="separator" if={ folder != null }>%fa:angle-right%</span> - <span class="folder current" if={ folder != null }>{ folder.name }</span> - </div> - <input class="search" type="search" placeholder=" %i18n:desktop.tags.mk-drive-browser.search%"/> - </nav> - <div class="main { uploading: uploads.length > 0, fetching: fetching }" ref="main" onmousedown={ onmousedown } ondragover={ ondragover } ondragenter={ ondragenter } ondragleave={ ondragleave } ondrop={ ondrop } oncontextmenu={ oncontextmenu }> - <div class="selection" ref="selection"></div> - <div class="contents" ref="contents"> - <div class="folders" ref="foldersContainer" if={ folders.length > 0 }> - <virtual each={ folder in folders }> - <mk-drive-browser-folder class="folder" folder={ folder }/> - </virtual> - <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> - <div class="padding" each={ Array(10).fill(16) }></div> - <button if={ moreFolders }>%i18n:desktop.tags.mk-drive-browser.load-more%</button> - </div> - <div class="files" ref="filesContainer" if={ files.length > 0 }> - <virtual each={ file in files }> - <mk-drive-browser-file class="file" file={ file }/> - </virtual> - <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> - <div class="padding" each={ Array(10).fill(16) }></div> - <button if={ moreFiles } onclick={ fetchMoreFiles }>%i18n:desktop.tags.mk-drive-browser.load-more%</button> - </div> - <div class="empty" if={ files.length == 0 && folders.length == 0 && !fetching }> - <p if={ draghover }>%i18n:desktop.tags.mk-drive-browser.empty-draghover%</p> - <p 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 if={ !draghover && folder != null }>%i18n:desktop.tags.mk-drive-browser.empty-folder%</p> - </div> - </div> - <div class="fetching" if={ fetching }> - <div class="spinner"> - <div class="dot1"></div> - <div class="dot2"></div> - </div> - </div> - </div> - <div class="dropzone" if={ draghover }></div> - <mk-uploader ref="uploader"/> - <input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" onchange={ changeFileInput }/> - <style> - :scope - display block - - > 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> - <script> - import contains from '../../../common/scripts/contains'; - import dialog from '../../scripts/dialog'; - import inputDialog from '../../scripts/input-dialog'; - - this.mixin('i'); - this.mixin('api'); - - this.mixin('drive-stream'); - this.connection = this.driveStream.getConnection(); - this.connectionId = this.driveStream.use(); - - this.files = []; - this.folders = []; - this.hierarchyFolders = []; - this.selectedFiles = []; - - this.uploads = []; - - // 現在の階層(フォルダ) - // * null でルートを表す - this.folder = null; - - this.multiple = this.opts.multiple != null ? this.opts.multiple : false; - - // ドロップされようとしているか - this.draghover = false; - - // 自信の所有するアイテムがドラッグをスタートさせたか - // (自分自身の階層にドロップできないようにするためのフラグ) - this.isDragSource = false; - - this.on('mount', () => { - this.refs.uploader.on('uploaded', file => { - this.addFile(file, true); - }); - - this.refs.uploader.on('change-uploads', uploads => { - this.update({ - uploads: uploads - }); - }); - - 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.opts.folder) { - this.move(this.opts.folder); - } else { - this.fetch(); - } - }); - - this.on('unmount', () => { - 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.driveStream.dispose(this.connectionId); - }); - - this.onStreamDriveFileCreated = file => { - this.addFile(file, true); - }; - - this.onStreamDriveFileUpdated = file => { - const current = this.folder ? this.folder.id : null; - if (current != file.folder_id) { - this.removeFile(file); - } else { - this.addFile(file, true); - } - }; - - this.onStreamDriveFolderCreated = folder => { - this.addFolder(folder, true); - }; - - this.onStreamDriveFolderUpdated = folder => { - const current = this.folder ? this.folder.id : null; - if (current != folder.parent_id) { - this.removeFolder(folder); - } else { - this.addFolder(folder, true); - } - }; - - this.onmousedown = e => { - if (contains(this.refs.foldersContainer, e.target) || contains(this.refs.filesContainer, e.target)) return true; - - const rect = this.refs.main.getBoundingClientRect(); - - const left = e.pageX + this.refs.main.scrollLeft - rect.left - window.pageXOffset - const top = e.pageY + this.refs.main.scrollTop - rect.top - window.pageYOffset - - const move = e => { - this.refs.selection.style.display = 'block'; - - const cursorX = e.pageX + this.refs.main.scrollLeft - rect.left - window.pageXOffset; - const cursorY = e.pageY + this.refs.main.scrollTop - rect.top - window.pageYOffset; - const w = cursorX - left; - const h = cursorY - top; - - if (w > 0) { - this.refs.selection.style.width = w + 'px'; - this.refs.selection.style.left = left + 'px'; - } else { - this.refs.selection.style.width = -w + 'px'; - this.refs.selection.style.left = cursorX + 'px'; - } - - if (h > 0) { - this.refs.selection.style.height = h + 'px'; - this.refs.selection.style.top = top + 'px'; - } else { - this.refs.selection.style.height = -h + 'px'; - this.refs.selection.style.top = cursorY + 'px'; - } - }; - - const up = e => { - document.documentElement.removeEventListener('mousemove', move); - document.documentElement.removeEventListener('mouseup', up); - - this.refs.selection.style.display = 'none'; - }; - - document.documentElement.addEventListener('mousemove', move); - document.documentElement.addEventListener('mouseup', up); - }; - - this.pathOncontextmenu = e => { - e.preventDefault(); - e.stopImmediatePropagation(); - return false; - }; - - this.ondragover = e => { - e.preventDefault(); - e.stopPropagation(); - - // ドラッグ元が自分自身の所有するアイテムかどうか - if (!this.isDragSource) { - // ドラッグされてきたものがファイルだったら - e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; - this.draghover = true; - } else { - // 自分自身にはドロップさせない - e.dataTransfer.dropEffect = 'none'; - return false; - } - }; - - this.ondragenter = e => { - e.preventDefault(); - if (!this.isDragSource) this.draghover = true; - }; - - this.ondragleave = e => { - this.draghover = false; - }; - - this.ondrop = e => { - e.preventDefault(); - e.stopPropagation(); - - this.draghover = false; - - // ドロップされてきたものがファイルだったら - if (e.dataTransfer.files.length > 0) { - Array.from(e.dataTransfer.files).forEach(file => { - this.upload(file, this.folder); - }); - return false; - } - - // データ取得 - const data = e.dataTransfer.getData('text'); - if (data == null) return false; - - // パース - // TODO: JSONじゃなかったら中断 - const obj = JSON.parse(data); - - // (ドライブの)ファイルだったら - if (obj.type == 'file') { - const file = obj.id; - if (this.files.some(f => f.id == file)) return false; - this.removeFile(file); - this.api('drive/files/update', { - file_id: file, - folder_id: this.folder ? this.folder.id : null - }); - // (ドライブの)フォルダーだったら - } else if (obj.type == 'folder') { - const folder = obj.id; - // 移動先が自分自身ならreject - if (this.folder && folder == this.folder.id) return false; - if (this.folders.some(f => f.id == folder)) return false; - this.removeFolder(folder); - this.api('drive/folders/update', { - folder_id: folder, - parent_id: this.folder ? this.folder.id : null - }).then(() => { - // something - }).catch(err => { - switch (err) { - case 'detected-circular-definition': - dialog('%fa:exclamation-triangle%%i18n:desktop.tags.mk-drive-browser.unable-to-process%', - '%i18n:desktop.tags.mk-drive-browser.circular-reference-detected%', [{ - text: '%i18n:common.ok%' - }]); - break; - default: - alert('%i18n:desktop.tags.mk-drive-browser.unhandled-error% ' + err); - } - }); - } - - return false; - }; - - this.oncontextmenu = e => { - e.preventDefault(); - e.stopImmediatePropagation(); - - const ctx = riot.mount(document.body.appendChild(document.createElement('mk-drive-browser-base-contextmenu')), { - browser: this - })[0]; - ctx.open({ - x: e.pageX - window.pageXOffset, - y: e.pageY - window.pageYOffset - }); - - return false; - }; - - this.selectLocalFile = () => { - this.refs.fileInput.click(); - }; - - this.urlUpload = () => { - inputDialog('%i18n:desktop.tags.mk-drive-browser.url-upload%', - '%i18n:desktop.tags.mk-drive-browser.url-of-file%', null, url => { - - this.api('drive/files/upload_from_url', { - url: url, - folder_id: this.folder ? this.folder.id : undefined - }); - - dialog('%fa:check%%i18n:desktop.tags.mk-drive-browser.url-upload-requested%', - '%i18n:desktop.tags.mk-drive-browser.may-take-time%', [{ - text: '%i18n:common.ok%' - }]); - }); - }; - - this.createFolder = () => { - inputDialog('%i18n:desktop.tags.mk-drive-browser.create-folder%', - '%i18n:desktop.tags.mk-drive-browser.folder-name%', null, name => { - - this.api('drive/folders/create', { - name: name, - folder_id: this.folder ? this.folder.id : undefined - }).then(folder => { - this.addFolder(folder, true); - this.update(); - }); - }); - }; - - this.changeFileInput = () => { - Array.from(this.refs.fileInput.files).forEach(file => { - this.upload(file, this.folder); - }); - }; - - this.upload = (file, folder) => { - if (folder && typeof folder == 'object') folder = folder.id; - this.refs.uploader.upload(file, folder); - }; - - this.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.update(); - this.trigger('change-selection', this.selectedFiles); - } else { - if (isAlreadySelected) { - this.trigger('selected', file); - } else { - this.selectedFiles = [file]; - this.trigger('change-selection', [file]); - } - } - }; - - this.newWindow = folderId => { - riot.mount(document.body.appendChild(document.createElement('mk-drive-browser-window')), { - folder: folderId - }); - }; - - this.move = target => { - if (target == null) { - this.goRoot(); - return; - } else if (typeof target == 'object') { - target = target.id; - } - - this.update({ - fetching: true - }); - - this.api('drive/folders/show', { - folder_id: 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.update(); - this.trigger('open-folder', folder); - this.fetch(); - }); - }; - - this.addFolder = (folder, unshift = false) => { - const current = this.folder ? this.folder.id : null; - if (current != folder.parent_id) return; - - if (this.folders.some(f => f.id == folder.id)) { - const exist = this.folders.map(f => f.id).indexOf(folder.id); - this.folders[exist] = folder; - this.update(); - return; - } - - if (unshift) { - this.folders.unshift(folder); - } else { - this.folders.push(folder); - } - - this.update(); - }; - - this.addFile = (file, unshift = false) => { - const current = this.folder ? this.folder.id : null; - if (current != file.folder_id) return; - - if (this.files.some(f => f.id == file.id)) { - const exist = this.files.map(f => f.id).indexOf(file.id); - this.files[exist] = file; - this.update(); - return; - } - - if (unshift) { - this.files.unshift(file); - } else { - this.files.push(file); - } - - this.update(); - }; - - this.removeFolder = folder => { - if (typeof folder == 'object') folder = folder.id; - this.folders = this.folders.filter(f => f.id != folder); - this.update(); - }; - - this.removeFile = file => { - if (typeof file == 'object') file = file.id; - this.files = this.files.filter(f => f.id != file); - this.update(); - }; - - this.appendFile = file => this.addFile(file); - this.appendFolder = file => this.addFolder(file); - this.prependFile = file => this.addFile(file, true); - this.prependFolder = file => this.addFolder(file, true); - - this.goRoot = () => { - // 既にrootにいるなら何もしない - if (this.folder == null) return; - - this.update({ - folder: null, - hierarchyFolders: [] - }); - this.trigger('move-root'); - this.fetch(); - }; - - this.fetch = () => { - this.update({ - folders: [], - files: [], - moreFolders: false, - moreFiles: false, - fetching: true - }); - - let fetchedFolders = null; - let fetchedFiles = null; - - const foldersMax = 30; - const filesMax = 30; - - // フォルダ一覧取得 - this.api('drive/folders', { - folder_id: this.folder ? this.folder.id : null, - limit: foldersMax + 1 - }).then(folders => { - if (folders.length == foldersMax + 1) { - this.moreFolders = true; - folders.pop(); - } - fetchedFolders = folders; - complete(); - }); - - // ファイル一覧取得 - this.api('drive/files', { - folder_id: this.folder ? this.folder.id : null, - limit: filesMax + 1 - }).then(files => { - if (files.length == filesMax + 1) { - this.moreFiles = true; - files.pop(); - } - fetchedFiles = files; - complete(); - }); - - let flag = false; - const complete = () => { - if (flag) { - fetchedFolders.forEach(this.appendFolder); - fetchedFiles.forEach(this.appendFile); - this.update({ - fetching: false - }); - } else { - flag = true; - } - }; - }; - - this.fetchMoreFiles = () => { - this.update({ - fetching: true - }); - - const max = 30; - - // ファイル一覧取得 - this.api('drive/files', { - folder_id: 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.update({ - fetching: false - }); - }); - }; - - </script> -</mk-drive-browser> diff --git a/src/web/app/desktop/tags/drive/file-contextmenu.tag b/src/web/app/desktop/tags/drive/file-contextmenu.tag deleted file mode 100644 index 532417c757..0000000000 --- a/src/web/app/desktop/tags/drive/file-contextmenu.tag +++ /dev/null @@ -1,99 +0,0 @@ -<mk-drive-browser-file-contextmenu> - <mk-contextmenu ref="ctx"> - <ul> - <li onclick={ parent.rename }> - <p>%fa:i-cursor%%i18n:desktop.tags.mk-drive-browser-file-contextmenu.rename%</p> - </li> - <li onclick={ parent.copyUrl }> - <p>%fa:link%%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copy-url%</p> - </li> - <li><a href={ parent.file.url + '?download' } download={ parent.file.name } onclick={ parent.download }>%fa:download%%i18n:desktop.tags.mk-drive-browser-file-contextmenu.download%</a></li> - <li class="separator"></li> - <li onclick={ parent.delete }> - <p>%fa:R trash-alt%%i18n:common.delete%</p> - </li> - <li class="separator"></li> - <li class="has-child"> - <p>%i18n:desktop.tags.mk-drive-browser-file-contextmenu.else-files%%fa:caret-right%</p> - <ul> - <li onclick={ parent.setAvatar }> - <p>%i18n:desktop.tags.mk-drive-browser-file-contextmenu.set-as-avatar%</p> - </li> - <li onclick={ parent.setBanner }> - <p>%i18n:desktop.tags.mk-drive-browser-file-contextmenu.set-as-banner%</p> - </li> - </ul> - </li> - <li class="has-child"> - <p>%i18n:desktop.tags.mk-drive-browser-file-contextmenu.open-in-app%...%fa:caret-right%</p> - <ul> - <li onclick={ parent.addApp }> - <p>%i18n:desktop.tags.mk-drive-browser-file-contextmenu.add-app%...</p> - </li> - </ul> - </li> - </ul> - </mk-contextmenu> - <script> - import copyToClipboard from '../../../common/scripts/copy-to-clipboard'; - import dialog from '../../scripts/dialog'; - import inputDialog from '../../scripts/input-dialog'; - import updateAvatar from '../../scripts/update-avatar'; - import NotImplementedException from '../../scripts/not-implemented-exception'; - - this.mixin('i'); - this.mixin('api'); - - this.browser = this.opts.browser; - this.file = this.opts.file; - - this.on('mount', () => { - this.refs.ctx.on('closed', () => { - this.trigger('closed'); - this.unmount(); - }); - }); - - this.open = pos => { - this.refs.ctx.open(pos); - }; - - this.rename = () => { - this.refs.ctx.close(); - - inputDialog('%i18n:desktop.tags.mk-drive-browser-file-contextmenu.rename-file%', '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.input-new-file-name%', this.file.name, name => { - this.api('drive/files/update', { - file_id: this.file.id, - name: name - }) - }); - }; - - this.copyUrl = () => { - copyToClipboard(this.file.url); - this.refs.ctx.close(); - dialog('%fa:check%%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copied%', - '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copied-url-to-clipboard%', [{ - text: '%i18n:common.ok%' - }]); - }; - - this.download = () => { - this.refs.ctx.close(); - }; - - this.setAvatar = () => { - this.refs.ctx.close(); - updateAvatar(this.I, null, this.file); - }; - - this.setBanner = () => { - this.refs.ctx.close(); - updateBanner(this.I, null, this.file); - }; - - this.addApp = () => { - NotImplementedException(); - }; - </script> -</mk-drive-browser-file-contextmenu> diff --git a/src/web/app/desktop/tags/drive/file.tag b/src/web/app/desktop/tags/drive/file.tag deleted file mode 100644 index 8b3d36b3f3..0000000000 --- a/src/web/app/desktop/tags/drive/file.tag +++ /dev/null @@ -1,217 +0,0 @@ -<mk-drive-browser-file data-is-selected={ isSelected } data-is-contextmenu-showing={ isContextmenuShowing.toString() } onclick={ onclick } oncontextmenu={ oncontextmenu } draggable="true" ondragstart={ ondragstart } ondragend={ ondragend } title={ title }> - <div class="label" if={ I.avatar_id == file.id }><img src="/assets/label.svg"/> - <p>%i18n:desktop.tags.mk-drive-browser-file.avatar%</p> - </div> - <div class="label" if={ I.banner_id == 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:{ file.properties.average_color ? 'rgb(' + file.properties.average_color.join(',') + ')' : 'transparent' }"> - <img src={ file.url + '?thumbnail&size=128' } alt="" onload={ onload }/> - </div> - <p class="name"><span>{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }</span><span class="ext" if={ file.name.lastIndexOf('.') != -1 }>{ file.name.substr(file.name.lastIndexOf('.')) }</span></p> - <style> - :scope - display block - 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='true'] - &: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> - <script> - import anime from 'animejs'; - import bytesToSize from '../../../common/scripts/bytes-to-size'; - - this.mixin('i'); - - this.file = this.opts.file; - this.browser = this.parent; - this.title = `${this.file.name}\n${this.file.type} ${bytesToSize(this.file.datasize)}`; - this.isContextmenuShowing = false; - this.isSelected = this.browser.selectedFiles.some(f => f.id == this.file.id); - - this.browser.on('change-selection', selections => { - this.isSelected = selections.some(f => f.id == this.file.id); - this.update(); - }); - - this.onclick = () => { - this.browser.chooseFile(this.file); - }; - - this.oncontextmenu = e => { - e.preventDefault(); - e.stopImmediatePropagation(); - - this.update({ - isContextmenuShowing: true - }); - const ctx = riot.mount(document.body.appendChild(document.createElement('mk-drive-browser-file-contextmenu')), { - browser: this.browser, - file: this.file - })[0]; - ctx.open({ - x: e.pageX - window.pageXOffset, - y: e.pageY - window.pageYOffset - }); - ctx.on('closed', () => { - this.update({ - isContextmenuShowing: false - }); - }); - return false; - }; - - this.ondragstart = e => { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text', JSON.stringify({ - type: 'file', - id: this.file.id, - file: this.file - })); - this.isDragging = true; - - // 親ブラウザに対して、ドラッグが開始されたフラグを立てる - // (=あなたの子供が、ドラッグを開始しましたよ) - this.browser.isDragSource = true; - }; - - this.ondragend = e => { - this.isDragging = false; - this.browser.isDragSource = false; - }; - - this.onload = () => { - if (this.file.properties.average_color) { - anime({ - targets: this.refs.thumbnail, - backgroundColor: `rgba(${this.file.properties.average_color.join(',')}, 0)`, - duration: 100, - easing: 'linear' - }); - } - }; - </script> -</mk-drive-browser-file> diff --git a/src/web/app/desktop/tags/drive/folder-contextmenu.tag b/src/web/app/desktop/tags/drive/folder-contextmenu.tag deleted file mode 100644 index c6a1ea3b84..0000000000 --- a/src/web/app/desktop/tags/drive/folder-contextmenu.tag +++ /dev/null @@ -1,63 +0,0 @@ -<mk-drive-browser-folder-contextmenu> - <mk-contextmenu ref="ctx"> - <ul> - <li onclick={ parent.move }> - <p>%fa:arrow-right%%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.move-to-this-folder%</p> - </li> - <li onclick={ parent.newWindow }> - <p>%fa:R window-restore%%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.show-in-new-window%</p> - </li> - <li class="separator"></li> - <li onclick={ parent.rename }> - <p>%fa:i-cursor%%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.rename%</p> - </li> - <li class="separator"></li> - <li onclick={ parent.delete }> - <p>%fa:R trash-alt%%i18n:common.delete%</p> - </li> - </ul> - </mk-contextmenu> - <script> - import inputDialog from '../../scripts/input-dialog'; - - this.mixin('api'); - - this.browser = this.opts.browser; - this.folder = this.opts.folder; - - this.open = pos => { - this.refs.ctx.open(pos); - - this.refs.ctx.on('closed', () => { - this.trigger('closed'); - this.unmount(); - }); - }; - - this.move = () => { - this.browser.move(this.folder.id); - this.refs.ctx.close(); - }; - - this.newWindow = () => { - this.browser.newWindow(this.folder.id); - this.refs.ctx.close(); - }; - - this.createFolder = () => { - this.browser.createFolder(); - this.refs.ctx.close(); - }; - - this.rename = () => { - this.refs.ctx.close(); - - inputDialog('%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.rename-folder%', '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.input-new-folder-name%', this.folder.name, name => { - this.api('drive/folders/update', { - folder_id: this.folder.id, - name: name - }); - }); - }; - </script> -</mk-drive-browser-folder-contextmenu> diff --git a/src/web/app/desktop/tags/drive/folder.tag b/src/web/app/desktop/tags/drive/folder.tag deleted file mode 100644 index 0b7ee6e2d1..0000000000 --- a/src/web/app/desktop/tags/drive/folder.tag +++ /dev/null @@ -1,202 +0,0 @@ -<mk-drive-browser-folder data-is-contextmenu-showing={ isContextmenuShowing.toString() } data-draghover={ draghover.toString() } onclick={ onclick } onmouseover={ onmouseover } onmouseout={ onmouseout } ondragover={ ondragover } ondragenter={ ondragenter } ondragleave={ ondragleave } ondrop={ ondrop } oncontextmenu={ oncontextmenu } draggable="true" ondragstart={ ondragstart } ondragend={ ondragend } title={ title }> - <p class="name"><virtual if={ hover }>%fa:R folder-open .fw%</virtual><virtual if={ !hover }>%fa:R folder .fw%</virtual>{ folder.name }</p> - <style> - :scope - display block - 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='true'] - &[data-draghover='true'] - &: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='true'] - 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> - <script> - import dialog from '../../scripts/dialog'; - - this.mixin('api'); - - this.folder = this.opts.folder; - this.browser = this.parent; - - this.title = this.folder.name; - this.hover = false; - this.draghover = false; - this.isContextmenuShowing = false; - - this.onclick = () => { - this.browser.move(this.folder); - }; - - this.onmouseover = () => { - this.hover = true; - }; - - this.onmouseout = () => { - this.hover = false - }; - - this.ondragover = e => { - e.preventDefault(); - e.stopPropagation(); - - // 自分自身がドラッグされていない場合 - if (!this.isDragging) { - // ドラッグされてきたものがファイルだったら - if (e.dataTransfer.effectAllowed === 'all') { - e.dataTransfer.dropEffect = 'copy'; - } else { - e.dataTransfer.dropEffect = 'move'; - } - } else { - // 自分自身にはドロップさせない - e.dataTransfer.dropEffect = 'none'; - } - return false; - }; - - this.ondragenter = e => { - e.preventDefault(); - if (!this.isDragging) this.draghover = true; - }; - - this.ondragleave = () => { - this.draghover = false; - }; - - this.ondrop = e => { - e.preventDefault(); - e.stopPropagation(); - this.draghover = false; - - // ファイルだったら - if (e.dataTransfer.files.length > 0) { - Array.from(e.dataTransfer.files).forEach(file => { - this.browser.upload(file, this.folder); - }); - return false; - }; - - // データ取得 - const data = e.dataTransfer.getData('text'); - if (data == null) return false; - - // パース - // TODO: Validate JSON - const obj = JSON.parse(data); - - // (ドライブの)ファイルだったら - if (obj.type == 'file') { - const file = obj.id; - this.browser.removeFile(file); - this.api('drive/files/update', { - file_id: file, - folder_id: this.folder.id - }); - // (ドライブの)フォルダーだったら - } else if (obj.type == 'folder') { - const folder = obj.id; - // 移動先が自分自身ならreject - if (folder == this.folder.id) return false; - this.browser.removeFolder(folder); - this.api('drive/folders/update', { - folder_id: folder, - parent_id: this.folder.id - }).then(() => { - // something - }).catch(err => { - switch (err) { - case 'detected-circular-definition': - dialog('%fa:exclamation-triangle%%i18n:desktop.tags.mk-drive-browser-folder.unable-to-process%', - '%i18n:desktop.tags.mk-drive-browser-folder.circular-reference-detected%', [{ - text: '%i18n:common.ok%' - }]); - break; - default: - alert('%i18n:desktop.tags.mk-drive-browser-folder.unhandled-error% ' + err); - } - }); - } - - return false; - }; - - this.ondragstart = e => { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text', JSON.stringify({ - type: 'folder', - id: this.folder.id - })); - this.isDragging = true; - - // 親ブラウザに対して、ドラッグが開始されたフラグを立てる - // (=あなたの子供が、ドラッグを開始しましたよ) - this.browser.isDragSource = true; - }; - - this.ondragend = e => { - this.isDragging = false; - this.browser.isDragSource = false; - }; - - this.oncontextmenu = e => { - e.preventDefault(); - e.stopImmediatePropagation(); - - this.update({ - isContextmenuShowing: true - }); - const ctx = riot.mount(document.body.appendChild(document.createElement('mk-drive-browser-folder-contextmenu')), { - browser: this.browser, - folder: this.folder - })[0]; - ctx.open({ - x: e.pageX - window.pageXOffset, - y: e.pageY - window.pageYOffset - }); - ctx.on('closed', () => { - this.update({ - isContextmenuShowing: false - }); - }); - - return false; - }; - </script> -</mk-drive-browser-folder> diff --git a/src/web/app/desktop/tags/ellipsis-icon.tag b/src/web/app/desktop/tags/ellipsis-icon.tag deleted file mode 100644 index 8462bfc4af..0000000000 --- a/src/web/app/desktop/tags/ellipsis-icon.tag +++ /dev/null @@ -1,37 +0,0 @@ -<mk-ellipsis-icon> - <div></div> - <div></div> - <div></div> - <style> - :scope - display block - 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> -</mk-ellipsis-icon> diff --git a/src/web/app/desktop/tags/follow-button.tag b/src/web/app/desktop/tags/follow-button.tag deleted file mode 100644 index ce6de3ac69..0000000000 --- a/src/web/app/desktop/tags/follow-button.tag +++ /dev/null @@ -1,150 +0,0 @@ -<mk-follow-button> - <button class={ wait: wait, follow: !user.is_following, unfollow: user.is_following } if={ !init } onclick={ onclick } disabled={ wait } title={ user.is_following ? 'フォロー解除' : 'フォローする' }> - <virtual if={ !wait && user.is_following }>%fa:minus%</virtual> - <virtual if={ !wait && !user.is_following }>%fa:plus%</virtual> - <virtual if={ wait }>%fa:spinner .pulse .fw%</virtual> - </button> - <div class="init" if={ init }>%fa:spinner .pulse .fw%</div> - <style> - :scope - display block - - > button - > .init - 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 - - </style> - <script> - import isPromise from '../../common/scripts/is-promise'; - - this.mixin('i'); - this.mixin('api'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.user = null; - this.userPromise = isPromise(this.opts.user) - ? this.opts.user - : Promise.resolve(this.opts.user); - this.init = true; - this.wait = false; - - this.on('mount', () => { - this.userPromise.then(user => { - this.update({ - init: false, - user: user - }); - this.connection.on('follow', this.onStreamFollow); - this.connection.on('unfollow', this.onStreamUnfollow); - }); - }); - - this.on('unmount', () => { - this.connection.off('follow', this.onStreamFollow); - this.connection.off('unfollow', this.onStreamUnfollow); - this.stream.dispose(this.connectionId); - }); - - this.onStreamFollow = user => { - if (user.id == this.user.id) { - this.update({ - user: user - }); - } - }; - - this.onStreamUnfollow = user => { - if (user.id == this.user.id) { - this.update({ - user: user - }); - } - }; - - this.onclick = () => { - this.wait = true; - if (this.user.is_following) { - this.api('following/delete', { - user_id: this.user.id - }).then(() => { - this.user.is_following = false; - }).catch(err => { - console.error(err); - }).then(() => { - this.wait = false; - this.update(); - }); - } else { - this.api('following/create', { - user_id: this.user.id - }).then(() => { - this.user.is_following = true; - }).catch(err => { - console.error(err); - }).then(() => { - this.wait = false; - this.update(); - }); - } - }; - </script> -</mk-follow-button> diff --git a/src/web/app/desktop/tags/following-setuper.tag b/src/web/app/desktop/tags/following-setuper.tag deleted file mode 100644 index a51a38ccd5..0000000000 --- a/src/web/app/desktop/tags/following-setuper.tag +++ /dev/null @@ -1,169 +0,0 @@ -<mk-following-setuper> - <p class="title">気になるユーザーをフォロー:</p> - <div class="users" if={ !fetching && users.length > 0 }> - <div class="user" each={ users }><a class="avatar-anchor" href={ '/' + username }><img class="avatar" src={ avatar_url + '?thumbnail&size=42' } alt="" data-user-preview={ id }/></a> - <div class="body"><a class="name" href={ '/' + username } target="_blank" data-user-preview={ id }>{ name }</a> - <p class="username">@{ username }</p> - </div> - <mk-follow-button user={ this }/> - </div> - </div> - <p class="empty" if={ !fetching && users.length == 0 }>おすすめのユーザーは見つかりませんでした。</p> - <p class="fetching" if={ fetching }>%fa:spinner .pulse .fw%読み込んでいます<mk-ellipsis/></p> - <a class="refresh" onclick={ refresh }>もっと見る</a> - <button class="close" onclick={ close } title="閉じる">%fa:times%</button> - <style> - :scope - display block - 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> - <script> - this.mixin('api'); - this.mixin('user-preview'); - - this.users = null; - this.fetching = true; - - this.limit = 6; - this.page = 0; - - this.on('mount', () => { - this.fetch(); - }); - - this.fetch = () => { - this.update({ - fetching: true, - users: null - }); - - this.api('users/recommendation', { - limit: this.limit, - offset: this.limit * this.page - }).then(users => { - this.fetching = false - this.users = users - this.update({ - fetching: false, - users: users - }); - }); - }; - - this.refresh = () => { - if (this.users.length < this.limit) { - this.page = 0; - } else { - this.page++; - } - this.fetch(); - }; - - this.close = () => { - this.unmount(); - }; - </script> -</mk-following-setuper> diff --git a/src/web/app/desktop/tags/home-widgets/access-log.tag b/src/web/app/desktop/tags/home-widgets/access-log.tag deleted file mode 100644 index 91a71022a7..0000000000 --- a/src/web/app/desktop/tags/home-widgets/access-log.tag +++ /dev/null @@ -1,95 +0,0 @@ -<mk-access-log-home-widget> - <virtual if={ data.design == 0 }> - <p class="title">%fa:server%%i18n:desktop.tags.mk-access-log-home-widget.title%</p> - </virtual> - <div ref="log"> - <p each={ requests }> - <span class="ip" style="color:{ fg }; background:{ bg }">{ ip }</span> - <span>{ method }</span> - <span>{ path }</span> - </p> - </div> - <style> - :scope - display block - overflow hidden - 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 - - > div - 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) - - > .ip - margin-right 4px - - </style> - <script> - import seedrandom from 'seedrandom'; - - this.data = { - design: 0 - }; - - this.mixin('widget'); - - this.mixin('requests-stream'); - this.connection = this.requestsStream.getConnection(); - this.connectionId = this.requestsStream.use(); - - this.requests = []; - - this.on('mount', () => { - this.connection.on('request', this.onRequest); - }); - - this.on('unmount', () => { - this.connection.off('request', this.onRequest); - this.requestsStream.dispose(this.connectionId); - }); - - this.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.update(); - - this.refs.log.scrollTop = this.refs.log.scrollHeight; - }; - - this.func = () => { - if (++this.data.design == 2) this.data.design = 0; - this.save(); - }; - </script> -</mk-access-log-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/activity.tag b/src/web/app/desktop/tags/home-widgets/activity.tag deleted file mode 100644 index 2274e84162..0000000000 --- a/src/web/app/desktop/tags/home-widgets/activity.tag +++ /dev/null @@ -1,32 +0,0 @@ -<mk-activity-home-widget> - <mk-activity-widget design={ data.design } view={ data.view } user={ I } ref="activity"/> - <style> - :scope - display block - </style> - <script> - this.data = { - view: 0, - design: 0 - }; - - this.mixin('widget'); - - this.initializing = true; - - this.on('mount', () => { - this.refs.activity.on('view-changed', view => { - this.data.view = view; - this.save(); - }); - }); - - this.func = () => { - if (++this.data.design == 3) this.data.design = 0; - this.refs.activity.update({ - design: this.data.design - }); - this.save(); - }; - </script> -</mk-activity-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/broadcast.tag b/src/web/app/desktop/tags/home-widgets/broadcast.tag deleted file mode 100644 index 6f4bb0756d..0000000000 --- a/src/web/app/desktop/tags/home-widgets/broadcast.tag +++ /dev/null @@ -1,143 +0,0 @@ -<mk-broadcast-home-widget data-found={ broadcasts.length != 0 } data-melt={ data.design == 1 }> - <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" if={ fetching }>%i18n:desktop.tags.mk-broadcast-home-widget.fetching%<mk-ellipsis/></p> - <h1 if={ !fetching }>{ - broadcasts.length == 0 ? '%i18n:desktop.tags.mk-broadcast-home-widget.no-broadcasts%' : broadcasts[i].title - }</h1> - <p if={ !fetching }><mk-raw if={ broadcasts.length != 0 } content={ broadcasts[i].text }/><virtual if={ broadcasts.length == 0 }>%i18n:desktop.tags.mk-broadcast-home-widget.have-a-nice-day%</virtual></p> - <a if={ broadcasts.length > 1 } onclick={ next }>%i18n:desktop.tags.mk-broadcast-home-widget.next% >></a> - <style> - :scope - display block - 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 - - </style> - <script> - this.data = { - design: 0 - }; - - this.mixin('widget'); - this.mixin('os'); - - this.i = 0; - this.fetching = true; - this.broadcasts = []; - - this.on('mount', () => { - this.mios.getMeta().then(meta => { - let broadcasts = []; - if (meta.broadcasts) { - meta.broadcasts.forEach(broadcast => { - if (broadcast[_LANG_]) { - broadcasts.push(broadcast[_LANG_]); - } - }); - } - this.update({ - fetching: false, - broadcasts: broadcasts - }); - }); - }); - - this.next = () => { - if (this.i == this.broadcasts.length - 1) { - this.i = 0; - } else { - this.i++; - } - this.update(); - }; - - this.func = () => { - if (++this.data.design == 2) this.data.design = 0; - this.save(); - }; - </script> -</mk-broadcast-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/calendar.tag b/src/web/app/desktop/tags/home-widgets/calendar.tag deleted file mode 100644 index fded57e07a..0000000000 --- a/src/web/app/desktop/tags/home-widgets/calendar.tag +++ /dev/null @@ -1,167 +0,0 @@ -<mk-calendar-home-widget data-melt={ data.design == 1 } data-special={ special }> - <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> - <style> - :scope - display block - 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 - - &: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> - <script> - this.data = { - design: 0 - }; - - this.mixin('widget'); - - this.draw = () => { - 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()]; - - this.dayNumer = now - new Date(ny, nm, nd); - this.dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/; - this.monthNumer = now - new Date(ny, nm, 1); - this.monthDenom = new Date(ny, nm + 1, 1) - new Date(ny, nm, 1); - this.yearNumer = now - new Date(ny, 0, 1); - this.yearDenom = new Date(ny + 1, 0, 1) - new Date(ny, 0, 1); - - this.dayP = this.dayNumer / this.dayDenom * 100; - this.monthP = this.monthNumer / this.monthDenom * 100; - this.yearP = this.yearNumer / this.yearDenom * 100; - - this.isHoliday = now.getDay() == 0 || now.getDay() == 6; - - this.special = - nm == 0 && nd == 1 ? 'on-new-years-day' : - false; - - this.update(); - }; - - this.draw(); - - this.on('mount', () => { - this.clock = setInterval(this.draw, 1000); - }); - - this.on('unmount', () => { - clearInterval(this.clock); - }); - - this.func = () => { - if (++this.data.design == 2) this.data.design = 0; - this.save(); - }; - </script> -</mk-calendar-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/channel.tag b/src/web/app/desktop/tags/home-widgets/channel.tag deleted file mode 100644 index 545bc38acf..0000000000 --- a/src/web/app/desktop/tags/home-widgets/channel.tag +++ /dev/null @@ -1,318 +0,0 @@ -<mk-channel-home-widget> - <virtual if={ !data.compact }> - <p class="title">%fa:tv%{ - channel ? channel.title : '%i18n:desktop.tags.mk-channel-home-widget.title%' - }</p> - <button onclick={ settings } title="%i18n:desktop.tags.mk-channel-home-widget.settings%">%fa:cog%</button> - </virtual> - <p class="get-started" if={ this.data.channel == null }>%i18n:desktop.tags.mk-channel-home-widget.get-started%</p> - <mk-channel ref="channel" show={ this.data.channel }/> - <style> - :scope - display block - 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 - - > mk-channel - height 200px - - </style> - <script> - this.data = { - channel: null, - compact: false - }; - - this.mixin('widget'); - - this.on('mount', () => { - if (this.data.channel) { - this.zap(); - } - }); - - this.zap = () => { - this.update({ - fetching: true - }); - - this.api('channels/show', { - channel_id: this.data.channel - }).then(channel => { - this.update({ - fetching: false, - channel: channel - }); - - this.refs.channel.zap(channel); - }); - }; - - this.settings = () => { - const id = window.prompt('チャンネルID'); - if (!id) return; - this.data.channel = id; - this.zap(); - - // Save state - this.save(); - }; - - this.func = () => { - this.data.compact = !this.data.compact; - this.save(); - }; - </script> -</mk-channel-home-widget> - -<mk-channel> - <p if={ fetching }>読み込み中<mk-ellipsis/></p> - <div if={ !fetching } ref="posts"> - <p if={ posts.length == 0 }>まだ投稿がありません</p> - <mk-channel-post each={ post in posts.slice().reverse() } post={ post } form={ parent.refs.form }/> - </div> - <mk-channel-form ref="form"/> - <style> - :scope - display block - - > p - margin 0 - padding 16px - text-align center - color #aaa - - > div - height calc(100% - 38px) - overflow auto - font-size 0.9em - - > mk-channel-post - border-bottom solid 1px #eee - - &:last-child - border-bottom none - - > mk-channel-form - position absolute - left 0 - bottom 0 - - </style> - <script> - import ChannelStream from '../../../common/scripts/streaming/channel-stream'; - - this.mixin('api'); - - this.fetching = true; - this.channel = null; - this.posts = []; - - this.on('unmount', () => { - if (this.connection) { - this.connection.off('post', this.onPost); - this.connection.close(); - } - }); - - this.zap = channel => { - this.update({ - fetching: true, - channel: channel - }); - - this.api('channels/posts', { - channel_id: channel.id - }).then(posts => { - this.update({ - fetching: false, - posts: posts - }); - - this.scrollToBottom(); - - if (this.connection) { - this.connection.off('post', this.onPost); - this.connection.close(); - } - this.connection = new ChannelStream(this.channel.id); - this.connection.on('post', this.onPost); - }); - }; - - this.onPost = post => { - this.posts.unshift(post); - this.update(); - this.scrollToBottom(); - }; - - this.scrollToBottom = () => { - this.refs.posts.scrollTop = this.refs.posts.scrollHeight; - }; - </script> -</mk-channel> - -<mk-channel-post> - <header> - <a class="index" onclick={ reply }>{ post.index }:</a> - <a class="name" href={ _URL_ + '/' + post.user.username }><b>{ post.user.name }</b></a> - <span>ID:<i>{ post.user.username }</i></span> - </header> - <div> - <a if={ post.reply }>>>{ post.reply.index }</a> - { post.text } - <div class="media" if={ post.media }> - <virtual 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> - </virtual> - </div> - </div> - <style> - :scope - display block - 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> - <script> - this.post = this.opts.post; - this.form = this.opts.form; - - this.reply = () => { - this.form.refs.text.value = `>>${ this.post.index } `; - }; - </script> -</mk-channel-post> - -<mk-channel-form> - <input ref="text" disabled={ wait } onkeydown={ onkeydown } placeholder="書いて"> - <style> - :scope - display block - 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> - <script> - this.mixin('api'); - - this.clear = () => { - this.refs.text.value = ''; - }; - - this.onkeydown = e => { - if (e.which == 10 || e.which == 13) this.post(); - }; - - this.post = () => { - this.update({ - wait: true - }); - - let text = this.refs.text.value; - let reply = null; - - if (/^>>([0-9]+) /.test(text)) { - const index = text.match(/^>>([0-9]+) /)[1]; - reply = this.parent.posts.find(p => p.index.toString() == index); - text = text.replace(/^>>([0-9]+) /, ''); - } - - this.api('posts/create', { - text: text, - reply_id: reply ? reply.id : undefined, - channel_id: this.parent.channel.id - }).then(data => { - this.clear(); - }).catch(err => { - alert('失敗した'); - }).then(() => { - this.update({ - wait: false - }); - }); - }; - </script> -</mk-channel-form> diff --git a/src/web/app/desktop/tags/home-widgets/donation.tag b/src/web/app/desktop/tags/home-widgets/donation.tag deleted file mode 100644 index a51a7bebbb..0000000000 --- a/src/web/app/desktop/tags/home-widgets/donation.tag +++ /dev/null @@ -1,36 +0,0 @@ -<mk-donation-home-widget> - <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="/syuilo" data-user-preview="@syuilo">@syuilo</a>{'%i18n:desktop.tags.mk-donation-home-widget.text%'.substr('%i18n:desktop.tags.mk-donation-home-widget.text%'.indexOf('}') + 1)}</p> - </article> - <style> - :scope - display block - 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 - - </style> - <script> - this.mixin('widget'); - this.mixin('user-preview'); - </script> -</mk-donation-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/mentions.tag b/src/web/app/desktop/tags/home-widgets/mentions.tag deleted file mode 100644 index 2687283079..0000000000 --- a/src/web/app/desktop/tags/home-widgets/mentions.tag +++ /dev/null @@ -1,125 +0,0 @@ -<mk-mentions-home-widget> - <header><span data-is-active={ mode == 'all' } onclick={ setMode.bind(this, 'all') }>すべて</span><span data-is-active={ mode == 'following' } onclick={ setMode.bind(this, 'following') }>フォロー中</span></header> - <div class="loading" if={ isLoading }> - <mk-ellipsis-icon/> - </div> - <p class="empty" if={ isEmpty }>%fa:R comments%<span if={ mode == 'all' }>あなた宛ての投稿はありません。</span><span if={ mode == 'following' }>あなたがフォローしているユーザーからの言及はありません。</span></p> - <mk-timeline ref="timeline"> - <yield to="footer"> - <virtual if={ !parent.moreLoading }>%fa:moon%</virtual> - <virtual if={ parent.moreLoading }>%fa:spinner .pulse .fw%</virtual> - </yield/> - </mk-timeline> - <style> - :scope - display block - 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 - - > .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> - <script> - this.mixin('i'); - this.mixin('api'); - - this.isLoading = true; - this.isEmpty = false; - this.moreLoading = false; - this.mode = 'all'; - - this.on('mount', () => { - document.addEventListener('keydown', this.onDocumentKeydown); - window.addEventListener('scroll', this.onScroll); - - this.fetch(() => this.trigger('loaded')); - }); - - this.on('unmount', () => { - document.removeEventListener('keydown', this.onDocumentKeydown); - window.removeEventListener('scroll', this.onScroll); - }); - - this.onDocumentKeydown = e => { - if (e.target.tagName != 'INPUT' && tag != 'TEXTAREA') { - if (e.which == 84) { // t - this.refs.timeline.focus(); - } - } - }; - - this.fetch = cb => { - this.api('posts/mentions', { - following: this.mode == 'following' - }).then(posts => { - this.update({ - isLoading: false, - isEmpty: posts.length == 0 - }); - this.refs.timeline.setPosts(posts); - if (cb) cb(); - }); - }; - - this.more = () => { - if (this.moreLoading || this.isLoading || this.refs.timeline.posts.length == 0) return; - this.update({ - moreLoading: true - }); - this.api('posts/mentions', { - following: this.mode == 'following', - until_id: this.refs.timeline.tail().id - }).then(posts => { - this.update({ - moreLoading: false - }); - this.refs.timeline.prependPosts(posts); - }); - }; - - this.onScroll = () => { - const current = window.scrollY + window.innerHeight; - if (current > document.body.offsetHeight - 8) this.more(); - }; - - this.setMode = mode => { - this.update({ - mode: mode - }); - this.fetch(); - }; - </script> -</mk-mentions-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/messaging.tag b/src/web/app/desktop/tags/home-widgets/messaging.tag deleted file mode 100644 index f2c7c35896..0000000000 --- a/src/web/app/desktop/tags/home-widgets/messaging.tag +++ /dev/null @@ -1,52 +0,0 @@ -<mk-messaging-home-widget> - <virtual if={ data.design == 0 }> - <p class="title">%fa:comments%%i18n:desktop.tags.mk-messaging-home-widget.title%</p> - </virtual> - <mk-messaging ref="index" compact={ true }/> - <style> - :scope - display block - 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> - <script> - this.data = { - design: 0 - }; - - this.mixin('widget'); - - this.on('mount', () => { - this.refs.index.on('navigate-user', user => { - riot.mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), { - user: user - }); - }); - }); - - this.func = () => { - if (++this.data.design == 2) this.data.design = 0; - this.save(); - }; - </script> -</mk-messaging-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/nav.tag b/src/web/app/desktop/tags/home-widgets/nav.tag deleted file mode 100644 index 61c0b4cb55..0000000000 --- a/src/web/app/desktop/tags/home-widgets/nav.tag +++ /dev/null @@ -1,23 +0,0 @@ -<mk-nav-home-widget> - <mk-nav-links/> - <style> - :scope - display block - 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> - <script> - this.mixin('widget'); - </script> -</mk-nav-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/notifications.tag b/src/web/app/desktop/tags/home-widgets/notifications.tag deleted file mode 100644 index 0ccd832d71..0000000000 --- a/src/web/app/desktop/tags/home-widgets/notifications.tag +++ /dev/null @@ -1,66 +0,0 @@ -<mk-notifications-home-widget> - <virtual if={ !data.compact }> - <p class="title">%fa:R bell%%i18n:desktop.tags.mk-notifications-home-widget.title%</p> - <button onclick={ settings } title="%i18n:desktop.tags.mk-notifications-home-widget.settings%">%fa:cog%</button> - </virtual> - <mk-notifications/> - <style> - :scope - display block - 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> - <script> - this.data = { - compact: false - }; - - this.mixin('widget'); - - this.settings = () => { - const w = riot.mount(document.body.appendChild(document.createElement('mk-settings-window')))[0]; - w.switch('notification'); - }; - - this.func = () => { - this.data.compact = !this.data.compact; - this.save(); - }; - </script> -</mk-notifications-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/photo-stream.tag b/src/web/app/desktop/tags/home-widgets/photo-stream.tag deleted file mode 100644 index e3bf3a988c..0000000000 --- a/src/web/app/desktop/tags/home-widgets/photo-stream.tag +++ /dev/null @@ -1,118 +0,0 @@ -<mk-photo-stream-home-widget data-melt={ data.design == 2 }> - <virtual if={ data.design == 0 }> - <p class="title">%fa:camera%%i18n:desktop.tags.mk-photo-stream-home-widget.title%</p> - </virtual> - <p class="initializing" if={ initializing }>%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <div class="stream" if={ !initializing && images.length > 0 }> - <virtual each={ image in images }> - <div class="img" style={ 'background-image: url(' + image.url + '?thumbnail&size=256)' }></div> - </virtual> - </div> - <p class="empty" if={ !initializing && images.length == 0 }>%i18n:desktop.tags.mk-photo-stream-home-widget.no-photos%</p> - <style> - :scope - display block - background #fff - border solid 1px rgba(0, 0, 0, 0.075) - border-radius 6px - - &[data-melt] - background transparent !important - border none !important - - > .stream - padding 0 - - > .img - border solid 4px transparent - border-radius 8px - - > .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 - - > .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 - - > .initializing - > .empty - margin 0 - padding 16px - text-align center - color #aaa - - > [data-fa] - margin-right 4px - - </style> - <script> - this.data = { - design: 0 - }; - - this.mixin('widget'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.images = []; - this.initializing = true; - - this.on('mount', () => { - this.connection.on('drive_file_created', this.onStreamDriveFileCreated); - - this.api('drive/stream', { - type: 'image/*', - limit: 9 - }).then(images => { - this.update({ - initializing: false, - images: images - }); - }); - }); - - this.on('unmount', () => { - this.connection.off('drive_file_created', this.onStreamDriveFileCreated); - this.stream.dispose(this.connectionId); - }); - - this.onStreamDriveFileCreated = file => { - if (/^image\/.+$/.test(file.type)) { - this.images.unshift(file); - if (this.images.length > 9) this.images.pop(); - this.update(); - } - }; - - this.func = () => { - if (++this.data.design == 3) this.data.design = 0; - this.save(); - }; - </script> -</mk-photo-stream-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/post-form.tag b/src/web/app/desktop/tags/home-widgets/post-form.tag deleted file mode 100644 index c8ccc5a30e..0000000000 --- a/src/web/app/desktop/tags/home-widgets/post-form.tag +++ /dev/null @@ -1,103 +0,0 @@ -<mk-post-form-home-widget> - <mk-post-form if={ place == 'main' }/> - <virtual if={ place != 'main' }> - <virtual if={ data.design == 0 }> - <p class="title">%fa:pencil-alt%%i18n:desktop.tags.mk-post-form-home-widget.title%</p> - </virtual> - <textarea disabled={ posting } ref="text" onkeydown={ onkeydown } placeholder="%i18n:desktop.tags.mk-post-form-home-widget.placeholder%"></textarea> - <button onclick={ post } disabled={ posting }>%i18n:desktop.tags.mk-post-form-home-widget.post%</button> - </virtual> - <style> - :scope - display block - 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> - <script> - this.data = { - design: 0 - }; - - this.mixin('widget'); - - this.func = () => { - if (++this.data.design == 2) this.data.design = 0; - this.save(); - }; - - this.onkeydown = e => { - if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); - }; - - this.post = () => { - this.update({ - posting: true - }); - - this.api('posts/create', { - text: this.refs.text.value - }).then(data => { - this.clear(); - }).catch(err => { - alert('失敗した'); - }).then(() => { - this.update({ - posting: false - }); - }); - }; - - this.clear = () => { - this.refs.text.value = ''; - }; - </script> -</mk-post-form-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/profile.tag b/src/web/app/desktop/tags/home-widgets/profile.tag deleted file mode 100644 index eb8ba52e84..0000000000 --- a/src/web/app/desktop/tags/home-widgets/profile.tag +++ /dev/null @@ -1,116 +0,0 @@ -<mk-profile-home-widget data-compact={ data.design == 1 || data.design == 2 } data-melt={ data.design == 2 }> - <div class="banner" style={ I.banner_url ? 'background-image: url(' + I.banner_url + '?thumbnail&size=256)' : '' } title="クリックでバナー編集" onclick={ setBanner }></div> - <img class="avatar" src={ I.avatar_url + '?thumbnail&size=96' } onclick={ setAvatar } alt="avatar" title="クリックでアバター編集" data-user-preview={ I.id }/> - <a class="name" href={ '/' + I.username }>{ I.name }</a> - <p class="username">@{ I.username }</p> - <style> - :scope - display block - 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> - <script> - import inputDialog from '../../scripts/input-dialog'; - import updateAvatar from '../../scripts/update-avatar'; - import updateBanner from '../../scripts/update-banner'; - - this.data = { - design: 0 - }; - - this.mixin('widget'); - - this.mixin('user-preview'); - - this.setAvatar = () => { - updateAvatar(this.I); - }; - - this.setBanner = () => { - updateBanner(this.I); - }; - - this.func = () => { - if (++this.data.design == 3) this.data.design = 0; - this.save(); - }; - </script> -</mk-profile-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/recommended-polls.tag b/src/web/app/desktop/tags/home-widgets/recommended-polls.tag deleted file mode 100644 index 776f666015..0000000000 --- a/src/web/app/desktop/tags/home-widgets/recommended-polls.tag +++ /dev/null @@ -1,119 +0,0 @@ -<mk-recommended-polls-home-widget> - <virtual if={ !data.compact }> - <p class="title">%fa:chart-pie%%i18n:desktop.tags.mk-recommended-polls-home-widget.title%</p> - <button onclick={ fetch } title="%i18n:desktop.tags.mk-recommended-polls-home-widget.refresh%">%fa:sync%</button> - </virtual> - <div class="poll" if={ !loading && poll != null }> - <p if={ poll.text }><a href="/{ poll.user.username }/{ poll.id }">{ poll.text }</a></p> - <p if={ !poll.text }><a href="/{ poll.user.username }/{ poll.id }">%fa:link%</a></p> - <mk-poll post={ poll }/> - </div> - <p class="empty" if={ !loading && poll == null }>%i18n:desktop.tags.mk-recommended-polls-home-widget.nothing%</p> - <p class="loading" if={ loading }>%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <style> - :scope - display block - 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 - - > .loading - margin 0 - padding 16px - text-align center - color #aaa - - > [data-fa] - margin-right 4px - - </style> - <script> - this.data = { - compact: false - }; - - this.mixin('widget'); - - this.poll = null; - this.loading = true; - - this.offset = 0; - - this.on('mount', () => { - this.fetch(); - }); - - this.fetch = () => { - this.update({ - loading: true, - poll: null - }); - this.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.update({ - loading: false, - poll: poll - }); - }); - }; - - this.func = () => { - this.data.compact = !this.data.compact; - this.save(); - }; - </script> -</mk-recommended-polls-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/rss-reader.tag b/src/web/app/desktop/tags/home-widgets/rss-reader.tag deleted file mode 100644 index a927693ce8..0000000000 --- a/src/web/app/desktop/tags/home-widgets/rss-reader.tag +++ /dev/null @@ -1,109 +0,0 @@ -<mk-rss-reader-home-widget> - <virtual if={ !data.compact }> - <p class="title">%fa:rss-square%RSS</p> - <button onclick={ settings } title="設定">%fa:cog%</button> - </virtual> - <div class="feed" if={ !initializing }> - <virtual each={ item in items }><a href={ item.link } target="_blank">{ item.title }</a></virtual> - </div> - <p class="initializing" if={ initializing }>%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <style> - :scope - display block - 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 - box-shadow 0 1px rgba(0, 0, 0, 0.07) - - > [data-fa] - margin-right 4px - - > button - position absolute - top 0 - right 0 - padding 0 - width 42px - font-size 0.9em - line-height 42px - color #ccc - - &:hover - color #aaa - - &:active - color #999 - - > .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 - - > .initializing - margin 0 - padding 16px - text-align center - color #aaa - - > [data-fa] - margin-right 4px - - </style> - <script> - this.data = { - compact: false - }; - - this.mixin('widget'); - - this.url = 'http://news.yahoo.co.jp/pickup/rss.xml'; - this.items = []; - this.initializing = true; - - this.on('mount', () => { - this.fetch(); - this.clock = setInterval(this.fetch, 60000); - }); - - this.on('unmount', () => { - clearInterval(this.clock); - }); - - this.fetch = () => { - fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.url}`, { - cache: 'no-cache' - }).then(res => { - res.json().then(feed => { - this.update({ - initializing: false, - items: feed.items - }); - }); - }); - }; - - this.settings = () => { - }; - - this.func = () => { - this.data.compact = !this.data.compact; - this.save(); - }; - </script> -</mk-rss-reader-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/server.tag b/src/web/app/desktop/tags/home-widgets/server.tag deleted file mode 100644 index b9b191c181..0000000000 --- a/src/web/app/desktop/tags/home-widgets/server.tag +++ /dev/null @@ -1,533 +0,0 @@ -<mk-server-home-widget data-melt={ data.design == 2 }> - <virtual if={ data.design == 0 }> - <p class="title">%fa:server%%i18n:desktop.tags.mk-server-home-widget.title%</p> - <button onclick={ toggle } title="%i18n:desktop.tags.mk-server-home-widget.toggle%">%fa:sort%</button> - </virtual> - <p class="initializing" if={ initializing }>%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <mk-server-home-widget-cpu-and-memory-usage if={ !initializing } show={ data.view == 0 } connection={ connection }/> - <mk-server-home-widget-cpu if={ !initializing } show={ data.view == 1 } connection={ connection } meta={ meta }/> - <mk-server-home-widget-memory if={ !initializing } show={ data.view == 2 } connection={ connection }/> - <mk-server-home-widget-disk if={ !initializing } show={ data.view == 3 } connection={ connection }/> - <mk-server-home-widget-uptimes if={ !initializing } show={ data.view == 4 } connection={ connection }/> - <mk-server-home-widget-info if={ !initializing } show={ data.view == 5 } connection={ connection } meta={ meta }/> - <style> - :scope - display block - 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 - - > .initializing - margin 0 - padding 16px - text-align center - color #aaa - - > [data-fa] - margin-right 4px - - </style> - <script> - this.mixin('os'); - - this.data = { - view: 0, - design: 0 - }; - - this.mixin('widget'); - - this.mixin('server-stream'); - this.connection = this.serverStream.getConnection(); - this.connectionId = this.serverStream.use(); - - this.initializing = true; - - this.on('mount', () => { - this.mios.getMeta().then(meta => { - this.update({ - initializing: false, - meta - }); - }); - }); - - this.on('unmount', () => { - this.serverStream.dispose(this.connectionId); - }); - - this.toggle = () => { - this.data.view++; - if (this.data.view == 6) this.data.view = 0; - - // Save widget state - this.save(); - }; - - this.func = () => { - if (++this.data.design == 3) this.data.design = 0; - this.save(); - }; - </script> -</mk-server-home-widget> - -<mk-server-home-widget-cpu-and-memory-usage> - <svg riot-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="33%" stop-color="hsl(120, 80%, 70%)"></stop> - <stop offset="66%" stop-color="hsl(60, 80%, 70%)"></stop> - <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> - </linearGradient> - <mask id={ cpuMaskId } x="0" y="0" riot-width={ viewBoxX } riot-height={ viewBoxY }> - <polygon - riot-points={ cpuPolygonPoints } - fill="#fff" - fill-opacity="0.5"/> - <polyline - riot-points={ cpuPolylinePoints } - fill="none" - stroke="#fff" - stroke-width="1"/> - </mask> - </defs> - <rect - x="-1" y="-1" - riot-width={ viewBoxX + 2 } riot-height={ viewBoxY + 2 } - style="stroke: none; fill: url(#{ cpuGradientId }); mask: url(#{ cpuMaskId })"/> - <text x="1" y="5">CPU <tspan>{ cpuP }%</tspan></text> - </svg> - <svg riot-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="33%" stop-color="hsl(120, 80%, 70%)"></stop> - <stop offset="66%" stop-color="hsl(60, 80%, 70%)"></stop> - <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> - </linearGradient> - <mask id={ memMaskId } x="0" y="0" riot-width={ viewBoxX } riot-height={ viewBoxY }> - <polygon - riot-points={ memPolygonPoints } - fill="#fff" - fill-opacity="0.5"/> - <polyline - riot-points={ memPolylinePoints } - fill="none" - stroke="#fff" - stroke-width="1"/> - </mask> - </defs> - <rect - x="-1" y="-1" - riot-width={ viewBoxX + 2 } riot-height={ viewBoxY + 2 } - style="stroke: none; fill: url(#{ memGradientId }); mask: url(#{ memMaskId })"/> - <text x="1" y="5">MEM <tspan>{ memP }%</tspan></text> - </svg> - <style> - :scope - display block - - > 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> - <script> - import uuid from 'uuid'; - - this.viewBoxX = 50; - this.viewBoxY = 30; - this.stats = []; - this.connection = this.opts.connection; - this.cpuGradientId = uuid(); - this.cpuMaskId = uuid(); - this.memGradientId = uuid(); - this.memMaskId = uuid(); - - 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.stats.push(stats); - if (this.stats.length > 50) this.stats.shift(); - - const cpuPolylinePoints = this.stats.map((s, i) => `${this.viewBoxX - ((this.stats.length - 1) - i)},${(1 - s.cpu_usage) * this.viewBoxY}`).join(' '); - const memPolylinePoints = this.stats.map((s, i) => `${this.viewBoxX - ((this.stats.length - 1) - i)},${(1 - (s.mem.used / s.mem.total)) * this.viewBoxY}`).join(' '); - - const cpuPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${ this.viewBoxY } ${ cpuPolylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; - const memPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${ this.viewBoxY } ${ memPolylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; - - const cpuP = (stats.cpu_usage * 100).toFixed(0); - const memP = (stats.mem.used / stats.mem.total * 100).toFixed(0); - - this.update({ - cpuPolylinePoints, - memPolylinePoints, - cpuPolygonPoints, - memPolygonPoints, - cpuP, - memP - }); - }; - </script> -</mk-server-home-widget-cpu-and-memory-usage> - -<mk-server-home-widget-cpu> - <mk-server-home-widget-pie ref="pie"/> - <div> - <p>%fa:microchip%CPU</p> - <p>{ cores } Cores</p> - <p>{ model }</p> - </div> - <style> - :scope - display block - - > mk-server-home-widget-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> - <script> - this.cores = this.opts.meta.cpu.cores; - this.model = this.opts.meta.cpu.model; - 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.pie.render(stats.cpu_usage); - }; - </script> -</mk-server-home-widget-cpu> - -<mk-server-home-widget-memory> - <mk-server-home-widget-pie ref="pie"/> - <div> - <p>%fa:flask%Memory</p> - <p>Total: { bytesToSize(total, 1) }</p> - <p>Used: { bytesToSize(used, 1) }</p> - <p>Free: { bytesToSize(free, 1) }</p> - </div> - <style> - :scope - display block - - > mk-server-home-widget-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> - <script> - import bytesToSize from '../../../common/scripts/bytes-to-size'; - - this.connection = this.opts.connection; - this.bytesToSize = bytesToSize; - - 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.pie.render(stats.mem.used / stats.mem.total); - - this.update({ - total: stats.mem.total, - used: stats.mem.used, - free: stats.mem.free - }); - }; - </script> -</mk-server-home-widget-memory> - -<mk-server-home-widget-disk> - <mk-server-home-widget-pie ref="pie"/> - <div> - <p>%fa:R hdd%Storage</p> - <p>Total: { bytesToSize(total, 1) }</p> - <p>Available: { bytesToSize(available, 1) }</p> - <p>Used: { bytesToSize(used, 1) }</p> - </div> - <style> - :scope - display block - - > mk-server-home-widget-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> - <script> - import bytesToSize from '../../../common/scripts/bytes-to-size'; - - this.connection = this.opts.connection; - this.bytesToSize = bytesToSize; - - this.on('mount', () => { - this.connection.on('stats', this.onStats); - }); - - this.on('unmount', () => { - this.connection.off('stats', this.onStats); - }); - - this.onStats = stats => { - stats.disk.used = stats.disk.total - stats.disk.free; - - this.refs.pie.render(stats.disk.used / stats.disk.total); - - this.update({ - total: stats.disk.total, - used: stats.disk.used, - available: stats.disk.available - }); - }; - </script> -</mk-server-home-widget-disk> - -<mk-server-home-widget-uptimes> - <p>Uptimes</p> - <p>Process: { process ? process.toFixed(0) : '---' }s</p> - <p>OS: { os ? os.toFixed(0) : '---' }s</p> - <style> - :scope - display block - padding 10px 14px - - > p - margin 0 - font-size 12px - color #505050 - - &:first-child - font-weight bold - - </style> - <script> - 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.update({ - process: stats.process_uptime, - os: stats.os_uptime - }); - }; - </script> -</mk-server-home-widget-uptimes> - -<mk-server-home-widget-info> - <p>Maintainer: <b>{ meta.maintainer }</b></p> - <p>Machine: { meta.machine }</p> - <p>Node: { meta.node }</p> - <style> - :scope - display block - padding 10px 14px - - > p - margin 0 - font-size 12px - color #505050 - - </style> - <script> - this.meta = this.opts.meta; - </script> -</mk-server-home-widget-info> - -<mk-server-home-widget-pie> - <svg viewBox="0 0 1 1" preserveAspectRatio="none"> - <circle - riot-r={ r } - cx="50%" cy="50%" - fill="none" - stroke-width="0.1" - stroke="rgba(0, 0, 0, 0.05)"/> - <circle - riot-r={ r } - cx="50%" cy="50%" - riot-stroke-dasharray={ Math.PI * (r * 2) } - riot-stroke-dashoffset={ strokeDashoffset } - fill="none" - stroke-width="0.1" - riot-stroke={ color }/> - <text x="50%" y="50%" dy="0.05" text-anchor="middle">{ (p * 100).toFixed(0) }%</text> - </svg> - <style> - :scope - display block - - > 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> - <script> - this.r = 0.4; - - this.render = p => { - const color = `hsl(${180 - (p * 180)}, 80%, 70%)`; - const strokeDashoffset = (1 - p) * (Math.PI * (this.r * 2)); - - this.update({ - p, - color, - strokeDashoffset - }); - }; - </script> -</mk-server-home-widget-pie> diff --git a/src/web/app/desktop/tags/home-widgets/slideshow.tag b/src/web/app/desktop/tags/home-widgets/slideshow.tag deleted file mode 100644 index 53fe047000..0000000000 --- a/src/web/app/desktop/tags/home-widgets/slideshow.tag +++ /dev/null @@ -1,151 +0,0 @@ -<mk-slideshow-home-widget> - <div onclick={ choose }> - <p if={ data.folder === undefined }>クリックしてフォルダを指定してください</p> - <p if={ data.folder !== undefined && images.length == 0 && !fetching }>このフォルダには画像がありません</p> - <div ref="slideA" class="slide a"></div> - <div ref="slideB" class="slide b"></div> - </div> - <button onclick={ resize }>%fa:expand%</button> - <style> - :scope - display block - overflow hidden - background #fff - border solid 1px rgba(0, 0, 0, 0.075) - border-radius 6px - - &:hover > button - display block - - > button - position absolute - left 0 - bottom 0 - display none - padding 4px - font-size 24px - color #fff - text-shadow 0 0 8px #000 - - > div - width 100% - height 100% - cursor pointer - - > * - pointer-events none - - > .slide - position absolute - top 0 - left 0 - width 100% - height 100% - background-size cover - background-position center - - &.b - opacity 0 - - </style> - <script> - import anime from 'animejs'; - - this.data = { - folder: undefined, - size: 0 - }; - - this.mixin('widget'); - - this.images = []; - this.fetching = true; - - this.on('mount', () => { - this.applySize(); - - if (this.data.folder !== undefined) { - this.fetch(); - } - - this.clock = setInterval(this.change, 10000); - }); - - this.on('unmount', () => { - clearInterval(this.clock); - }); - - this.applySize = () => { - let h; - - if (this.data.size == 1) { - h = 250; - } else { - h = 170; - } - - this.root.style.height = `${h}px`; - }; - - this.resize = () => { - this.data.size++; - if (this.data.size == 2) this.data.size = 0; - - this.applySize(); - this.save(); - }; - - this.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.style.backgroundImage = img; - - anime({ - targets: this.refs.slideB, - opacity: 1, - duration: 1000, - easing: 'linear', - complete: () => { - this.refs.slideA.style.backgroundImage = img; - anime({ - targets: this.refs.slideB, - opacity: 0, - duration: 0 - }); - } - }); - }; - - this.fetch = () => { - this.update({ - fetching: true - }); - - this.api('drive/files', { - folder_id: this.data.folder, - type: 'image/*', - limit: 100 - }).then(images => { - this.update({ - fetching: false, - images: images - }); - this.refs.slideA.style.backgroundImage = ''; - this.refs.slideB.style.backgroundImage = ''; - this.change(); - }); - }; - - this.choose = () => { - const i = riot.mount(document.body.appendChild(document.createElement('mk-select-folder-from-drive-window')))[0]; - i.one('selected', folder => { - this.data.folder = folder ? folder.id : null; - this.fetch(); - this.save(); - }); - }; - </script> -</mk-slideshow-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/timeline.tag b/src/web/app/desktop/tags/home-widgets/timeline.tag deleted file mode 100644 index 9571b09f34..0000000000 --- a/src/web/app/desktop/tags/home-widgets/timeline.tag +++ /dev/null @@ -1,143 +0,0 @@ -<mk-timeline-home-widget> - <mk-following-setuper if={ noFollowing }/> - <div class="loading" if={ isLoading }> - <mk-ellipsis-icon/> - </div> - <p class="empty" if={ isEmpty && !isLoading }>%fa:R comments%自分の投稿や、自分がフォローしているユーザーの投稿が表示されます。</p> - <mk-timeline ref="timeline" hide={ isLoading }> - <yield to="footer"> - <virtual if={ !parent.moreLoading }>%fa:moon%</virtual> - <virtual if={ parent.moreLoading }>%fa:spinner .pulse .fw%</virtual> - </yield/> - </mk-timeline> - <style> - :scope - display block - background #fff - border solid 1px rgba(0, 0, 0, 0.075) - border-radius 6px - - > mk-following-setuper - border-bottom solid 1px #eee - - > .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> - <script> - this.mixin('i'); - this.mixin('api'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.isLoading = true; - this.isEmpty = false; - this.moreLoading = false; - this.noFollowing = this.I.following_count == 0; - - this.on('mount', () => { - this.connection.on('post', this.onStreamPost); - this.connection.on('follow', this.onStreamFollow); - this.connection.on('unfollow', this.onStreamUnfollow); - - document.addEventListener('keydown', this.onDocumentKeydown); - window.addEventListener('scroll', this.onScroll); - - this.load(() => this.trigger('loaded')); - }); - - this.on('unmount', () => { - this.connection.off('post', this.onStreamPost); - this.connection.off('follow', this.onStreamFollow); - this.connection.off('unfollow', this.onStreamUnfollow); - this.stream.dispose(this.connectionId); - - document.removeEventListener('keydown', this.onDocumentKeydown); - window.removeEventListener('scroll', this.onScroll); - }); - - this.onDocumentKeydown = e => { - if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { - if (e.which == 84) { // t - this.refs.timeline.focus(); - } - } - }; - - this.load = (cb) => { - this.update({ - isLoading: true - }); - - this.api('posts/timeline', { - until_date: this.date ? this.date.getTime() : undefined - }).then(posts => { - this.update({ - isLoading: false, - isEmpty: posts.length == 0 - }); - this.refs.timeline.setPosts(posts); - if (cb) cb(); - }); - }; - - this.more = () => { - if (this.moreLoading || this.isLoading || this.refs.timeline.posts.length == 0) return; - this.update({ - moreLoading: true - }); - this.api('posts/timeline', { - until_id: this.refs.timeline.tail().id - }).then(posts => { - this.update({ - moreLoading: false - }); - this.refs.timeline.prependPosts(posts); - }); - }; - - this.onStreamPost = post => { - this.update({ - isEmpty: false - }); - this.refs.timeline.addPost(post); - }; - - this.onStreamFollow = () => { - this.load(); - }; - - this.onStreamUnfollow = () => { - this.load(); - }; - - this.onScroll = () => { - const current = window.scrollY + window.innerHeight; - if (current > document.body.offsetHeight - 8) this.more(); - }; - - this.warp = date => { - this.update({ - date: date - }); - - this.load(); - }; - </script> -</mk-timeline-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/timemachine.tag b/src/web/app/desktop/tags/home-widgets/timemachine.tag deleted file mode 100644 index 3cddf53551..0000000000 --- a/src/web/app/desktop/tags/home-widgets/timemachine.tag +++ /dev/null @@ -1,23 +0,0 @@ -<mk-timemachine-home-widget> - <mk-calendar-widget design={ data.design } warp={ warp }/> - <style> - :scope - display block - </style> - <script> - this.data = { - design: 0 - }; - - this.mixin('widget'); - - this.warp = date => { - this.opts.tl.warp(date); - }; - - this.func = () => { - if (++this.data.design == 6) this.data.design = 0; - this.save(); - }; - </script> -</mk-timemachine-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/tips.tag b/src/web/app/desktop/tags/home-widgets/tips.tag deleted file mode 100644 index 53b61dca91..0000000000 --- a/src/web/app/desktop/tags/home-widgets/tips.tag +++ /dev/null @@ -1,94 +0,0 @@ -<mk-tips-home-widget> - <p ref="tip">%fa:R lightbulb%<span ref="text"></span></p> - <style> - :scope - display block - 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> - <script> - import anime from 'animejs'; - - this.mixin('widget'); - - this.tips = [ - '<kbd>t</kbd>でタイムラインにフォーカスできます', - '<kbd>p</kbd>または<kbd>n</kbd>で投稿フォームを開きます', - '投稿フォームにはファイルをドラッグ&ドロップできます', - '投稿フォームにクリップボードにある画像データをペーストできます', - 'ドライブにファイルをドラッグ&ドロップしてアップロードできます', - 'ドライブでファイルをドラッグしてフォルダ移動できます', - 'ドライブでフォルダをドラッグしてフォルダ移動できます', - 'ホームは設定からカスタマイズできます', - 'MisskeyはMIT Licenseです', - 'タイムマシンウィジェットを利用すると、簡単に過去のタイムラインに遡れます', - '投稿の ... をクリックして、投稿をユーザーページにピン留めできます', - 'ドライブの容量は(デフォルトで)1GBです', - '投稿に添付したファイルは全てドライブに保存されます', - 'ホームのカスタマイズ中、ウィジェットを右クリックしてデザインを変更できます', - 'タイムライン上部にもウィジェットを設置できます', - '投稿をダブルクリックすると詳細が見れます', - '「**」でテキストを囲むと**強調表示**されます', - 'チャンネルウィジェットを利用すると、よく利用するチャンネルを素早く確認できます', - 'いくつかのウィンドウはブラウザの外に切り離すことができます', - 'カレンダーウィジェットのパーセンテージは、経過の割合を示しています', - 'APIを利用してbotの開発なども行えます', - 'MisskeyはLINEを通じてでも利用できます', - 'まゆかわいいよまゆ', - 'Misskeyは2014年にサービスを開始しました', - '対応ブラウザではMisskeyを開いていなくても通知を受け取れます' - ] - - this.on('mount', () => { - this.set(); - this.clock = setInterval(this.change, 20000); - }); - - this.on('unmount', () => { - clearInterval(this.clock); - }); - - this.set = () => { - this.refs.text.innerHTML = this.tips[Math.floor(Math.random() * this.tips.length)]; - }; - - this.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> -</mk-tips-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/trends.tag b/src/web/app/desktop/tags/home-widgets/trends.tag deleted file mode 100644 index 3a2304111b..0000000000 --- a/src/web/app/desktop/tags/home-widgets/trends.tag +++ /dev/null @@ -1,125 +0,0 @@ -<mk-trends-home-widget> - <virtual if={ !data.compact }> - <p class="title">%fa:fire%%i18n:desktop.tags.mk-trends-home-widget.title%</p> - <button onclick={ fetch } title="%i18n:desktop.tags.mk-trends-home-widget.refresh%">%fa:sync%</button> - </virtual> - <div class="post" if={ !loading && post != null }> - <p class="text"><a href="/{ post.user.username }/{ post.id }">{ post.text }</a></p> - <p class="author">―<a href="/{ post.user.username }">@{ post.user.username }</a></p> - </div> - <p class="empty" if={ !loading && post == null }>%i18n:desktop.tags.mk-trends-home-widget.nothing%</p> - <p class="loading" if={ loading }>%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <style> - :scope - display block - 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 - - > .loading - margin 0 - padding 16px - text-align center - color #aaa - - > [data-fa] - margin-right 4px - - </style> - <script> - this.data = { - compact: false - }; - - this.mixin('widget'); - - this.post = null; - this.loading = true; - - this.offset = 0; - - this.on('mount', () => { - this.fetch(); - }); - - this.fetch = () => { - this.update({ - loading: true, - post: null - }); - this.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.update({ - loading: false, - post: post - }); - }); - }; - - this.func = () => { - this.data.compact = !this.data.compact; - this.save(); - }; - </script> -</mk-trends-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/user-recommendation.tag b/src/web/app/desktop/tags/home-widgets/user-recommendation.tag deleted file mode 100644 index a1af7a5c49..0000000000 --- a/src/web/app/desktop/tags/home-widgets/user-recommendation.tag +++ /dev/null @@ -1,165 +0,0 @@ -<mk-user-recommendation-home-widget> - <virtual if={ !data.compact }> - <p class="title">%fa:users%%i18n:desktop.tags.mk-user-recommendation-home-widget.title%</p> - <button onclick={ refresh } title="%i18n:desktop.tags.mk-user-recommendation-home-widget.refresh%">%fa:sync%</button> - </virtual> - <div class="user" if={ !loading && users.length != 0 } each={ _user in users }> - <a class="avatar-anchor" href={ '/' + _user.username }> - <img class="avatar" src={ _user.avatar_url + '?thumbnail&size=42' } alt="" data-user-preview={ _user.id }/> - </a> - <div class="body"> - <a class="name" href={ '/' + _user.username } data-user-preview={ _user.id }>{ _user.name }</a> - <p class="username">@{ _user.username }</p> - </div> - <mk-follow-button user={ _user }/> - </div> - <p class="empty" if={ !loading && users.length == 0 }>%i18n:desktop.tags.mk-user-recommendation-home-widget.no-one%</p> - <p class="loading" if={ loading }>%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <style> - :scope - display block - 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 - - > .loading - margin 0 - padding 16px - text-align center - color #aaa - - > [data-fa] - margin-right 4px - - </style> - <script> - this.data = { - compact: false - }; - - this.mixin('widget'); - this.mixin('user-preview'); - - this.users = null; - this.loading = true; - - this.limit = 3; - this.page = 0; - - this.on('mount', () => { - this.fetch(); - }); - - this.fetch = () => { - this.update({ - loading: true, - users: null - }); - this.api('users/recommendation', { - limit: this.limit, - offset: this.limit * this.page - }).then(users => { - this.update({ - loading: false, - users: users - }); - }); - }; - - this.refresh = () => { - if (this.users.length < this.limit) { - this.page = 0; - } else { - this.page++; - } - this.fetch(); - }; - - this.func = () => { - this.data.compact = !this.data.compact; - this.save(); - }; - </script> -</mk-user-recommendation-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/version.tag b/src/web/app/desktop/tags/home-widgets/version.tag deleted file mode 100644 index 2b66b0490e..0000000000 --- a/src/web/app/desktop/tags/home-widgets/version.tag +++ /dev/null @@ -1,20 +0,0 @@ -<mk-version-home-widget> - <p>ver { _VERSION_ } (葵 aoi)</p> - <style> - :scope - display block - overflow visible !important - - > p - display block - margin 0 - padding 0 12px - text-align center - font-size 0.7em - color #aaa - - </style> - <script> - this.mixin('widget'); - </script> -</mk-version-home-widget> diff --git a/src/web/app/desktop/tags/home.tag b/src/web/app/desktop/tags/home.tag deleted file mode 100644 index 50f6c84604..0000000000 --- a/src/web/app/desktop/tags/home.tag +++ /dev/null @@ -1,388 +0,0 @@ -<mk-home data-customize={ opts.customize }> - <div class="customize" if={ opts.customize }> - <a href="/">%fa:check%完了</a> - <div> - <div class="adder"> - <p>ウィジェットを追加:</p> - <select ref="widgetSelector"> - <option value="profile">プロフィール</option> - <option value="calendar">カレンダー</option> - <option value="timemachine">カレンダー(タイムマシン)</option> - <option value="activity">アクティビティ</option> - <option value="rss-reader">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="user-recommendation">おすすめユーザー</option> - <option value="recommended-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 onclick={ addWidget }>追加</button> - </div> - <div class="trash"> - <div ref="trash"></div> - <p>ゴミ箱</p> - </div> - </div> - </div> - <div class="main"> - <div class="left"> - <div ref="left" data-place="left"></div> - </div> - <main ref="main"> - <div class="maintop" ref="maintop" data-place="main" if={ opts.customize }></div> - <mk-timeline-home-widget ref="tl" if={ mode == 'timeline' }/> - <mk-mentions-home-widget ref="tl" if={ mode == 'mentions' }/> - </main> - <div class="right"> - <div ref="right" data-place="right"></div> - </div> - </div> - <style> - :scope - display block - - &[data-customize] - padding-top 48px - background-image url('/assets/desktop/grid.svg') - - > .main > main > *:not(.maintop) - cursor not-allowed - - > * - 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 - - > * - pointer-events none - - > main - padding 16px - width calc(100% - 275px * 2) - - > *:not(.maintop):not(:last-child) - > .maintop > *:not(:last-child) - margin-bottom 16px - - > .maintop - min-height 64px - margin-bottom 16px - - > *:not(main) - width 275px - - > * - padding 16px 0 16px 0 - - > *:not(:last-child) - margin-bottom 16px - - > .left - padding-left 16px - - > .right - padding-right 16px - - @media (max-width 1100px) - > *:not(main) - display none - - > main - float none - width 100% - max-width 700px - margin 0 auto - - </style> - <script> - import uuid from 'uuid'; - import Sortable from 'sortablejs'; - import dialog from '../scripts/dialog'; - import ScrollFollower from '../scripts/scroll-follower'; - - this.mixin('i'); - this.mixin('api'); - - this.mode = this.opts.mode || 'timeline'; - - this.home = []; - - this.bakeHomeData = () => JSON.stringify(this.I.client_settings.home); - this.bakedHomeData = this.bakeHomeData(); - - this.on('mount', () => { - this.refs.tl.on('loaded', () => { - this.trigger('loaded'); - }); - - this.I.on('refreshed', this.onMeRefreshed); - - this.I.client_settings.home.forEach(widget => { - try { - this.setWidget(widget); - } catch (e) { - // noop - } - }); - - if (!this.opts.customize) { - if (this.refs.left.children.length == 0) { - this.refs.left.parentNode.removeChild(this.refs.left); - } - if (this.refs.right.children.length == 0) { - this.refs.right.parentNode.removeChild(this.refs.right); - } - } - - if (this.opts.customize) { - dialog('%fa:info-circle%カスタマイズのヒント', - '<p>ホームのカスタマイズでは、ウィジェットを追加/削除したり、ドラッグ&ドロップして並べ替えたりすることができます。</p>' + - '<p>一部のウィジェットは、<strong><strong>右</strong>クリック</strong>することで表示を変更することができます。</p>' + - '<p>ウィジェットを削除するには、ヘッダーの<strong>「ゴミ箱」</strong>と書かれたエリアにウィジェットをドラッグ&ドロップします。</p>' + - '<p>カスタマイズを終了するには、右上の「完了」をクリックします。</p>', - [{ - text: 'Got it!' - }]); - - const sortableOption = { - group: 'kyoppie', - animation: 150, - onMove: evt => { - const id = evt.dragged.getAttribute('data-widget-id'); - this.home.find(tag => tag.id == id).update({ place: evt.to.getAttribute('data-place') }); - }, - onSort: () => { - this.saveHome(); - } - }; - - new Sortable(this.refs.left, sortableOption); - new Sortable(this.refs.right, sortableOption); - new Sortable(this.refs.maintop, sortableOption); - new Sortable(this.refs.trash, Object.assign({}, sortableOption, { - onAdd: evt => { - const el = evt.item; - const id = el.getAttribute('data-widget-id'); - el.parentNode.removeChild(el); - this.I.client_settings.home = this.I.client_settings.home.filter(w => w.id != id); - this.saveHome(); - } - })); - } - - if (!this.opts.customize) { - this.scrollFollowerLeft = this.refs.left.parentNode ? new ScrollFollower(this.refs.left, this.root.getBoundingClientRect().top) : null; - this.scrollFollowerRight = this.refs.right.parentNode ? new ScrollFollower(this.refs.right, this.root.getBoundingClientRect().top) : null; - } - }); - - this.on('unmount', () => { - this.I.off('refreshed', this.onMeRefreshed); - - this.home.forEach(widget => { - widget.unmount(); - }); - - if (!this.opts.customize) { - if (this.scrollFollowerLeft) this.scrollFollowerLeft.dispose(); - if (this.scrollFollowerRight) this.scrollFollowerRight.dispose(); - } - }); - - this.onMeRefreshed = () => { - if (this.bakedHomeData != this.bakeHomeData()) { - alert('別の場所でホームが編集されました。ページを再度読み込みすると編集が反映されます。'); - } - }; - - this.setWidget = (widget, prepend = false) => { - const el = document.createElement(`mk-${widget.name}-home-widget`); - - let actualEl; - - if (this.opts.customize) { - const container = document.createElement('div'); - container.classList.add('customize-container'); - container.setAttribute('data-widget-id', widget.id); - container.appendChild(el); - actualEl = container; - } else { - actualEl = el; - } - - switch (widget.place) { - case 'left': - if (prepend) { - this.refs.left.insertBefore(actualEl, this.refs.left.firstChild); - } else { - this.refs.left.appendChild(actualEl); - } - break; - case 'right': - if (prepend) { - this.refs.right.insertBefore(actualEl, this.refs.right.firstChild); - } else { - this.refs.right.appendChild(actualEl); - } - break; - case 'main': - if (this.opts.customize) { - this.refs.maintop.appendChild(actualEl); - } else { - this.refs.main.insertBefore(actualEl, this.refs.tl.root); - } - break; - } - - const tag = riot.mount(el, { - id: widget.id, - data: widget.data, - place: widget.place, - tl: this.refs.tl - })[0]; - - this.home.push(tag); - - if (this.opts.customize) { - actualEl.oncontextmenu = e => { - e.preventDefault(); - e.stopImmediatePropagation(); - if (tag.func) tag.func(); - return false; - }; - } - }; - - this.addWidget = () => { - const widget = { - name: this.refs.widgetSelector.options[this.refs.widgetSelector.selectedIndex].value, - id: uuid(), - place: 'left', - data: {} - }; - - this.I.client_settings.home.unshift(widget); - - this.setWidget(widget, true); - - this.saveHome(); - }; - - this.saveHome = () => { - const data = []; - - Array.from(this.refs.left.children).forEach(el => { - const id = el.getAttribute('data-widget-id'); - const widget = this.I.client_settings.home.find(w => w.id == id); - widget.place = 'left'; - data.push(widget); - }); - - Array.from(this.refs.right.children).forEach(el => { - const id = el.getAttribute('data-widget-id'); - const widget = this.I.client_settings.home.find(w => w.id == id); - widget.place = 'right'; - data.push(widget); - }); - - Array.from(this.refs.maintop.children).forEach(el => { - const id = el.getAttribute('data-widget-id'); - const widget = this.I.client_settings.home.find(w => w.id == id); - widget.place = 'main'; - data.push(widget); - }); - - this.api('i/update_home', { - home: data - }).then(() => { - this.I.update(); - }); - }; - </script> -</mk-home> diff --git a/src/web/app/desktop/tags/images.tag b/src/web/app/desktop/tags/images.tag deleted file mode 100644 index 0cd408576f..0000000000 --- a/src/web/app/desktop/tags/images.tag +++ /dev/null @@ -1,172 +0,0 @@ -<mk-images> - <virtual each={ image in images }> - <mk-images-image image={ image }/> - </virtual> - <style> - :scope - display grid - grid-gap 4px - height 256px - </style> - <script> - this.images = this.opts.images; - - this.on('mount', () => { - if (this.images.length == 1) { - this.root.style.gridTemplateRows = '1fr'; - - this.tags['mk-images-image'].root.style.gridColumn = '1 / 2'; - this.tags['mk-images-image'].root.style.gridRow = '1 / 2'; - } else if (this.images.length == 2) { - this.root.style.gridTemplateColumns = '1fr 1fr'; - this.root.style.gridTemplateRows = '1fr'; - - this.tags['mk-images-image'][0].root.style.gridColumn = '1 / 2'; - this.tags['mk-images-image'][0].root.style.gridRow = '1 / 2'; - this.tags['mk-images-image'][1].root.style.gridColumn = '2 / 3'; - this.tags['mk-images-image'][1].root.style.gridRow = '1 / 2'; - } else if (this.images.length == 3) { - this.root.style.gridTemplateColumns = '1fr 0.5fr'; - this.root.style.gridTemplateRows = '1fr 1fr'; - - this.tags['mk-images-image'][0].root.style.gridColumn = '1 / 2'; - this.tags['mk-images-image'][0].root.style.gridRow = '1 / 3'; - this.tags['mk-images-image'][1].root.style.gridColumn = '2 / 3'; - this.tags['mk-images-image'][1].root.style.gridRow = '1 / 2'; - this.tags['mk-images-image'][2].root.style.gridColumn = '2 / 3'; - this.tags['mk-images-image'][2].root.style.gridRow = '2 / 3'; - } else if (this.images.length == 4) { - this.root.style.gridTemplateColumns = '1fr 1fr'; - this.root.style.gridTemplateRows = '1fr 1fr'; - - this.tags['mk-images-image'][0].root.style.gridColumn = '1 / 2'; - this.tags['mk-images-image'][0].root.style.gridRow = '1 / 2'; - this.tags['mk-images-image'][1].root.style.gridColumn = '2 / 3'; - this.tags['mk-images-image'][1].root.style.gridRow = '1 / 2'; - this.tags['mk-images-image'][2].root.style.gridColumn = '1 / 2'; - this.tags['mk-images-image'][2].root.style.gridRow = '2 / 3'; - this.tags['mk-images-image'][3].root.style.gridColumn = '2 / 3'; - this.tags['mk-images-image'][3].root.style.gridRow = '2 / 3'; - } - }); - </script> -</mk-images> - -<mk-images-image> - <a ref="view" - href={ image.url } - onmousemove={ mousemove } - onmouseleave={ mouseleave } - style={ styles } - onclick={ click } - title={ image.name }></a> - <style> - :scope - display block - overflow hidden - border-radius 4px - - > a - display block - cursor zoom-in - overflow hidden - width 100% - height 100% - background-position center - - &:not(:hover) - background-size cover - - </style> - <script> - this.image = this.opts.image; - this.styles = { - 'background-color': this.image.properties.average_color ? `rgb(${this.image.properties.average_color.join(',')})` : 'transparent', - 'background-image': `url(${this.image.url}?thumbnail&size=512)` - }; - - this.mousemove = e => { - const rect = this.refs.view.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - const xp = mouseX / this.refs.view.offsetWidth * 100; - const yp = mouseY / this.refs.view.offsetHeight * 100; - this.refs.view.style.backgroundPosition = xp + '% ' + yp + '%'; - this.refs.view.style.backgroundImage = 'url("' + this.image.url + '?thumbnail")'; - }; - - this.mouseleave = () => { - this.refs.view.style.backgroundPosition = ''; - }; - - this.click = ev => { - ev.preventDefault(); - riot.mount(document.body.appendChild(document.createElement('mk-image-dialog')), { - image: this.image - }); - return false; - }; - </script> -</mk-images-image> - -<mk-image-dialog> - <div class="bg" ref="bg" onclick={ close }></div><img ref="img" src={ image.url } alt={ image.name } title={ image.name } onclick={ close }/> - <style> - :scope - 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> - <script> - import anime from 'animejs'; - - this.image = this.opts.image; - - this.on('mount', () => { - anime({ - targets: this.root, - opacity: 1, - duration: 100, - easing: 'linear' - }); - }); - - this.close = () => { - anime({ - targets: this.root, - opacity: 0, - duration: 100, - easing: 'linear', - complete: () => this.unmount() - }); - }; - </script> -</mk-image-dialog> diff --git a/src/web/app/desktop/tags/index.ts b/src/web/app/desktop/tags/index.ts deleted file mode 100644 index 4edda83534..0000000000 --- a/src/web/app/desktop/tags/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -require('./contextmenu.tag'); -require('./dialog.tag'); -require('./window.tag'); -require('./input-dialog.tag'); -require('./follow-button.tag'); -require('./drive/base-contextmenu.tag'); -require('./drive/file-contextmenu.tag'); -require('./drive/folder-contextmenu.tag'); -require('./drive/file.tag'); -require('./drive/folder.tag'); -require('./drive/nav-folder.tag'); -require('./drive/browser-window.tag'); -require('./drive/browser.tag'); -require('./select-file-from-drive-window.tag'); -require('./select-folder-from-drive-window.tag'); -require('./crop-window.tag'); -require('./settings.tag'); -require('./settings-window.tag'); -require('./analog-clock.tag'); -require('./notifications.tag'); -require('./post-form-window.tag'); -require('./post-form.tag'); -require('./post-preview.tag'); -require('./repost-form-window.tag'); -require('./home-widgets/user-recommendation.tag'); -require('./home-widgets/timeline.tag'); -require('./home-widgets/mentions.tag'); -require('./home-widgets/calendar.tag'); -require('./home-widgets/donation.tag'); -require('./home-widgets/tips.tag'); -require('./home-widgets/nav.tag'); -require('./home-widgets/profile.tag'); -require('./home-widgets/notifications.tag'); -require('./home-widgets/rss-reader.tag'); -require('./home-widgets/photo-stream.tag'); -require('./home-widgets/broadcast.tag'); -require('./home-widgets/version.tag'); -require('./home-widgets/recommended-polls.tag'); -require('./home-widgets/trends.tag'); -require('./home-widgets/activity.tag'); -require('./home-widgets/server.tag'); -require('./home-widgets/slideshow.tag'); -require('./home-widgets/channel.tag'); -require('./home-widgets/timemachine.tag'); -require('./home-widgets/post-form.tag'); -require('./home-widgets/access-log.tag'); -require('./home-widgets/messaging.tag'); -require('./timeline.tag'); -require('./messaging/window.tag'); -require('./messaging/room-window.tag'); -require('./following-setuper.tag'); -require('./ellipsis-icon.tag'); -require('./ui.tag'); -require('./home.tag'); -require('./user-timeline.tag'); -require('./user.tag'); -require('./big-follow-button.tag'); -require('./pages/entrance.tag'); -require('./pages/home.tag'); -require('./pages/home-customize.tag'); -require('./pages/user.tag'); -require('./pages/post.tag'); -require('./pages/search.tag'); -require('./pages/not-found.tag'); -require('./pages/selectdrive.tag'); -require('./pages/drive.tag'); -require('./pages/messaging-room.tag'); -require('./autocomplete-suggestion.tag'); -require('./progress-dialog.tag'); -require('./user-preview.tag'); -require('./post-detail.tag'); -require('./post-detail-sub.tag'); -require('./search.tag'); -require('./search-posts.tag'); -require('./set-avatar-suggestion.tag'); -require('./set-banner-suggestion.tag'); -require('./repost-form.tag'); -require('./sub-post-content.tag'); -require('./images.tag'); -require('./donation.tag'); -require('./users-list.tag'); -require('./user-following.tag'); -require('./user-followers.tag'); -require('./user-following-window.tag'); -require('./user-followers-window.tag'); -require('./list-user.tag'); -require('./detailed-post-window.tag'); -require('./widgets/calendar.tag'); -require('./widgets/activity.tag'); diff --git a/src/web/app/desktop/tags/input-dialog.tag b/src/web/app/desktop/tags/input-dialog.tag deleted file mode 100644 index f175277547..0000000000 --- a/src/web/app/desktop/tags/input-dialog.tag +++ /dev/null @@ -1,172 +0,0 @@ -<mk-input-dialog> - <mk-window ref="window" is-modal={ true } width={ '500px' }> - <yield to="header"> - %fa:i-cursor%{ parent.title } - </yield> - <yield to="content"> - <div class="body"> - <input ref="text" type={ parent.type } oninput={ parent.onInput } onkeydown={ parent.onKeydown } placeholder={ parent.placeholder }/> - </div> - <div class="action"> - <button class="cancel" onclick={ parent.cancel }>キャンセル</button> - <button class="ok" disabled={ !parent.allowEmpty && refs.text.value.length == 0 } onclick={ parent.ok }>決定</button> - </div> - </yield> - </mk-window> - <style> - :scope - display block - - > mk-window - [data-yield='header'] - > [data-fa] - margin-right 4px - - [data-yield='content'] - > .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) - - > .action - 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> - <script> - this.done = false; - - this.title = this.opts.title; - this.placeholder = this.opts.placeholder; - this.default = this.opts.default; - this.allowEmpty = this.opts.allowEmpty != null ? this.opts.allowEmpty : true; - this.type = this.opts.type ? this.opts.type : 'text'; - - this.on('mount', () => { - this.text = this.refs.window.refs.text; - if (this.default) this.text.value = this.default; - this.text.focus(); - - this.refs.window.on('closing', () => { - if (this.done) { - this.opts.onOk(this.text.value); - } else { - if (this.opts.onCancel) this.opts.onCancel(); - } - }); - - this.refs.window.on('closed', () => { - this.unmount(); - }); - }); - - this.cancel = () => { - this.done = false; - this.refs.window.close(); - }; - - this.ok = () => { - if (!this.allowEmpty && this.text.value == '') return; - this.done = true; - this.refs.window.close(); - }; - - this.onInput = () => { - this.update(); - }; - - this.onKeydown = e => { - if (e.which == 13) { // Enter - e.preventDefault(); - e.stopPropagation(); - this.ok(); - } - }; - </script> -</mk-input-dialog> diff --git a/src/web/app/desktop/tags/list-user.tag b/src/web/app/desktop/tags/list-user.tag deleted file mode 100644 index 91a6de0a0d..0000000000 --- a/src/web/app/desktop/tags/list-user.tag +++ /dev/null @@ -1,93 +0,0 @@ -<mk-list-user> - <a class="avatar-anchor" href={ '/' + user.username }> - <img class="avatar" src={ user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <div class="main"> - <header> - <a class="name" href={ '/' + user.username }>{ user.name }</a> - <span class="username">@{ user.username }</span> - </header> - <div class="body"> - <p class="followed" if={ user.is_followed }>フォローされています</p> - <div class="description">{ user.description }</div> - </div> - </div> - <mk-follow-button user={ user }/> - <style> - :scope - display block - margin 0 - 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> - <script>this.user = this.opts.user</script> -</mk-list-user> diff --git a/src/web/app/desktop/tags/messaging/room-window.tag b/src/web/app/desktop/tags/messaging/room-window.tag deleted file mode 100644 index 7c0bb0d76e..0000000000 --- a/src/web/app/desktop/tags/messaging/room-window.tag +++ /dev/null @@ -1,32 +0,0 @@ -<mk-messaging-room-window> - <mk-window ref="window" is-modal={ false } width={ '500px' } height={ '560px' } popout={ popout }> - <yield to="header">%fa:comments%メッセージ: { parent.user.name }</yield> - <yield to="content"> - <mk-messaging-room user={ parent.user }/> - </yield> - </mk-window> - <style> - :scope - > mk-window - [data-yield='header'] - > [data-fa] - margin-right 4px - - [data-yield='content'] - > mk-messaging-room - height 100% - overflow auto - - </style> - <script> - this.user = this.opts.user; - - this.popout = `${_URL_}/i/messaging/${this.user.username}`; - - this.on('mount', () => { - this.refs.window.on('closed', () => { - this.unmount(); - }); - }); - </script> -</mk-messaging-room-window> diff --git a/src/web/app/desktop/tags/messaging/window.tag b/src/web/app/desktop/tags/messaging/window.tag deleted file mode 100644 index 529db11af1..0000000000 --- a/src/web/app/desktop/tags/messaging/window.tag +++ /dev/null @@ -1,34 +0,0 @@ -<mk-messaging-window> - <mk-window ref="window" is-modal={ false } width={ '500px' } height={ '560px' }> - <yield to="header">%fa:comments%メッセージ</yield> - <yield to="content"> - <mk-messaging ref="index"/> - </yield> - </mk-window> - <style> - :scope - > mk-window - [data-yield='header'] - > [data-fa] - margin-right 4px - - [data-yield='content'] - > mk-messaging - height 100% - overflow auto - - </style> - <script> - this.on('mount', () => { - this.refs.window.on('closed', () => { - this.unmount(); - }); - - this.refs.window.refs.index.on('navigate-user', user => { - riot.mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), { - user: user - }); - }); - }); - </script> -</mk-messaging-window> diff --git a/src/web/app/desktop/tags/notifications.tag b/src/web/app/desktop/tags/notifications.tag deleted file mode 100644 index 39862487e9..0000000000 --- a/src/web/app/desktop/tags/notifications.tag +++ /dev/null @@ -1,301 +0,0 @@ -<mk-notifications> - <div class="notifications" if={ notifications.length != 0 }> - <virtual each={ notification, i in notifications }> - <div class="notification { notification.type }"> - <mk-time time={ notification.created_at }/> - <virtual if={ notification.type == 'reaction' }> - <a class="avatar-anchor" href={ '/' + notification.user.username } data-user-preview={ notification.user.id }> - <img class="avatar" src={ notification.user.avatar_url + '?thumbnail&size=48' } alt="avatar"/> - </a> - <div class="text"> - <p><mk-reaction-icon reaction={ notification.reaction }/><a href={ '/' + notification.user.username } data-user-preview={ notification.user.id }>{ notification.user.name }</a></p> - <a class="post-ref" href={ '/' + notification.post.user.username + '/' + notification.post.id }> - %fa:quote-left%{ getPostSummary(notification.post) }%fa:quote-right% - </a> - </div> - </virtual> - <virtual if={ notification.type == 'repost' }> - <a class="avatar-anchor" href={ '/' + notification.post.user.username } data-user-preview={ notification.post.user_id }> - <img class="avatar" src={ notification.post.user.avatar_url + '?thumbnail&size=48' } alt="avatar"/> - </a> - <div class="text"> - <p>%fa:retweet%<a href={ '/' + notification.post.user.username } data-user-preview={ notification.post.user_id }>{ notification.post.user.name }</a></p> - <a class="post-ref" href={ '/' + notification.post.user.username + '/' + notification.post.id }> - %fa:quote-left%{ getPostSummary(notification.post.repost) }%fa:quote-right% - </a> - </div> - </virtual> - <virtual if={ notification.type == 'quote' }> - <a class="avatar-anchor" href={ '/' + notification.post.user.username } data-user-preview={ notification.post.user_id }> - <img class="avatar" src={ notification.post.user.avatar_url + '?thumbnail&size=48' } alt="avatar"/> - </a> - <div class="text"> - <p>%fa:quote-left%<a href={ '/' + notification.post.user.username } data-user-preview={ notification.post.user_id }>{ notification.post.user.name }</a></p> - <a class="post-preview" href={ '/' + notification.post.user.username + '/' + notification.post.id }>{ getPostSummary(notification.post) }</a> - </div> - </virtual> - <virtual if={ notification.type == 'follow' }> - <a class="avatar-anchor" href={ '/' + notification.user.username } data-user-preview={ notification.user.id }> - <img class="avatar" src={ notification.user.avatar_url + '?thumbnail&size=48' } alt="avatar"/> - </a> - <div class="text"> - <p>%fa:user-plus%<a href={ '/' + notification.user.username } data-user-preview={ notification.user.id }>{ notification.user.name }</a></p> - </div> - </virtual> - <virtual if={ notification.type == 'reply' }> - <a class="avatar-anchor" href={ '/' + notification.post.user.username } data-user-preview={ notification.post.user_id }> - <img class="avatar" src={ notification.post.user.avatar_url + '?thumbnail&size=48' } alt="avatar"/> - </a> - <div class="text"> - <p>%fa:reply%<a href={ '/' + notification.post.user.username } data-user-preview={ notification.post.user_id }>{ notification.post.user.name }</a></p> - <a class="post-preview" href={ '/' + notification.post.user.username + '/' + notification.post.id }>{ getPostSummary(notification.post) }</a> - </div> - </virtual> - <virtual if={ notification.type == 'mention' }> - <a class="avatar-anchor" href={ '/' + notification.post.user.username } data-user-preview={ notification.post.user_id }> - <img class="avatar" src={ notification.post.user.avatar_url + '?thumbnail&size=48' } alt="avatar"/> - </a> - <div class="text"> - <p>%fa:at%<a href={ '/' + notification.post.user.username } data-user-preview={ notification.post.user_id }>{ notification.post.user.name }</a></p> - <a class="post-preview" href={ '/' + notification.post.user.username + '/' + notification.post.id }>{ getPostSummary(notification.post) }</a> - </div> - </virtual> - <virtual if={ notification.type == 'poll_vote' }> - <a class="avatar-anchor" href={ '/' + notification.user.username } data-user-preview={ notification.user.id }> - <img class="avatar" src={ notification.user.avatar_url + '?thumbnail&size=48' } alt="avatar"/> - </a> - <div class="text"> - <p>%fa:chart-pie%<a href={ '/' + notification.user.username } data-user-preview={ notification.user.id }>{ notification.user.name }</a></p> - <a class="post-ref" href={ '/' + notification.post.user.username + '/' + notification.post.id }> - %fa:quote-left%{ getPostSummary(notification.post) }%fa:quote-right% - </a> - </div> - </virtual> - </div> - <p class="date" 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> - </virtual> - </div> - <button class="more { fetching: fetchingMoreNotifications }" if={ moreNotifications } onclick={ fetchMoreNotifications } disabled={ fetchingMoreNotifications }> - <virtual if={ fetchingMoreNotifications }>%fa:spinner .pulse .fw%</virtual>{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:desktop.tags.mk-notifications.more%' } - </button> - <p class="empty" if={ notifications.length == 0 && !loading }>ありません!</p> - <p class="loading" if={ loading }>%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <style> - :scope - display block - - > .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> - <script> - import getPostSummary from '../../../../common/get-post-summary.ts'; - this.getPostSummary = getPostSummary; - - this.mixin('i'); - this.mixin('api'); - this.mixin('user-preview'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.notifications = []; - this.loading = true; - - this.on('mount', () => { - const max = 10; - - this.api('i/notifications', { - limit: max + 1 - }).then(notifications => { - if (notifications.length == max + 1) { - this.moreNotifications = true; - notifications.pop(); - } - - this.update({ - loading: false, - notifications: notifications - }); - }); - - this.connection.on('notification', this.onNotification); - }); - - this.on('unmount', () => { - this.connection.off('notification', this.onNotification); - this.stream.dispose(this.connectionId); - }); - - this.on('update', () => { - this.notifications.forEach(notification => { - const date = new Date(notification.created_at).getDate(); - const month = new Date(notification.created_at).getMonth() + 1; - notification._date = date; - notification._datetext = `${month}月 ${date}日`; - }); - }); - - this.onNotification = notification => { - // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.connection.send({ - type: 'read_notification', - id: notification.id - }); - - this.notifications.unshift(notification); - this.update(); - }; - - this.fetchMoreNotifications = () => { - this.update({ - fetchingMoreNotifications: true - }); - - const max = 30; - - this.api('i/notifications', { - limit: max + 1, - until_id: this.notifications[this.notifications.length - 1].id - }).then(notifications => { - if (notifications.length == max + 1) { - this.moreNotifications = true; - notifications.pop(); - } else { - this.moreNotifications = false; - } - this.update({ - notifications: this.notifications.concat(notifications), - fetchingMoreNotifications: false - }); - }); - }; - </script> -</mk-notifications> diff --git a/src/web/app/desktop/tags/pages/drive.tag b/src/web/app/desktop/tags/pages/drive.tag deleted file mode 100644 index 9f3e75ab21..0000000000 --- a/src/web/app/desktop/tags/pages/drive.tag +++ /dev/null @@ -1,37 +0,0 @@ -<mk-drive-page> - <mk-drive-browser ref="browser" folder={ opts.folder }/> - <style> - :scope - display block - position fixed - width 100% - height 100% - background #fff - - > mk-drive-browser - height 100% - </style> - <script> - this.on('mount', () => { - document.title = 'Misskey Drive'; - - this.refs.browser.on('move-root', () => { - const title = 'Misskey Drive'; - - // Rewrite URL - history.pushState(null, title, '/i/drive'); - - document.title = title; - }); - - this.refs.browser.on('open-folder', folder => { - const title = folder.name + ' | Misskey Drive'; - - // Rewrite URL - history.pushState(null, title, '/i/drive/folder/' + folder.id); - - document.title = title; - }); - }); - </script> -</mk-drive-page> diff --git a/src/web/app/desktop/tags/pages/entrance.tag b/src/web/app/desktop/tags/pages/entrance.tag deleted file mode 100644 index 974f49a4fe..0000000000 --- a/src/web/app/desktop/tags/pages/entrance.tag +++ /dev/null @@ -1,342 +0,0 @@ -<mk-entrance> - <main> - <div> - <h1>どこにいても、ここにあります</h1> - <p>ようこそ! MisskeyはTwitter風ミニブログSNSです――思ったこと、共有したいことをシンプルに書き残せます。タイムラインを見れば、皆の反応や皆がどう思っているのかもすぐにわかります。</p> - <p if={ stats }>これまでに{ stats.posts_count }投稿されました</p> - </div> - <div> - <mk-entrance-signin if={ mode == 'signin' }/> - <mk-entrance-signup if={ mode == 'signup' }/> - <div class="introduction" if={ mode == 'introduction' }> - <mk-introduction/> - <button onclick={ signin }>わかった</button> - </div> - </div> - </main> - <mk-forkit/> - <footer> - <div> - <mk-nav-links/> - <p class="c">{ _COPYRIGHT_ }</p> - </div> - </footer> - <!-- ↓ https://github.com/riot/riot/issues/2134 (将来的)--> - <style data-disable-scope="data-disable-scope"> - #wait { - right: auto; - left: 15px; - } - </style> - <style> - :scope - $width = 1000px - - display block - - &:before - content "" - display block - position fixed - width 100% - height 100% - background rgba(0, 0, 0, 0.3) - - > main - display block - max-width $width - margin 0 auto - padding 64px 0 0 0 - padding-bottom 16px - - &:after - content "" - display block - clear both - - > div:first-child - position absolute - top 64px - left 0 - width calc(100% - 500px) - color #fff - text-shadow 0 0 32px rgba(0, 0, 0, 0.5) - font-weight bold - - > p:last-child - padding 1em 0 0 0 - border-top solid 1px #fff - - > div:last-child - float right - - > .introduction - max-width 360px - margin 0 auto - color #777 - - > mk-introduction - padding 32px - background #fff - box-shadow 0 4px 16px rgba(0, 0, 0, 0.2) - - > button - display block - margin 16px auto 0 auto - color #666 - - &:hover - text-decoration underline - - > footer - * - color #fff !important - text-shadow 0 0 8px #000 - font-weight bold - - > div - max-width $width - margin 0 auto - padding 16px 0 - text-align center - border-top solid 1px #fff - - > .c - margin 0 - line-height 64px - font-size 10px - - </style> - <script> - this.mixin('api'); - - this.mode = 'signin'; - - this.on('mount', () => { - document.documentElement.style.backgroundColor = '#444'; - - this.api('meta').then(meta => { - const img = meta.top_image ? meta.top_image : '/assets/desktop/index.jpg'; - document.documentElement.style.backgroundImage = `url("${ img }")`; - document.documentElement.style.backgroundSize = 'cover'; - document.documentElement.style.backgroundPosition = 'center'; - }); - - this.api('stats').then(stats => { - this.update({ - stats - }); - }); - }); - - this.signup = () => { - this.update({ - mode: 'signup' - }); - }; - - this.signin = () => { - this.update({ - mode: 'signin' - }); - }; - - this.introduction = () => { - this.update({ - mode: 'introduction' - }); - }; - </script> -</mk-entrance> - -<mk-entrance-signin> - <a class="help" href={ _DOCS_URL_ + '/help' } title="お困りですか?">%fa:question%</a> - <div class="form"> - <h1><img if={ user } src={ user.avatar_url + '?thumbnail&size=32' }/> - <p>{ user ? user.name : 'アカウント' }</p> - </h1> - <mk-signin ref="signin"/> - </div> - <a href={ _API_URL_ + '/signin/twitter' }>Twitterでサインイン</a> - <div class="divider"><span>or</span></div> - <button class="signup" onclick={ parent.signup }>新規登録</button><a class="introduction" onclick={ introduction }>Misskeyについて</a> - <style> - :scope - display block - width 290px - margin 0 auto - text-align center - - &:hover - > .help - opacity 1 - - > .help - 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 - opacity 0 - transition opacity 0.1s ease - - &:hover - color #444 - - &:active - color #222 - - > [data-fa] - padding 14px - - > .form - padding 10px 28px 16px 28px - background #fff - box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) - - > h1 - display block - margin 0 - padding 0 - height 54px - line-height 54px - text-align center - text-transform uppercase - font-size 1em - font-weight bold - color rgba(0, 0, 0, 0.5) - border-bottom solid 1px rgba(0, 0, 0, 0.1) - - > p - display inline - margin 0 - padding 0 - - > img - display inline-block - top 10px - width 32px - height 32px - margin-right 8px - border-radius 100% - - &[src=''] - display none - - > .divider - padding 16px 0 - text-align center - - &:before - &:after - content "" - display block - position absolute - top 50% - width 45% - height 1px - border-top solid 1px rgba(0, 0, 0, 0.1) - - &:before - left 0 - - &:after - right 0 - - > * - z-index 1 - padding 0 8px - color #fff - text-shadow 0 0 8px rgba(0, 0, 0, 0.5) - - > .signup - width 100% - line-height 56px - font-size 1em - color #fff - background $theme-color - border-radius 64px - - &:hover - background lighten($theme-color, 5%) - - &:active - background darken($theme-color, 5%) - - > .introduction - display inline-block - margin-top 16px - font-size 12px - color #666 - - </style> - <script> - this.on('mount', () => { - this.refs.signin.on('user', user => { - this.update({ - user: user - }); - }); - }); - - this.introduction = () => { - this.parent.introduction(); - }; - </script> -</mk-entrance-signin> - -<mk-entrance-signup> - <mk-signup/> - <button class="cancel" type="button" onclick={ parent.signin } title="キャンセル">%fa:times%</button> - <style> - :scope - display block - width 368px - margin 0 auto - - &:hover - > .cancel - opacity 1 - - > mk-signup - padding 18px 32px 0 32px - background #fff - box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) - - > .cancel - 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 - box-shadow none - background transparent - opacity 0 - transition opacity 0.1s ease - - &:hover - color #555 - - &:active - color #222 - - > [data-fa] - padding 14px - - </style> -</mk-entrance-signup> diff --git a/src/web/app/desktop/tags/pages/home-customize.tag b/src/web/app/desktop/tags/pages/home-customize.tag deleted file mode 100644 index 457b8390e7..0000000000 --- a/src/web/app/desktop/tags/pages/home-customize.tag +++ /dev/null @@ -1,12 +0,0 @@ -<mk-home-customize-page> - <mk-home ref="home" mode="timeline" customize={ true }/> - <style> - :scope - display block - </style> - <script> - this.on('mount', () => { - document.title = 'Misskey - ホームのカスタマイズ'; - }); - </script> -</mk-home-customize-page> diff --git a/src/web/app/desktop/tags/pages/home.tag b/src/web/app/desktop/tags/pages/home.tag deleted file mode 100644 index 3c8f4ec570..0000000000 --- a/src/web/app/desktop/tags/pages/home.tag +++ /dev/null @@ -1,54 +0,0 @@ -<mk-home-page> - <mk-ui ref="ui" page={ page }> - <mk-home ref="home" mode={ parent.opts.mode }/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import Progress from '../../../common/scripts/loading'; - import getPostSummary from '../../../../../common/get-post-summary.ts'; - - this.mixin('i'); - this.mixin('api'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.unreadCount = 0; - this.page = this.opts.mode || 'timeline'; - - this.on('mount', () => { - this.refs.ui.refs.home.on('loaded', () => { - Progress.done(); - }); - document.title = 'Misskey'; - Progress.start(); - - this.connection.on('post', this.onStreamPost); - document.addEventListener('visibilitychange', this.windowOnVisibilitychange, false); - }); - - this.on('unmount', () => { - this.connection.off('post', this.onStreamPost); - this.stream.dispose(this.connectionId); - document.removeEventListener('visibilitychange', this.windowOnVisibilitychange); - }); - - this.onStreamPost = post => { - if (document.hidden && post.user_id != this.I.id) { - this.unreadCount++; - document.title = `(${this.unreadCount}) ${getPostSummary(post)}`; - } - }; - - this.windowOnVisibilitychange = () => { - if (!document.hidden) { - this.unreadCount = 0; - document.title = 'Misskey'; - } - }; - </script> -</mk-home-page> diff --git a/src/web/app/desktop/tags/pages/messaging-room.tag b/src/web/app/desktop/tags/pages/messaging-room.tag deleted file mode 100644 index 3c21b97501..0000000000 --- a/src/web/app/desktop/tags/pages/messaging-room.tag +++ /dev/null @@ -1,37 +0,0 @@ -<mk-messaging-room-page> - <mk-messaging-room if={ user } user={ user } is-naked={ true }/> - - <style> - :scope - display block - background #fff - - </style> - <script> - import Progress from '../../../common/scripts/loading'; - - this.mixin('api'); - - this.fetching = true; - this.user = null; - - this.on('mount', () => { - Progress.start(); - - document.documentElement.style.background = '#fff'; - - this.api('users/show', { - username: this.opts.user - }).then(user => { - this.update({ - fetching: false, - user: user - }); - - document.title = 'メッセージ: ' + this.user.name; - - Progress.done(); - }); - }); - </script> -</mk-messaging-room-page> diff --git a/src/web/app/desktop/tags/pages/not-found.tag b/src/web/app/desktop/tags/pages/not-found.tag deleted file mode 100644 index e62ea11008..0000000000 --- a/src/web/app/desktop/tags/pages/not-found.tag +++ /dev/null @@ -1,11 +0,0 @@ -<mk-not-found> - <mk-ui> - <main> - <h1>Not Found</h1> - </main> - </mk-ui> - <style> - :scope - display block - </style> -</mk-not-found> diff --git a/src/web/app/desktop/tags/pages/post.tag b/src/web/app/desktop/tags/pages/post.tag deleted file mode 100644 index 6d3b030e05..0000000000 --- a/src/web/app/desktop/tags/pages/post.tag +++ /dev/null @@ -1,58 +0,0 @@ -<mk-post-page> - <mk-ui ref="ui"> - <main if={ !parent.fetching }> - <a if={ parent.post.next } href={ parent.post.next }>%fa:angle-up%%i18n:desktop.tags.mk-post-page.next%</a> - <mk-post-detail ref="detail" post={ parent.post }/> - <a if={ parent.post.prev } href={ parent.post.prev }>%fa:angle-down%%i18n:desktop.tags.mk-post-page.prev%</a> - </main> - </mk-ui> - <style> - :scope - display block - - 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> - <script> - import Progress from '../../../common/scripts/loading'; - - this.mixin('api'); - - this.fetching = true; - this.post = null; - - this.on('mount', () => { - Progress.start(); - - this.api('posts/show', { - post_id: this.opts.post - }).then(post => { - - this.update({ - fetching: false, - post: post - }); - - Progress.done(); - }); - }); - </script> -</mk-post-page> diff --git a/src/web/app/desktop/tags/pages/search.tag b/src/web/app/desktop/tags/pages/search.tag deleted file mode 100644 index 4f5867bdb9..0000000000 --- a/src/web/app/desktop/tags/pages/search.tag +++ /dev/null @@ -1,20 +0,0 @@ -<mk-search-page> - <mk-ui ref="ui"> - <mk-search ref="search" query={ parent.opts.query }/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import Progress from '../../../common/scripts/loading'; - - this.on('mount', () => { - Progress.start(); - - this.refs.ui.refs.search.on('loaded', () => { - Progress.done(); - }); - }); - </script> -</mk-search-page> diff --git a/src/web/app/desktop/tags/pages/selectdrive.tag b/src/web/app/desktop/tags/pages/selectdrive.tag deleted file mode 100644 index 123977e905..0000000000 --- a/src/web/app/desktop/tags/pages/selectdrive.tag +++ /dev/null @@ -1,161 +0,0 @@ -<mk-selectdrive-page> - <mk-drive-browser ref="browser" multiple={ multiple }/> - <div> - <button class="upload" title="%i18n:desktop.tags.mk-selectdrive-page.upload%" onclick={ upload }>%fa:upload%</button> - <button class="cancel" onclick={ close }>%i18n:desktop.tags.mk-selectdrive-page.cancel%</button> - <button class="ok" onclick={ ok }>%i18n:desktop.tags.mk-selectdrive-page.ok%</button> - </div> - - <style> - :scope - display block - position fixed - width 100% - height 100% - background #fff - - > mk-drive-browser - height calc(100% - 72px) - - > div - 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> - <script> - const q = (new URL(location)).searchParams; - this.multiple = q.get('multiple') == 'true' ? true : false; - - this.on('mount', () => { - document.title = '%i18n:desktop.tags.mk-selectdrive-page.title%'; - - this.refs.browser.on('selected', file => { - this.files = [file]; - this.ok(); - }); - - this.refs.browser.on('change-selection', files => { - this.update({ - files: files - }); - }); - }); - - this.upload = () => { - this.refs.browser.selectLocalFile(); - }; - - this.close = () => { - window.close(); - }; - - this.ok = () => { - window.opener.cb(this.multiple ? this.files : this.files[0]); - window.close(); - }; - </script> -</mk-selectdrive-page> diff --git a/src/web/app/desktop/tags/pages/user.tag b/src/web/app/desktop/tags/pages/user.tag deleted file mode 100644 index 811ca5c0fd..0000000000 --- a/src/web/app/desktop/tags/pages/user.tag +++ /dev/null @@ -1,27 +0,0 @@ -<mk-user-page> - <mk-ui ref="ui"> - <mk-user ref="user" user={ parent.user } page={ parent.opts.page }/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import Progress from '../../../common/scripts/loading'; - - this.user = this.opts.user; - - this.on('mount', () => { - Progress.start(); - - this.refs.ui.refs.user.on('user-fetched', user => { - Progress.set(0.5); - document.title = user.name + ' | Misskey'; - }); - - this.refs.ui.refs.user.on('loaded', () => { - Progress.done(); - }); - }); - </script> -</mk-user-page> diff --git a/src/web/app/desktop/tags/post-detail-sub.tag b/src/web/app/desktop/tags/post-detail-sub.tag deleted file mode 100644 index cccd85c474..0000000000 --- a/src/web/app/desktop/tags/post-detail-sub.tag +++ /dev/null @@ -1,149 +0,0 @@ -<mk-post-detail-sub title={ title }> - <a class="avatar-anchor" href={ '/' + post.user.username }> - <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar" data-user-preview={ post.user_id }/> - </a> - <div class="main"> - <header> - <div class="left"> - <a class="name" href={ '/' + post.user.username } data-user-preview={ post.user_id }>{ post.user.name }</a> - <span class="username">@{ post.user.username }</span> - </div> - <div class="right"> - <a class="time" href={ '/' + post.user.username + '/' + post.id }> - <mk-time time={ post.created_at }/> - </a> - </div> - </header> - <div class="body"> - <div class="text" ref="text"></div> - <div class="media" if={ post.media }> - <mk-images images={ post.media }/> - </div> - </div> - </div> - <style> - :scope - display block - 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 - - > .body - - > .text - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - font-size 1em - color #717171 - - > mk-url-preview - margin-top 8px - - </style> - <script> - import compile from '../../common/scripts/text-compiler'; - import dateStringify from '../../common/scripts/date-stringify'; - - this.mixin('api'); - this.mixin('user-preview'); - - this.post = this.opts.post; - this.title = dateStringify(this.post.created_at); - - this.on('mount', () => { - if (this.post.text) { - const tokens = this.post.ast; - - this.refs.text.innerHTML = compile(tokens); - - Array.from(this.refs.text.children).forEach(e => { - if (e.tagName == 'MK-URL') riot.mount(e); - }); - } - }); - - this.like = () => { - if (this.post.is_liked) { - this.api('posts/likes/delete', { - post_id: this.post.id - }).then(() => { - this.post.is_liked = false; - this.update(); - }); - } else { - this.api('posts/likes/create', { - post_id: this.post.id - }).then(() => { - this.post.is_liked = true; - this.update(); - }); - } - }; - </script> -</mk-post-detail-sub> diff --git a/src/web/app/desktop/tags/post-detail.tag b/src/web/app/desktop/tags/post-detail.tag deleted file mode 100644 index 47c71a6c12..0000000000 --- a/src/web/app/desktop/tags/post-detail.tag +++ /dev/null @@ -1,328 +0,0 @@ -<mk-post-detail title={ title }> - <div class="main"> - <button class="read-more" if={ p.reply && p.reply.reply_id && context == null } title="会話をもっと読み込む" onclick={ loadContext } disabled={ contextFetching }> - <virtual if={ !contextFetching }>%fa:ellipsis-v%</virtual> - <virtual if={ contextFetching }>%fa:spinner .pulse%</virtual> - </button> - <div class="context"> - <virtual each={ post in context }> - <mk-post-detail-sub post={ post }/> - </virtual> - </div> - <div class="reply-to" if={ p.reply }> - <mk-post-detail-sub post={ p.reply }/> - </div> - <div class="repost" if={ isRepost }> - <p> - <a class="avatar-anchor" href={ '/' + post.user.username } data-user-preview={ post.user_id }> - <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=32' } alt="avatar"/> - </a> - %fa:retweet%<a class="name" href={ '/' + post.user.username }> - { post.user.name } - </a> - がRepost - </p> - </div> - <article> - <a class="avatar-anchor" href={ '/' + p.user.username }> - <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=64' } alt="avatar" data-user-preview={ p.user.id }/> - </a> - <header> - <a class="name" href={ '/' + p.user.username } data-user-preview={ p.user.id }>{ p.user.name }</a> - <span class="username">@{ p.user.username }</span> - <a class="time" href={ '/' + p.user.username + '/' + p.id }> - <mk-time time={ p.created_at }/> - </a> - </header> - <div class="body"> - <div class="text" ref="text"></div> - <div class="media" if={ p.media }> - <mk-images images={ p.media }/> - </div> - <mk-poll if={ p.poll } post={ p }/> - </div> - <footer> - <mk-reactions-viewer post={ p }/> - <button onclick={ reply } title="返信"> - %fa:reply%<p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> - </button> - <button onclick={ repost } title="Repost"> - %fa:retweet%<p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> - </button> - <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="リアクション"> - %fa:plus%<p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> - </button> - <button onclick={ menu } ref="menuButton"> - %fa:ellipsis-h% - </button> - </footer> - </article> - <div class="replies" if={ !compact }> - <virtual each={ post in replies }> - <mk-post-detail-sub post={ post }/> - </virtual> - </div> - </div> - <style> - :scope - display block - margin 0 - padding 0 - overflow hidden - text-align left - background #fff - border solid 1px rgba(0, 0, 0, 0.1) - border-radius 8px - - > .main - - > .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 - - > .text - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - font-size 1.5em - color #717171 - - > mk-url-preview - margin-top 8px - - > 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> - <script> - import compile from '../../common/scripts/text-compiler'; - import dateStringify from '../../common/scripts/date-stringify'; - - this.mixin('api'); - this.mixin('user-preview'); - - this.compact = this.opts.compact; - this.contextFetching = false; - this.context = null; - this.post = this.opts.post; - this.isRepost = this.post.repost != null; - this.p = this.isRepost ? this.post.repost : this.post; - this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0; - this.title = dateStringify(this.p.created_at); - - this.on('mount', () => { - if (this.p.text) { - const tokens = this.p.ast; - - this.refs.text.innerHTML = compile(tokens); - - Array.from(this.refs.text.children).forEach(e => { - if (e.tagName == 'MK-URL') riot.mount(e); - }); - - // URLをプレビュー - tokens - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => { - riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), { - url: t.url - }); - }); - } - - // Get replies - if (!this.compact) { - this.api('posts/replies', { - post_id: this.p.id, - limit: 8 - }).then(replies => { - this.update({ - replies: replies - }); - }); - } - }); - - this.reply = () => { - riot.mount(document.body.appendChild(document.createElement('mk-post-form-window')), { - reply: this.p - }); - }; - - this.repost = () => { - riot.mount(document.body.appendChild(document.createElement('mk-repost-form-window')), { - post: this.p - }); - }; - - this.react = () => { - riot.mount(document.body.appendChild(document.createElement('mk-reaction-picker')), { - source: this.refs.reactButton, - post: this.p - }); - }; - - this.menu = () => { - riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), { - source: this.refs.menuButton, - post: this.p - }); - }; - - this.loadContext = () => { - this.contextFetching = true; - - // Fetch context - this.api('posts/context', { - post_id: this.p.reply_id - }).then(context => { - this.update({ - contextFetching: false, - context: context.reverse() - }); - }); - }; - </script> -</mk-post-detail> diff --git a/src/web/app/desktop/tags/post-form-window.tag b/src/web/app/desktop/tags/post-form-window.tag deleted file mode 100644 index 05a09b7803..0000000000 --- a/src/web/app/desktop/tags/post-form-window.tag +++ /dev/null @@ -1,68 +0,0 @@ -<mk-post-form-window> - <mk-window ref="window" is-modal={ true }> - <yield to="header"> - <span if={ !parent.opts.reply }>%i18n:desktop.tags.mk-post-form-window.post%</span> - <span if={ parent.opts.reply }>%i18n:desktop.tags.mk-post-form-window.reply%</span> - <span class="files" if={ parent.files.length != 0 }>{ '%i18n:desktop.tags.mk-post-form-window.attaches%'.replace('{}', parent.files.length) }</span> - <span class="uploading-files" if={ parent.uploadingFiles.length != 0 }>{ '%i18n:desktop.tags.mk-post-form-window.uploading-media%'.replace('{}', parent.uploadingFiles.length) }<mk-ellipsis/></span> - </yield> - <yield to="content"> - <div class="ref" if={ parent.opts.reply }> - <mk-post-preview post={ parent.opts.reply }/> - </div> - <div class="body"> - <mk-post-form ref="form" reply={ parent.opts.reply }/> - </div> - </yield> - </mk-window> - <style> - :scope - > mk-window - - [data-yield='header'] - > .files - > .uploading-files - margin-left 8px - opacity 0.8 - - &:before - content '(' - - &:after - content ')' - - [data-yield='content'] - > .ref - > mk-post-preview - margin 16px 22px - - </style> - <script> - this.uploadingFiles = []; - this.files = []; - - this.on('mount', () => { - this.refs.window.refs.form.focus(); - - this.refs.window.on('closed', () => { - this.unmount(); - }); - - this.refs.window.refs.form.on('post', () => { - this.refs.window.close(); - }); - - this.refs.window.refs.form.on('change-uploading-files', files => { - this.update({ - uploadingFiles: files || [] - }); - }); - - this.refs.window.refs.form.on('change-files', files => { - this.update({ - files: files || [] - }); - }); - }); - </script> -</mk-post-form-window> diff --git a/src/web/app/desktop/tags/post-form.tag b/src/web/app/desktop/tags/post-form.tag deleted file mode 100644 index 0b4c07906a..0000000000 --- a/src/web/app/desktop/tags/post-form.tag +++ /dev/null @@ -1,540 +0,0 @@ -<mk-post-form ondragover={ ondragover } ondragenter={ ondragenter } ondragleave={ ondragleave } ondrop={ ondrop }> - <div class="content"> - <textarea class={ with: (files.length != 0 || poll) } ref="text" disabled={ wait } oninput={ update } onkeydown={ onkeydown } onpaste={ onpaste } placeholder={ placeholder }></textarea> - <div class="medias { with: poll }" show={ files.length != 0 }> - <ul ref="media"> - <li each={ files } data-id={ id }> - <div class="img" style="background-image: url({ url + '?thumbnail&size=64' })" title={ name }></div> - <img class="remove" onclick={ removeFile } src="/assets/desktop/remove.png" title="%i18n:desktop.tags.mk-post-form.attach-cancel%" alt=""/> - </li> - </ul> - <p class="remain">{ 4 - files.length }/4</p> - </div> - <mk-poll-editor if={ poll } ref="poll" ondestroy={ onPollDestroyed }/> - </div> - <mk-uploader ref="uploader"/> - <button ref="upload" title="%i18n:desktop.tags.mk-post-form.attach-media-from-local%" onclick={ selectFile }>%fa:upload%</button> - <button ref="drive" title="%i18n:desktop.tags.mk-post-form.attach-media-from-drive%" onclick={ selectFileFromDrive }>%fa:cloud%</button> - <button class="kao" title="%i18n:desktop.tags.mk-post-form.insert-a-kao%" onclick={ kao }>%fa:R smile%</button> - <button class="poll" title="%i18n:desktop.tags.mk-post-form.create-poll%" onclick={ addPoll }>%fa:chart-pie%</button> - <p class="text-count { over: refs.text.value.length > 1000 }">{ '%i18n:desktop.tags.mk-post-form.text-remain%'.replace('{}', 1000 - refs.text.value.length) }</p> - <button class={ wait: wait } ref="submit" disabled={ wait || (refs.text.value.length == 0 && files.length == 0 && !poll && !repost) } onclick={ post }> - { wait ? '%i18n:desktop.tags.mk-post-form.posting%' : submitText }<mk-ellipsis if={ wait }/> - </button> - <input ref="file" type="file" accept="image/*" multiple="multiple" tabindex="-1" onchange={ changeFile }/> - <div class="dropzone" if={ draghover }></div> - <style> - :scope - display block - padding 16px - background lighten($theme-color, 95%) - - &:after - content "" - display block - clear both - - > .content - - [ref='text'] - 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) - - > ul - display block - margin 0 - padding 4px - list-style none - - &:after - content "" - display block - clear both - - > li - display block - float left - margin 0 - padding 0 - 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 - - [ref='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 - - [ref='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;} - - [ref='upload'] - [ref='drive'] - .kao - .poll - 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> - <script> - import Sortable from 'sortablejs'; - import getKao from '../../common/scripts/get-kao'; - import notify from '../scripts/notify'; - import Autocomplete from '../scripts/autocomplete'; - - this.mixin('api'); - - this.wait = false; - this.uploadings = []; - this.files = []; - this.autocomplete = null; - this.poll = false; - - this.inReplyToPost = this.opts.reply; - - this.repost = this.opts.repost; - - this.placeholder = this.repost - ? '%i18n:desktop.tags.mk-post-form.quote-placeholder%' - : this.inReplyToPost - ? '%i18n:desktop.tags.mk-post-form.reply-placeholder%' - : '%i18n:desktop.tags.mk-post-form.post-placeholder%'; - - this.submitText = this.repost - ? '%i18n:desktop.tags.mk-post-form.repost%' - : this.inReplyToPost - ? '%i18n:desktop.tags.mk-post-form.reply%' - : '%i18n:desktop.tags.mk-post-form.post%'; - - this.draftId = this.repost - ? 'repost:' + this.repost.id - : this.inReplyToPost - ? 'reply:' + this.inReplyToPost.id - : 'post'; - - this.on('mount', () => { - this.refs.uploader.on('uploaded', file => { - this.addFile(file); - }); - - this.refs.uploader.on('change-uploads', uploads => { - this.trigger('change-uploading-files', uploads); - }); - - this.autocomplete = new Autocomplete(this.refs.text); - this.autocomplete.attach(); - - // 書きかけの投稿を復元 - const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId]; - if (draft) { - this.refs.text.value = draft.data.text; - this.files = draft.data.files; - if (draft.data.poll) { - this.poll = true; - this.update(); - this.refs.poll.set(draft.data.poll); - } - this.trigger('change-files', this.files); - this.update(); - } - - new Sortable(this.refs.media, { - animation: 150 - }); - }); - - this.on('unmount', () => { - this.autocomplete.detach(); - }); - - this.focus = () => { - this.refs.text.focus(); - }; - - this.clear = () => { - this.refs.text.value = ''; - this.files = []; - this.poll = false; - this.trigger('change-files'); - this.update(); - }; - - this.ondragover = e => { - e.preventDefault(); - e.stopPropagation(); - this.draghover = true; - e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; - }; - - this.ondragenter = e => { - this.draghover = true; - }; - - this.ondragleave = e => { - this.draghover = false; - }; - - this.ondrop = e => { - e.preventDefault(); - e.stopPropagation(); - this.draghover = false; - - // ファイルだったら - if (e.dataTransfer.files.length > 0) { - Array.from(e.dataTransfer.files).forEach(this.upload); - return; - } - - // データ取得 - const data = e.dataTransfer.getData('text'); - if (data == null) return false; - - try { - // パース - const obj = JSON.parse(data); - - // (ドライブの)ファイルだったら - if (obj.type == 'file') { - this.files.push(obj.file); - this.update(); - } - } catch (e) { - - } - }; - - 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()); - } - }); - }; - - this.selectFile = () => { - this.refs.file.click(); - }; - - this.selectFileFromDrive = () => { - const i = riot.mount(document.body.appendChild(document.createElement('mk-select-file-from-drive-window')), { - multiple: true - })[0]; - i.one('selected', files => { - files.forEach(this.addFile); - }); - }; - - this.changeFile = () => { - Array.from(this.refs.file.files).forEach(this.upload); - }; - - this.upload = file => { - this.refs.uploader.upload(file); - }; - - this.addFile = file => { - this.files.push(file); - this.trigger('change-files', this.files); - this.update(); - }; - - this.removeFile = e => { - const file = e.item; - this.files = this.files.filter(x => x.id != file.id); - this.trigger('change-files', this.files); - this.update(); - }; - - this.addPoll = () => { - this.poll = true; - }; - - this.onPollDestroyed = () => { - this.update({ - poll: false - }); - }; - - this.post = e => { - this.wait = true; - - const files = []; - - if (this.files.length > 0) { - Array.from(this.refs.media.children).forEach(el => { - const id = el.getAttribute('data-id'); - const file = this.files.find(f => f.id == id); - files.push(file); - }); - } - - this.api('posts/create', { - text: this.refs.text.value == '' ? undefined : this.refs.text.value, - media_ids: this.files.length > 0 ? files.map(f => f.id) : undefined, - reply_id: this.inReplyToPost ? this.inReplyToPost.id : undefined, - repost_id: this.repost ? this.repost.id : undefined, - poll: this.poll ? this.refs.poll.get() : undefined - }).then(data => { - this.clear(); - this.removeDraft(); - this.trigger('post'); - notify(this.repost - ? '%i18n:desktop.tags.mk-post-form.reposted%' - : this.inReplyToPost - ? '%i18n:desktop.tags.mk-post-form.replied%' - : '%i18n:desktop.tags.mk-post-form.posted%'); - }).catch(err => { - notify(this.repost - ? '%i18n:desktop.tags.mk-post-form.repost-failed%' - : this.inReplyToPost - ? '%i18n:desktop.tags.mk-post-form.reply-failed%' - : '%i18n:desktop.tags.mk-post-form.post-failed%'); - }).then(() => { - this.update({ - wait: false - }); - }); - }; - - this.kao = () => { - this.refs.text.value += getKao(); - }; - - this.on('update', () => { - this.saveDraft(); - }); - - this.saveDraft = () => { - const data = JSON.parse(localStorage.getItem('drafts') || '{}'); - - data[this.draftId] = { - updated_at: new Date(), - data: { - text: this.refs.text.value, - files: this.files, - poll: this.poll && this.refs.poll ? this.refs.poll.get() : undefined - } - } - - localStorage.setItem('drafts', JSON.stringify(data)); - }; - - this.removeDraft = () => { - const data = JSON.parse(localStorage.getItem('drafts') || '{}'); - - delete data[this.draftId]; - - localStorage.setItem('drafts', JSON.stringify(data)); - }; - </script> -</mk-post-form> diff --git a/src/web/app/desktop/tags/post-preview.tag b/src/web/app/desktop/tags/post-preview.tag deleted file mode 100644 index 9a7db5ffa3..0000000000 --- a/src/web/app/desktop/tags/post-preview.tag +++ /dev/null @@ -1,94 +0,0 @@ -<mk-post-preview title={ title }> - <article><a class="avatar-anchor" href={ '/' + post.user.username }><img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar" data-user-preview={ post.user_id }/></a> - <div class="main"> - <header><a class="name" href={ '/' + post.user.username } data-user-preview={ post.user_id }>{ post.user.name }</a><span class="username">@{ post.user.username }</span><a class="time" href={ '/' + post.user.username + '/' + post.id }> - <mk-time time={ post.created_at }/></a></header> - <div class="body"> - <mk-sub-post-content class="text" post={ post }/> - </div> - </div> - </article> - <style> - :scope - display block - margin 0 - padding 0 - font-size 0.9em - background #fff - - > article - - &: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 - margin 4px 0 - white-space nowrap - - > .name - margin 0 .5em 0 0 - padding 0 - color #607073 - font-size 1em - line-height 1.1em - font-weight 700 - text-align left - text-decoration none - white-space normal - - &: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> - <script> - import dateStringify from '../../common/scripts/date-stringify'; - - this.mixin('user-preview'); - - this.post = this.opts.post; - - this.title = dateStringify(this.post.created_at); - </script> -</mk-post-preview> diff --git a/src/web/app/desktop/tags/progress-dialog.tag b/src/web/app/desktop/tags/progress-dialog.tag deleted file mode 100644 index a0ac51b2f4..0000000000 --- a/src/web/app/desktop/tags/progress-dialog.tag +++ /dev/null @@ -1,97 +0,0 @@ -<mk-progress-dialog> - <mk-window ref="window" is-modal={ false } can-close={ false } width={ '500px' }> - <yield to="header">{ parent.title }<mk-ellipsis/></yield> - <yield to="content"> - <div class="body"> - <p class="init" if={ isNaN(parent.value) }>待機中<mk-ellipsis/></p> - <p class="percentage" if={ !isNaN(parent.value) }>{ Math.floor((parent.value / parent.max) * 100) }</p> - <progress if={ !isNaN(parent.value) && parent.value < parent.max } value={ isNaN(parent.value) ? 0 : parent.value } max={ parent.max }></progress> - <div class="progress waiting" if={ parent.value >= parent.max }></div> - </div> - </yield> - </mk-window> - <style> - :scope - display block - - > mk-window - [data-yield='content'] - - > .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 - > .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) - - > .progress - 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> - <script> - this.title = this.opts.title; - this.value = parseInt(this.opts.value, 10); - this.max = parseInt(this.opts.max, 10); - - this.on('mount', () => { - this.refs.window.on('closed', () => { - this.unmount(); - }); - }); - - this.updateProgress = (value, max) => { - this.update({ - value: parseInt(value, 10), - max: parseInt(max, 10) - }); - }; - - this.close = () => { - this.refs.window.close(); - }; - </script> -</mk-progress-dialog> diff --git a/src/web/app/desktop/tags/repost-form-window.tag b/src/web/app/desktop/tags/repost-form-window.tag deleted file mode 100644 index dbc3f5a3c5..0000000000 --- a/src/web/app/desktop/tags/repost-form-window.tag +++ /dev/null @@ -1,47 +0,0 @@ -<mk-repost-form-window> - <mk-window ref="window" is-modal={ true }> - <yield to="header"> - %fa:retweet%%i18n:desktop.tags.mk-repost-form-window.title% - </yield> - <yield to="content"> - <mk-repost-form ref="form" post={ parent.opts.post }/> - </yield> - </mk-window> - <style> - :scope - > mk-window - [data-yield='header'] - > [data-fa] - margin-right 4px - - </style> - <script> - this.onDocumentKeydown = e => { - if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { - if (e.which == 27) { // Esc - this.refs.window.close(); - } - } - }; - - this.on('mount', () => { - this.refs.window.refs.form.on('cancel', () => { - this.refs.window.close(); - }); - - this.refs.window.refs.form.on('posted', () => { - this.refs.window.close(); - }); - - document.addEventListener('keydown', this.onDocumentKeydown); - - this.refs.window.on('closed', () => { - this.unmount(); - }); - }); - - this.on('unmount', () => { - document.removeEventListener('keydown', this.onDocumentKeydown); - }); - </script> -</mk-repost-form-window> diff --git a/src/web/app/desktop/tags/repost-form.tag b/src/web/app/desktop/tags/repost-form.tag deleted file mode 100644 index c3cf6c1fb3..0000000000 --- a/src/web/app/desktop/tags/repost-form.tag +++ /dev/null @@ -1,127 +0,0 @@ -<mk-repost-form> - <mk-post-preview post={ opts.post }/> - <virtual if={ !quote }> - <footer> - <a class="quote" if={ !quote } onclick={ onquote }>%i18n:desktop.tags.mk-repost-form.quote%</a> - <button class="cancel" onclick={ cancel }>%i18n:desktop.tags.mk-repost-form.cancel%</button> - <button class="ok" onclick={ ok } disabled={ wait }>{ wait ? '%i18n:desktop.tags.mk-repost-form.reposting%' : '%i18n:desktop.tags.mk-repost-form.repost%' }</button> - </footer> - </virtual> - <virtual if={ quote }> - <mk-post-form ref="form" repost={ opts.post }/> - </virtual> - <style> - :scope - - > mk-post-preview - margin 16px 22px - - > div - padding 16px - - > 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> - <script> - import notify from '../scripts/notify'; - - this.mixin('api'); - - this.wait = false; - this.quote = false; - - this.cancel = () => { - this.trigger('cancel'); - }; - - this.ok = () => { - this.wait = true; - this.api('posts/create', { - repost_id: this.opts.post.id - }).then(data => { - this.trigger('posted'); - notify('%i18n:desktop.tags.mk-repost-form.success%'); - }).catch(err => { - notify('%i18n:desktop.tags.mk-repost-form.failure%'); - }).then(() => { - this.update({ - wait: false - }); - }); - }; - - this.onquote = () => { - this.update({ - quote: true - }); - - this.refs.form.on('post', () => { - this.trigger('posted'); - }); - - this.refs.form.focus(); - }; - </script> -</mk-repost-form> diff --git a/src/web/app/desktop/tags/search-posts.tag b/src/web/app/desktop/tags/search-posts.tag deleted file mode 100644 index f7ec85a4fe..0000000000 --- a/src/web/app/desktop/tags/search-posts.tag +++ /dev/null @@ -1,96 +0,0 @@ -<mk-search-posts> - <div class="loading" if={ isLoading }> - <mk-ellipsis-icon/> - </div> - <p class="empty" if={ isEmpty }>%fa:search%「{ query }」に関する投稿は見つかりませんでした。</p> - <mk-timeline ref="timeline"> - <yield to="footer"> - <virtual if={ !parent.moreLoading }>%fa:moon%</virtual> - <virtual if={ parent.moreLoading }>%fa:spinner .pulse .fw%</virtual> - </yield/> - </mk-timeline> - <style> - :scope - display block - background #fff - - > .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> - <script> - import parse from '../../common/scripts/parse-search-query'; - - this.mixin('api'); - - this.query = this.opts.query; - this.isLoading = true; - this.isEmpty = false; - this.moreLoading = false; - this.limit = 30; - this.offset = 0; - - this.on('mount', () => { - document.addEventListener('keydown', this.onDocumentKeydown); - window.addEventListener('scroll', this.onScroll); - - this.api('posts/search', parse(this.query)).then(posts => { - this.update({ - isLoading: false, - isEmpty: posts.length == 0 - }); - this.refs.timeline.setPosts(posts); - this.trigger('loaded'); - }); - }); - - this.on('unmount', () => { - document.removeEventListener('keydown', this.onDocumentKeydown); - window.removeEventListener('scroll', this.onScroll); - }); - - this.onDocumentKeydown = e => { - if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { - if (e.which == 84) { // t - this.refs.timeline.focus(); - } - } - }; - - this.more = () => { - if (this.moreLoading || this.isLoading || this.timeline.posts.length == 0) return; - this.offset += this.limit; - this.update({ - moreLoading: true - }); - return this.api('posts/search', Object.assign({}, parse(this.query), { - limit: this.limit, - offset: this.offset - })).then(posts => { - this.update({ - moreLoading: false - }); - this.refs.timeline.prependPosts(posts); - }); - }; - - this.onScroll = () => { - const current = window.scrollY + window.innerHeight; - if (current > document.body.offsetHeight - 16) this.more(); - }; - </script> -</mk-search-posts> diff --git a/src/web/app/desktop/tags/search.tag b/src/web/app/desktop/tags/search.tag deleted file mode 100644 index d5159fe4e9..0000000000 --- a/src/web/app/desktop/tags/search.tag +++ /dev/null @@ -1,34 +0,0 @@ -<mk-search> - <header> - <h1>{ query }</h1> - </header> - <mk-search-posts ref="posts" query={ query }/> - <style> - :scope - display block - padding-bottom 32px - - > header - width 100% - max-width 600px - margin 0 auto - color #555 - - > mk-search-posts - max-width 600px - margin 0 auto - border solid 1px rgba(0, 0, 0, 0.075) - border-radius 6px - overflow hidden - - </style> - <script> - this.query = this.opts.query; - - this.on('mount', () => { - this.refs.posts.on('loaded', () => { - this.trigger('loaded'); - }); - }); - </script> -</mk-search> diff --git a/src/web/app/desktop/tags/select-file-from-drive-window.tag b/src/web/app/desktop/tags/select-file-from-drive-window.tag deleted file mode 100644 index c660a2fe90..0000000000 --- a/src/web/app/desktop/tags/select-file-from-drive-window.tag +++ /dev/null @@ -1,173 +0,0 @@ -<mk-select-file-from-drive-window> - <mk-window ref="window" is-modal={ true } width={ '800px' } height={ '500px' }> - <yield to="header"> - <mk-raw content={ parent.title }/> - <span class="count" if={ parent.multiple && parent.files.length > 0 }>({ parent.files.length }ファイル選択中)</span> - </yield> - <yield to="content"> - <mk-drive-browser ref="browser" multiple={ parent.multiple }/> - <div> - <button class="upload" title="PCからドライブにファイルをアップロード" onclick={ parent.upload }>%fa:upload%</button> - <button class="cancel" onclick={ parent.close }>キャンセル</button> - <button class="ok" disabled={ parent.multiple && parent.files.length == 0 } onclick={ parent.ok }>決定</button> - </div> - </yield> - </mk-window> - <style> - :scope - > mk-window - [data-yield='header'] - > mk-raw - > [data-fa] - margin-right 4px - - .count - margin-left 8px - opacity 0.7 - - [data-yield='content'] - > mk-drive-browser - height calc(100% - 72px) - - > div - 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> - <script> - this.files = []; - - this.multiple = this.opts.multiple != null ? this.opts.multiple : false; - this.title = this.opts.title || '%fa:R file%ファイルを選択'; - - this.on('mount', () => { - this.refs.window.refs.browser.on('selected', file => { - this.files = [file]; - this.ok(); - }); - - this.refs.window.refs.browser.on('change-selection', files => { - this.update({ - files: files - }); - }); - - this.refs.window.on('closed', () => { - this.unmount(); - }); - }); - - this.close = () => { - this.refs.window.close(); - }; - - this.upload = () => { - this.refs.window.refs.browser.selectLocalFile(); - }; - - this.ok = () => { - this.trigger('selected', this.multiple ? this.files : this.files[0]); - this.refs.window.close(); - }; - </script> -</mk-select-file-from-drive-window> diff --git a/src/web/app/desktop/tags/select-folder-from-drive-window.tag b/src/web/app/desktop/tags/select-folder-from-drive-window.tag deleted file mode 100644 index 3c66a4e6da..0000000000 --- a/src/web/app/desktop/tags/select-folder-from-drive-window.tag +++ /dev/null @@ -1,112 +0,0 @@ -<mk-select-folder-from-drive-window> - <mk-window ref="window" is-modal={ true } width={ '800px' } height={ '500px' }> - <yield to="header"> - <mk-raw content={ parent.title }/> - </yield> - <yield to="content"> - <mk-drive-browser ref="browser"/> - <div> - <button class="cancel" onclick={ parent.close }>キャンセル</button> - <button class="ok" onclick={ parent.ok }>決定</button> - </div> - </yield> - </mk-window> - <style> - :scope - > mk-window - [data-yield='header'] - > mk-raw - > [data-fa] - margin-right 4px - - [data-yield='content'] - > mk-drive-browser - height calc(100% - 72px) - - > div - 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> - <script> - this.files = []; - - this.title = this.opts.title || '%fa:R folder%フォルダを選択'; - - this.on('mount', () => { - this.refs.window.on('closed', () => { - this.unmount(); - }); - }); - - this.close = () => { - this.refs.window.close(); - }; - - this.ok = () => { - this.trigger('selected', this.refs.window.refs.browser.folder); - this.refs.window.close(); - }; - </script> -</mk-select-folder-from-drive-window> diff --git a/src/web/app/desktop/tags/set-avatar-suggestion.tag b/src/web/app/desktop/tags/set-avatar-suggestion.tag deleted file mode 100644 index 7e871129fc..0000000000 --- a/src/web/app/desktop/tags/set-avatar-suggestion.tag +++ /dev/null @@ -1,48 +0,0 @@ -<mk-set-avatar-suggestion onclick={ set }> - <p><b>アバターを設定</b>してみませんか? - <button onclick={ close }>%fa:times%</button> - </p> - <style> - :scope - display block - cursor pointer - color #fff - background #a8cad0 - - &:hover - background #70abb5 - - > p - display block - margin 0 auto - padding 8px - max-width 1024px - - > a - font-weight bold - color #fff - - > button - position absolute - top 0 - right 0 - padding 8px - color #fff - - </style> - <script> - import updateAvatar from '../scripts/update-avatar'; - - this.mixin('i'); - - this.set = () => { - updateAvatar(this.I); - }; - - this.close = e => { - e.preventDefault(); - e.stopPropagation(); - this.unmount(); - }; - </script> -</mk-set-avatar-suggestion> diff --git a/src/web/app/desktop/tags/set-banner-suggestion.tag b/src/web/app/desktop/tags/set-banner-suggestion.tag deleted file mode 100644 index 4cd364ca3e..0000000000 --- a/src/web/app/desktop/tags/set-banner-suggestion.tag +++ /dev/null @@ -1,48 +0,0 @@ -<mk-set-banner-suggestion onclick={ set }> - <p><b>バナーを設定</b>してみませんか? - <button onclick={ close }>%fa:times%</button> - </p> - <style> - :scope - display block - cursor pointer - color #fff - background #a8cad0 - - &:hover - background #70abb5 - - > p - display block - margin 0 auto - padding 8px - max-width 1024px - - > a - font-weight bold - color #fff - - > button - position absolute - top 0 - right 0 - padding 8px - color #fff - - </style> - <script> - import updateBanner from '../scripts/update-banner'; - - this.mixin('i'); - - this.set = () => { - updateBanner(this.I); - }; - - this.close = e => { - e.preventDefault(); - e.stopPropagation(); - this.unmount(); - }; - </script> -</mk-set-banner-suggestion> diff --git a/src/web/app/desktop/tags/settings-window.tag b/src/web/app/desktop/tags/settings-window.tag deleted file mode 100644 index 5a725af51e..0000000000 --- a/src/web/app/desktop/tags/settings-window.tag +++ /dev/null @@ -1,30 +0,0 @@ -<mk-settings-window> - <mk-window ref="window" is-modal={ true } width={ '700px' } height={ '550px' }> - <yield to="header">%fa:cog%設定</yield> - <yield to="content"> - <mk-settings/> - </yield> - </mk-window> - <style> - :scope - > mk-window - [data-yield='header'] - > [data-fa] - margin-right 4px - - [data-yield='content'] - overflow hidden - - </style> - <script> - this.on('mount', () => { - this.refs.window.on('closed', () => { - this.unmount(); - }); - }); - - this.close = () => { - this.refs.window.close(); - }; - </script> -</mk-settings-window> diff --git a/src/web/app/desktop/tags/settings.tag b/src/web/app/desktop/tags/settings.tag deleted file mode 100644 index 457b7e2276..0000000000 --- a/src/web/app/desktop/tags/settings.tag +++ /dev/null @@ -1,426 +0,0 @@ -<mk-settings> - <div class="nav"> - <p class={ active: page == 'profile' } onmousedown={ setPage.bind(null, 'profile') }>%fa:user .fw%%i18n:desktop.tags.mk-settings.profile%</p> - <p class={ active: page == 'web' } onmousedown={ setPage.bind(null, 'web') }>%fa:desktop .fw%Web</p> - <p class={ active: page == 'notification' } onmousedown={ setPage.bind(null, 'notification') }>%fa:R bell .fw%通知</p> - <p class={ active: page == 'drive' } onmousedown={ setPage.bind(null, 'drive') }>%fa:cloud .fw%%i18n:desktop.tags.mk-settings.drive%</p> - <p class={ active: page == 'mute' } onmousedown={ setPage.bind(null, 'mute') }>%fa:ban .fw%%i18n:desktop.tags.mk-settings.mute%</p> - <p class={ active: page == 'apps' } onmousedown={ setPage.bind(null, 'apps') }>%fa:puzzle-piece .fw%アプリ</p> - <p class={ active: page == 'twitter' } onmousedown={ setPage.bind(null, 'twitter') }>%fa:B twitter .fw%Twitter</p> - <p class={ active: page == 'security' } onmousedown={ setPage.bind(null, 'security') }>%fa:unlock-alt .fw%%i18n:desktop.tags.mk-settings.security%</p> - <p class={ active: page == 'api' } onmousedown={ setPage.bind(null, 'api') }>%fa:key .fw%API</p> - <p class={ active: page == 'other' } onmousedown={ setPage.bind(null, 'other') }>%fa:cogs .fw%%i18n:desktop.tags.mk-settings.other%</p> - </div> - <div class="pages"> - <section class="profile" show={ page == 'profile' }> - <h1>%i18n:desktop.tags.mk-settings.profile%</h1> - <mk-profile-setting/> - </section> - - <section class="web" show={ page == 'web' }> - <h1>デザイン</h1> - <a href="/i/customize-home" class="ui button">ホームをカスタマイズ</a> - </section> - - <section class="drive" show={ page == 'drive' }> - <h1>%i18n:desktop.tags.mk-settings.drive%</h1> - <mk-drive-setting/> - </section> - - <section class="mute" show={ page == 'mute' }> - <h1>%i18n:desktop.tags.mk-settings.mute%</h1> - <mk-mute-setting/> - </section> - - <section class="apps" show={ page == 'apps' }> - <h1>アプリケーション</h1> - <mk-authorized-apps/> - </section> - - <section class="twitter" show={ page == 'twitter' }> - <h1>Twitter</h1> - <mk-twitter-setting/> - </section> - - <section class="password" show={ page == 'security' }> - <h1>%i18n:desktop.tags.mk-settings.password%</h1> - <mk-password-setting/> - </section> - - <section class="2fa" show={ page == 'security' }> - <h1>%i18n:desktop.tags.mk-settings.2fa%</h1> - <mk-2fa-setting/> - </section> - - <section class="signin" show={ page == 'security' }> - <h1>サインイン履歴</h1> - <mk-signin-history/> - </section> - - <section class="api" show={ page == 'api' }> - <h1>API</h1> - <mk-api-info/> - </section> - - <section class="other" show={ page == 'other' }> - <h1>%i18n:desktop.tags.mk-settings.license%</h1> - %license% - </section> - </div> - <style> - :scope - 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 - display block - margin 0 0 1em 0 - padding 0 0 8px 0 - font-size 1em - color #555 - border-bottom solid 1px #eee - - </style> - <script> - this.page = 'profile'; - - this.setPage = page => { - this.page = page; - }; - </script> -</mk-settings> - -<mk-profile-setting> - <label class="avatar ui from group"> - <p>%i18n:desktop.tags.mk-profile-setting.avatar%</p><img class="avatar" src={ I.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - <button class="ui" onclick={ avatar }>%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 ref="accountName" type="text" value={ I.name } class="ui"/> - </label> - <label class="ui from group"> - <p>%i18n:desktop.tags.mk-profile-setting.location%</p> - <input ref="accountLocation" type="text" value={ I.profile.location } class="ui"/> - </label> - <label class="ui from group"> - <p>%i18n:desktop.tags.mk-profile-setting.description%</p> - <textarea ref="accountDescription" class="ui">{ I.description }</textarea> - </label> - <label class="ui from group"> - <p>%i18n:desktop.tags.mk-profile-setting.birthday%</p> - <input ref="accountBirthday" type="date" value={ I.profile.birthday } class="ui"/> - </label> - <button class="ui primary" onclick={ updateAccount }>%i18n:desktop.tags.mk-profile-setting.save%</button> - <style> - :scope - display block - - > .avatar - > img - display inline-block - vertical-align top - width 64px - height 64px - border-radius 4px - - > button - margin-left 8px - - </style> - <script> - import updateAvatar from '../scripts/update-avatar'; - import notify from '../scripts/notify'; - - this.mixin('i'); - this.mixin('api'); - - this.avatar = () => { - updateAvatar(this.I); - }; - - this.updateAccount = () => { - this.api('i/update', { - name: this.refs.accountName.value, - location: this.refs.accountLocation.value || null, - description: this.refs.accountDescription.value || null, - birthday: this.refs.accountBirthday.value || null - }).then(() => { - notify('プロフィールを更新しました'); - }); - }; - </script> -</mk-profile-setting> - -<mk-api-info> - <p>Token: <code>{ I.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" onclick={ regenerateToken }>%i18n:desktop.tags.mk-api-info.regenerate-token%</button> - <style> - :scope - display block - color #4a535a - - code - display inline-block - padding 4px 6px - color #555 - background #eee - border-radius 2px - </style> - <script> - import passwordDialog from '../scripts/password-dialog'; - - this.mixin('i'); - this.mixin('api'); - - this.regenerateToken = () => { - passwordDialog('%i18n:desktop.tags.mk-api-info.enter-password%', password => { - this.api('i/regenerate_token', { - password: password - }); - }); - }; - </script> -</mk-api-info> - -<mk-password-setting> - <button onclick={ reset } class="ui primary">%i18n:desktop.tags.mk-password-setting.reset%</button> - <style> - :scope - display block - color #4a535a - </style> - <script> - import passwordDialog from '../scripts/password-dialog'; - import dialog from '../scripts/dialog'; - import notify from '../scripts/notify'; - - this.mixin('i'); - this.mixin('api'); - - this.reset = () => { - passwordDialog('%i18n:desktop.tags.mk-password-setting.enter-current-password%', currentPassword => { - passwordDialog('%i18n:desktop.tags.mk-password-setting.enter-new-password%', newPassword => { - passwordDialog('%i18n:desktop.tags.mk-password-setting.enter-new-password-again%', newPassword2 => { - if (newPassword !== newPassword2) { - dialog(null, '%i18n:desktop.tags.mk-password-setting.not-match%', [{ - text: 'OK' - }]); - return; - } - this.api('i/change_password', { - current_password: currentPassword, - new_password: newPassword - }).then(() => { - notify('%i18n:desktop.tags.mk-password-setting.changed%'); - }); - }); - }); - }); - }; - </script> -</mk-password-setting> - -<mk-2fa-setting> - <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 if={ !data && !I.two_factor_enabled }><button onclick={ register } class="ui primary">%i18n:desktop.tags.mk-2fa-setting.register%</button></p> - <virtual if={ I.two_factor_enabled }> - <p>%i18n:desktop.tags.mk-2fa-setting.already-registered%</p> - <button onclick={ unregister } class="ui">%i18n:desktop.tags.mk-2fa-setting.unregister%</button> - </virtual> - <div 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" ref="token" class="ui"> - <button onclick={ 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> - <style> - :scope - display block - color #4a535a - - </style> - <script> - import passwordDialog from '../scripts/password-dialog'; - import notify from '../scripts/notify'; - - this.mixin('i'); - this.mixin('api'); - - this.register = () => { - passwordDialog('%i18n:desktop.tags.mk-2fa-setting.enter-password%', password => { - this.api('i/2fa/register', { - password: password - }).then(data => { - this.update({ - data: data - }); - }); - }); - }; - - this.unregister = () => { - passwordDialog('%i18n:desktop.tags.mk-2fa-setting.enter-password%', password => { - this.api('i/2fa/unregister', { - password: password - }).then(data => { - notify('%i18n:desktop.tags.mk-2fa-setting.unregistered%'); - this.I.two_factor_enabled = false; - this.I.update(); - }); - }); - }; - - this.submit = () => { - this.api('i/2fa/done', { - token: this.refs.token.value - }).then(() => { - notify('%i18n:desktop.tags.mk-2fa-setting.success%'); - this.I.two_factor_enabled = true; - this.I.update(); - }).catch(() => { - notify('%i18n:desktop.tags.mk-2fa-setting.failed%'); - }); - }; - </script> -</mk-2fa-setting> - -<mk-drive-setting> - <svg viewBox="0 0 1 1" preserveAspectRatio="none"> - <circle - riot-r={ r } - cx="50%" cy="50%" - fill="none" - stroke-width="0.1" - stroke="rgba(0, 0, 0, 0.05)"/> - <circle - riot-r={ r } - cx="50%" cy="50%" - riot-stroke-dasharray={ Math.PI * (r * 2) } - riot-stroke-dashoffset={ strokeDashoffset } - fill="none" - stroke-width="0.1" - riot-stroke={ color }/> - <text x="50%" y="50%" dy="0.05" text-anchor="middle">{ (usageP * 100).toFixed(0) }%</text> - </svg> - - <style> - :scope - display block - color #4a535a - - > svg - display block - height 128px - - > 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> - <script> - this.mixin('api'); - - this.r = 0.4; - - this.on('mount', () => { - this.api('drive').then(info => { - const usageP = info.usage / info.capacity; - const color = `hsl(${180 - (usageP * 180)}, 80%, 70%)`; - const strokeDashoffset = (1 - usageP) * (Math.PI * (this.r * 2)); - - this.update({ - color, - strokeDashoffset, - usageP, - usage: info.usage, - capacity: info.capacity - }); - }); - }); - </script> -</mk-drive-setting> - -<mk-mute-setting> - <div class="none ui info" if={ !fetching && users.length == 0 }> - <p>%fa:info-circle%%i18n:desktop.tags.mk-mute-setting.no-users%</p> - </div> - <div class="users" if={ users.length != 0 }> - <div each={ user in users }> - <p><b>{ user.name }</b> @{ user.username }</p> - </div> - </div> - - <style> - :scope - display block - - </style> - <script> - this.mixin('api'); - - this.apps = []; - this.fetching = true; - - this.on('mount', () => { - this.api('mute/list').then(x => { - this.update({ - fetching: false, - users: x.users - }); - }); - }); - </script> -</mk-mute-setting> diff --git a/src/web/app/desktop/tags/sub-post-content.tag b/src/web/app/desktop/tags/sub-post-content.tag deleted file mode 100644 index 1a81b545b6..0000000000 --- a/src/web/app/desktop/tags/sub-post-content.tag +++ /dev/null @@ -1,54 +0,0 @@ -<mk-sub-post-content> - <div class="body"> - <a class="reply" if={ post.reply_id }> - %fa:reply% - </a> - <span ref="text"></span> - <a class="quote" if={ post.repost_id } href={ '/post:' + post.repost_id }>RP: ...</a> - </div> - <details if={ post.media }> - <summary>({ post.media.length }つのメディア)</summary> - <mk-images images={ post.media }/> - </details> - <details if={ post.poll }> - <summary>投票</summary> - <mk-poll post={ post }/> - </details> - <style> - :scope - display block - overflow-wrap break-word - - > .body - > .reply - margin-right 6px - color #717171 - - > .quote - margin-left 4px - font-style oblique - color #a0bf46 - - mk-poll - font-size 80% - - </style> - <script> - import compile from '../../common/scripts/text-compiler'; - - this.mixin('user-preview'); - - this.post = this.opts.post; - - this.on('mount', () => { - if (this.post.text) { - const tokens = this.post.ast; - this.refs.text.innerHTML = compile(tokens, false); - - Array.from(this.refs.text.children).forEach(e => { - if (e.tagName == 'MK-URL') riot.mount(e); - }); - } - }); - </script> -</mk-sub-post-content> diff --git a/src/web/app/desktop/tags/timeline.tag b/src/web/app/desktop/tags/timeline.tag deleted file mode 100644 index ed77a9e608..0000000000 --- a/src/web/app/desktop/tags/timeline.tag +++ /dev/null @@ -1,704 +0,0 @@ -<mk-timeline> - <virtual each={ post, i in posts }> - <mk-timeline-post post={ post }/> - <p class="date" 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> - </virtual> - <footer data-yield="footer"> - <yield from="footer"/> - </footer> - <style> - :scope - display block - - > .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 - padding 16px - text-align center - color #ccc - border-top solid 1px #eaeaea - border-bottom-left-radius 4px - border-bottom-right-radius 4px - - </style> - <script> - this.posts = []; - - this.on('update', () => { - this.posts.forEach(post => { - const date = new Date(post.created_at).getDate(); - const month = new Date(post.created_at).getMonth() + 1; - post._date = date; - post._datetext = `${month}月 ${date}日`; - }); - }); - - this.setPosts = posts => { - this.update({ - posts: posts - }); - }; - - this.prependPosts = posts => { - posts.forEach(post => { - this.posts.push(post); - this.update(); - }); - } - - this.addPost = post => { - this.posts.unshift(post); - this.update(); - }; - - this.tail = () => { - return this.posts[this.posts.length - 1]; - }; - - this.clear = () => { - this.posts = []; - this.update(); - }; - - this.focus = () => { - this.root.children[0].focus(); - }; - - </script> -</mk-timeline> - -<mk-timeline-post tabindex="-1" title={ title } onkeydown={ onKeyDown } dblclick={ onDblClick }> - <div class="reply-to" if={ p.reply }> - <mk-timeline-post-sub post={ p.reply }/> - </div> - <div class="repost" if={ isRepost }> - <p> - <a class="avatar-anchor" href={ '/' + post.user.username } data-user-preview={ post.user_id }> - <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=32' } alt="avatar"/> - </a> - %fa:retweet%{'%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('{'))}<a class="name" href={ '/' + post.user.username } data-user-preview={ post.user_id }>{ post.user.name }</a>{'%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr('%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1)} - </p> - <mk-time time={ post.created_at }/> - </div> - <article> - <a class="avatar-anchor" href={ '/' + p.user.username }> - <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=64' } alt="avatar" data-user-preview={ p.user.id }/> - </a> - <div class="main"> - <header> - <a class="name" href={ '/' + p.user.username } data-user-preview={ p.user.id }>{ p.user.name }</a> - <span class="is-bot" if={ p.user.is_bot }>bot</span> - <span class="username">@{ p.user.username }</span> - <div class="info"> - <span class="app" if={ p.app }>via <b>{ p.app.name }</b></span> - <a class="created-at" href={ url }> - <mk-time time={ p.created_at }/> - </a> - </div> - </header> - <div class="body"> - <div class="text" ref="text"> - <p class="channel" if={ p.channel != null }><a href={ _CH_URL_ + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p> - <a class="reply" if={ p.reply }> - %fa:reply% - </a> - <p class="dummy"></p> - <a class="quote" if={ p.repost != null }>RP:</a> - </div> - <div class="media" if={ p.media }> - <mk-images images={ p.media }/> - </div> - <mk-poll if={ p.poll } post={ p } ref="pollViewer"/> - <div class="repost" if={ p.repost }>%fa:quote-right -flip-h% - <mk-post-preview class="repost" post={ p.repost }/> - </div> - </div> - <footer> - <mk-reactions-viewer post={ p } ref="reactionsViewer"/> - <button onclick={ reply } title="%i18n:desktop.tags.mk-timeline-post.reply%"> - %fa:reply%<p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> - </button> - <button onclick={ repost } title="%i18n:desktop.tags.mk-timeline-post.repost%"> - %fa:retweet%<p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> - </button> - <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:desktop.tags.mk-timeline-post.add-reaction%"> - %fa:plus%<p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> - </button> - <button onclick={ menu } ref="menuButton"> - %fa:ellipsis-h% - </button> - <button onclick={ toggleDetail } title="%i18n:desktop.tags.mk-timeline-post.detail"> - <virtual if={ !isDetailOpened }>%fa:caret-down%</virtual> - <virtual if={ isDetailOpened }>%fa:caret-up%</virtual> - </button> - </footer> - </div> - </article> - <div class="detail" if={ isDetailOpened }> - <mk-post-status-graph width="462" height="130" post={ p }/> - </div> - <style> - :scope - display block - 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 - margin-bottom 4px - white-space nowrap - line-height 1.4 - - > .name - display block - margin 0 .5em 0 0 - padding 0 - overflow hidden - color #777 - font-size 1em - font-weight 700 - text-align left - text-decoration none - text-overflow ellipsis - - &:hover - text-decoration underline - - > .is-bot - text-align left - margin 0 .5em 0 0 - padding 1px 6px - font-size 12px - color #aaa - border solid 1px #ddd - border-radius 3px - - > .username - text-align left - margin 0 .5em 0 0 - color #ccc - - > .info - margin-left auto - text-align right - font-size 0.9em - - > .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 - - > .dummy - display none - - mk-url-preview - margin-top 8px - - > .channel - margin 0 - - > .reply - margin-right 8px - color #717171 - - > .quote - margin-left 4px - font-style oblique - color #a0bf46 - - 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 - - > mk-poll - font-size 80% - - > .repost - margin 8px 0 - - > [data-fa]:first-child - position absolute - top -8px - left -8px - z-index 1 - color #c0dac6 - font-size 28px - background #fff - - > 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> - <script> - import compile from '../../common/scripts/text-compiler'; - import dateStringify from '../../common/scripts/date-stringify'; - - this.mixin('i'); - this.mixin('api'); - this.mixin('user-preview'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.isDetailOpened = false; - - this.set = post => { - this.post = post; - this.isRepost = this.post.repost && this.post.text == null && this.post.media_ids == null && this.post.poll == null; - this.p = this.isRepost ? this.post.repost : this.post; - this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0; - this.title = dateStringify(this.p.created_at); - this.url = `/${this.p.user.username}/${this.p.id}`; - }; - - this.set(this.opts.post); - - this.refresh = post => { - this.set(post); - this.update(); - if (this.refs.reactionsViewer) this.refs.reactionsViewer.update({ - post - }); - if (this.refs.pollViewer) this.refs.pollViewer.init(post); - }; - - this.onStreamPostUpdated = data => { - const post = data.post; - if (post.id == this.post.id) { - this.refresh(post); - } - }; - - this.onStreamConnected = () => { - this.capture(); - }; - - this.capture = withHandler => { - if (this.SIGNIN) { - this.connection.send({ - type: 'capture', - id: this.post.id - }); - if (withHandler) this.connection.on('post-updated', this.onStreamPostUpdated); - } - }; - - this.decapture = withHandler => { - if (this.SIGNIN) { - this.connection.send({ - type: 'decapture', - id: this.post.id - }); - if (withHandler) this.connection.off('post-updated', this.onStreamPostUpdated); - } - }; - - this.on('mount', () => { - this.capture(true); - - if (this.SIGNIN) { - this.connection.on('_connected_', this.onStreamConnected); - } - - if (this.p.text) { - const tokens = this.p.ast; - - this.refs.text.innerHTML = this.refs.text.innerHTML.replace('<p class="dummy"></p>', compile(tokens)); - - Array.from(this.refs.text.children).forEach(e => { - if (e.tagName == 'MK-URL') riot.mount(e); - }); - - // URLをプレビュー - tokens - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => { - riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), { - url: t.url - }); - }); - } - }); - - this.on('unmount', () => { - this.decapture(true); - this.connection.off('_connected_', this.onStreamConnected); - this.stream.dispose(this.connectionId); - }); - - this.reply = () => { - riot.mount(document.body.appendChild(document.createElement('mk-post-form-window')), { - reply: this.p - }); - }; - - this.repost = () => { - riot.mount(document.body.appendChild(document.createElement('mk-repost-form-window')), { - post: this.p - }); - }; - - this.react = () => { - riot.mount(document.body.appendChild(document.createElement('mk-reaction-picker')), { - source: this.refs.reactButton, - post: this.p - }); - }; - - this.menu = () => { - riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), { - source: this.refs.menuButton, - post: this.p - }); - }; - - this.toggleDetail = () => { - this.update({ - isDetailOpened: !this.isDetailOpened - }); - }; - - this.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.root, e => e.previousElementSibling); - break; - - case e.which == 40: // [↓] - case e.which == 75: // [k] - case e.which == 9: // [Tab] - focus(this.root, 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(); - }; - - this.onDblClick = () => { - riot.mount(document.body.appendChild(document.createElement('mk-detailed-post-window')), { - post: this.p.id - }); - }; - - function focus(el, fn) { - const target = fn(el); - if (target) { - if (target.hasAttribute('tabindex')) { - target.focus(); - } else { - focus(target, fn); - } - } - } - </script> -</mk-timeline-post> - -<mk-timeline-post-sub title={ title }> - <article> - <a class="avatar-anchor" href={ '/' + post.user.username }> - <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar" data-user-preview={ post.user_id }/> - </a> - <div class="main"> - <header> - <a class="name" href={ '/' + post.user.username } data-user-preview={ post.user_id }>{ post.user.name }</a> - <span class="username">@{ post.user.username }</span> - <a class="created-at" href={ '/' + post.user.username + '/' + post.id }> - <mk-time time={ post.created_at }/> - </a> - </header> - <div class="body"> - <mk-sub-post-content class="text" post={ post }/> - </div> - </div> - </article> - <style> - :scope - display block - margin 0 - padding 0 - font-size 0.9em - - > article - padding 16px - - &: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 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 - - > .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> - <script> - import dateStringify from '../../common/scripts/date-stringify'; - - this.mixin('user-preview'); - - this.post = this.opts.post; - this.title = dateStringify(this.post.created_at); - </script> -</mk-timeline-post-sub> diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag deleted file mode 100644 index 3dfdeec01c..0000000000 --- a/src/web/app/desktop/tags/ui.tag +++ /dev/null @@ -1,896 +0,0 @@ -<mk-ui> - <mk-ui-header page={ opts.page }/> - <mk-set-avatar-suggestion if={ SIGNIN && I.avatar_id == null }/> - <mk-set-banner-suggestion if={ SIGNIN && I.banner_id == null }/> - <div class="content"> - <yield /> - </div> - <mk-stream-indicator if={ SIGNIN }/> - <style> - :scope - display block - </style> - <script> - this.mixin('i'); - - this.openPostForm = () => { - riot.mount(document.body.appendChild(document.createElement('mk-post-form-window'))); - }; - - this.on('mount', () => { - document.addEventListener('keydown', this.onkeydown); - }); - - this.on('unmount', () => { - document.removeEventListener('keydown', this.onkeydown); - }); - - this.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.openPostForm(); - } - }; - </script> -</mk-ui> - -<mk-ui-header> - <mk-donation if={ SIGNIN && I.client_settings.show_donation }/> - <mk-special-message/> - <div class="main"> - <div class="backdrop"></div> - <div class="main"> - <div class="container"> - <div class="left"> - <mk-ui-header-nav page={ opts.page }/> - </div> - <div class="right"> - <mk-ui-header-search/> - <mk-ui-header-account if={ SIGNIN }/> - <mk-ui-header-notifications if={ SIGNIN }/> - <mk-ui-header-post-button if={ SIGNIN }/> - <mk-ui-header-clock/> - </div> - </div> - </div> - </div> - <style> - :scope - display block - position -webkit-sticky - position sticky - top 0 - z-index 1024 - width 100% - box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) - - > .main - - > .backdrop - position absolute - top 0 - z-index 1023 - width 100% - height 48px - backdrop-filter blur(12px) - background #f7f7f7 - - &:after - content "" - 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 - - > .main - z-index 1024 - margin 0 - padding 0 - background-clip content-box - font-size 0.9rem - user-select none - - > .container - width 100% - max-width 1300px - margin 0 auto - - &:after - content "" - display block - clear both - - > .left - float left - height 3rem - - > .right - float right - height 48px - - @media (max-width 1100px) - > mk-ui-header-search - display none - - </style> - <script>this.mixin('i');</script> -</mk-ui-header> - -<mk-ui-header-search> - <form class="search" onsubmit={ onsubmit }> - %fa:search% - <input ref="q" type="search" placeholder="%i18n:desktop.tags.mk-ui-header-search.placeholder%"/> - <div class="result"></div> - </form> - <style> - :scope - - > form - display block - float left - - > [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> - <script> - this.mixin('page'); - - this.onsubmit = e => { - e.preventDefault(); - this.page('/search?q=' + encodeURIComponent(this.refs.q.value)); - }; - </script> -</mk-ui-header-search> - -<mk-ui-header-post-button> - <button onclick={ post } title="%i18n:desktop.tags.mk-ui-header-post-button.post%">%fa:pencil-alt%</button> - <style> - :scope - 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> - <script> - this.post = e => { - this.parent.parent.openPostForm(); - }; - </script> -</mk-ui-header-post-button> - -<mk-ui-header-notifications> - <button data-active={ isOpen } onclick={ toggle } title="%i18n:desktop.tags.mk-ui-header-notifications.title%"> - %fa:R bell%<virtual if={ hasUnreadNotifications }>%fa:circle%</virtual> - </button> - <div class="notifications" if={ isOpen }> - <mk-notifications/> - </div> - <style> - :scope - display block - float left - - > 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 - - > .notifications - 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> - <script> - import contains from '../../common/scripts/contains'; - - this.mixin('i'); - this.mixin('api'); - - if (this.SIGNIN) { - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - } - - this.isOpen = false; - - this.on('mount', () => { - if (this.SIGNIN) { - this.connection.on('read_all_notifications', this.onReadAllNotifications); - this.connection.on('unread_notification', this.onUnreadNotification); - - // Fetch count of unread notifications - this.api('notifications/get_unread_count').then(res => { - if (res.count > 0) { - this.update({ - hasUnreadNotifications: true - }); - } - }); - } - }); - - this.on('unmount', () => { - if (this.SIGNIN) { - this.connection.off('read_all_notifications', this.onReadAllNotifications); - this.connection.off('unread_notification', this.onUnreadNotification); - this.stream.dispose(this.connectionId); - } - }); - - this.onReadAllNotifications = () => { - this.update({ - hasUnreadNotifications: false - }); - }; - - this.onUnreadNotification = () => { - this.update({ - hasUnreadNotifications: true - }); - }; - - this.toggle = () => { - this.isOpen ? this.close() : this.open(); - }; - - this.open = () => { - this.update({ - isOpen: true - }); - document.querySelectorAll('body *').forEach(el => { - el.addEventListener('mousedown', this.mousedown); - }); - }; - - this.close = () => { - this.update({ - isOpen: false - }); - document.querySelectorAll('body *').forEach(el => { - el.removeEventListener('mousedown', this.mousedown); - }); - }; - - this.mousedown = e => { - e.preventDefault(); - if (!contains(this.root, e.target) && this.root != e.target) this.close(); - return false; - }; - </script> -</mk-ui-header-notifications> - -<mk-ui-header-nav> - <ul> - <virtual if={ SIGNIN }> - <li class="home { active: page == 'home' }"> - <a href={ _URL_ }> - %fa:home% - <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p> - </a> - </li> - <li class="messaging"> - <a onclick={ messaging }> - %fa:comments% - <p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p> - <virtual if={ hasUnreadMessagingMessages }>%fa:circle%</virtual> - </a> - </li> - </virtual> - <li class="ch"> - <a href={ _CH_URL_ } target="_blank"> - %fa:tv% - <p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p> - </a> - </li> - <li class="info"> - <a href="https://twitter.com/misskey_xyz" target="_blank"> - %fa:info% - <p>%i18n:desktop.tags.mk-ui-header-nav.info%</p> - </a> - </li> - </ul> - <style> - :scope - 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> - <script> - this.mixin('i'); - this.mixin('api'); - - if (this.SIGNIN) { - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - } - - this.page = this.opts.page; - - this.on('mount', () => { - if (this.SIGNIN) { - this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.connection.on('unread_messaging_message', this.onUnreadMessagingMessage); - - // Fetch count of unread messaging messages - this.api('messaging/unread').then(res => { - if (res.count > 0) { - this.update({ - hasUnreadMessagingMessages: true - }); - } - }); - } - }); - - this.on('unmount', () => { - if (this.SIGNIN) { - this.connection.off('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.connection.off('unread_messaging_message', this.onUnreadMessagingMessage); - this.stream.dispose(this.connectionId); - } - }); - - this.onReadAllMessagingMessages = () => { - this.update({ - hasUnreadMessagingMessages: false - }); - }; - - this.onUnreadMessagingMessage = () => { - this.update({ - hasUnreadMessagingMessages: true - }); - }; - - this.messaging = () => { - riot.mount(document.body.appendChild(document.createElement('mk-messaging-window'))); - }; - </script> -</mk-ui-header-nav> - -<mk-ui-header-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> - <style> - :scope - 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> - <script> - this.now = new Date(); - - this.draw = () => { - const now = this.now = new Date(); - this.yyyy = now.getFullYear(); - this.mm = ('0' + (now.getMonth() + 1)).slice(-2); - this.dd = ('0' + now.getDate()).slice(-2); - this.hh = ('0' + now.getHours()).slice(-2); - this.nn = ('0' + now.getMinutes()).slice(-2); - this.update(); - }; - - this.on('mount', () => { - this.draw(); - this.clock = setInterval(this.draw, 1000); - }); - - this.on('unmount', () => { - clearInterval(this.clock); - }); - </script> -</mk-ui-header-clock> - -<mk-ui-header-account> - <button class="header" data-active={ isOpen.toString() } onclick={ toggle }> - <span class="username">{ I.username }<virtual if={ !isOpen }>%fa:angle-down%</virtual><virtual if={ isOpen }>%fa:angle-up%</virtual></span> - <img class="avatar" src={ I.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </button> - <div class="menu" if={ isOpen }> - <ul> - <li> - <a href={ '/' + I.username }>%fa:user%%i18n:desktop.tags.mk-ui-header-account.profile%%fa:angle-right%</a> - </li> - <li onclick={ 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 onclick={ settings }> - <p>%fa:cog%%i18n:desktop.tags.mk-ui-header-account.settings%%fa:angle-right%</p> - </li> - </ul> - <ul> - <li onclick={ signout }> - <p>%fa:power-off%%i18n:desktop.tags.mk-ui-header-account.signout%%fa:angle-right%</p> - </li> - </ul> - </div> - <style> - :scope - display block - float left - - > .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 - - </style> - <script> - import contains from '../../common/scripts/contains'; - import signout from '../../common/scripts/signout'; - this.signout = signout; - - this.mixin('i'); - - this.isOpen = false; - - this.on('before-unmount', () => { - this.close(); - }); - - this.toggle = () => { - this.isOpen ? this.close() : this.open(); - }; - - this.open = () => { - this.update({ - isOpen: true - }); - document.querySelectorAll('body *').forEach(el => { - el.addEventListener('mousedown', this.mousedown); - }); - }; - - this.close = () => { - this.update({ - isOpen: false - }); - document.querySelectorAll('body *').forEach(el => { - el.removeEventListener('mousedown', this.mousedown); - }); - }; - - this.mousedown = e => { - e.preventDefault(); - if (!contains(this.root, e.target) && this.root != e.target) this.close(); - return false; - }; - - this.drive = () => { - this.close(); - riot.mount(document.body.appendChild(document.createElement('mk-drive-browser-window'))); - }; - - this.settings = () => { - this.close(); - riot.mount(document.body.appendChild(document.createElement('mk-settings-window'))); - }; - - </script> -</mk-ui-header-account> - -<mk-ui-notification> - <p>{ opts.message }</p> - <style> - :scope - 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> - <script> - import anime from 'animejs'; - - this.on('mount', () => { - anime({ - targets: this.root, - opacity: 1, - translateY: [-64, 0], - easing: 'easeOutElastic', - duration: 500 - }); - - setTimeout(() => { - anime({ - targets: this.root, - opacity: 0, - translateY: -64, - duration: 500, - easing: 'easeInElastic', - complete: () => this.unmount() - }); - }, 6000); - }); - </script> -</mk-ui-notification> diff --git a/src/web/app/desktop/tags/user-followers-window.tag b/src/web/app/desktop/tags/user-followers-window.tag deleted file mode 100644 index 43127a68a8..0000000000 --- a/src/web/app/desktop/tags/user-followers-window.tag +++ /dev/null @@ -1,19 +0,0 @@ -<mk-user-followers-window> - <mk-window is-modal={ false } width={ '400px' } height={ '550px' }><yield to="header"><img src={ parent.user.avatar_url + '?thumbnail&size=64' } alt=""/>{ parent.user.name }のフォロワー</yield> -<yield to="content"> - <mk-user-followers user={ parent.user }/></yield> - </mk-window> - <style> - :scope - > mk-window - [data-yield='header'] - > img - display inline-block - vertical-align bottom - height calc(100% - 10px) - margin 5px - border-radius 4px - - </style> - <script>this.user = this.opts.user</script> -</mk-user-followers-window> diff --git a/src/web/app/desktop/tags/user-followers.tag b/src/web/app/desktop/tags/user-followers.tag deleted file mode 100644 index ea670e2729..0000000000 --- a/src/web/app/desktop/tags/user-followers.tag +++ /dev/null @@ -1,23 +0,0 @@ -<mk-user-followers> - <mk-users-list fetch={ fetch } count={ user.followers_count } you-know-count={ user.followers_you_know_count } no-users={ 'フォロワーはいないようです。' }/> - <style> - :scope - display block - height 100% - - </style> - <script> - this.mixin('api'); - - this.user = this.opts.user; - - this.fetch = (iknow, limit, cursor, cb) => { - this.api('users/followers', { - user_id: this.user.id, - iknow: iknow, - limit: limit, - cursor: cursor ? cursor : undefined - }).then(cb); - }; - </script> -</mk-user-followers> diff --git a/src/web/app/desktop/tags/user-following-window.tag b/src/web/app/desktop/tags/user-following-window.tag deleted file mode 100644 index 10a84db315..0000000000 --- a/src/web/app/desktop/tags/user-following-window.tag +++ /dev/null @@ -1,19 +0,0 @@ -<mk-user-following-window> - <mk-window is-modal={ false } width={ '400px' } height={ '550px' }><yield to="header"><img src={ parent.user.avatar_url + '?thumbnail&size=64' } alt=""/>{ parent.user.name }のフォロー</yield> -<yield to="content"> - <mk-user-following user={ parent.user }/></yield> - </mk-window> - <style> - :scope - > mk-window - [data-yield='header'] - > img - display inline-block - vertical-align bottom - height calc(100% - 10px) - margin 5px - border-radius 4px - - </style> - <script>this.user = this.opts.user</script> -</mk-user-following-window> diff --git a/src/web/app/desktop/tags/user-following.tag b/src/web/app/desktop/tags/user-following.tag deleted file mode 100644 index 4523beac2c..0000000000 --- a/src/web/app/desktop/tags/user-following.tag +++ /dev/null @@ -1,23 +0,0 @@ -<mk-user-following> - <mk-users-list fetch={ fetch } count={ user.following_count } you-know-count={ user.following_you_know_count } no-users={ 'フォロー中のユーザーはいないようです。' }/> - <style> - :scope - display block - height 100% - - </style> - <script> - this.mixin('api'); - - this.user = this.opts.user; - - this.fetch = (iknow, limit, cursor, cb) => { - this.api('users/following', { - user_id: this.user.id, - iknow: iknow, - limit: limit, - cursor: cursor ? cursor : undefined - }).then(cb); - }; - </script> -</mk-user-following> diff --git a/src/web/app/desktop/tags/user-preview.tag b/src/web/app/desktop/tags/user-preview.tag deleted file mode 100644 index b836ff1e78..0000000000 --- a/src/web/app/desktop/tags/user-preview.tag +++ /dev/null @@ -1,149 +0,0 @@ -<mk-user-preview> - <virtual if={ user != null }> - <div class="banner" style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=512)' : '' }></div><a class="avatar" href={ '/' + user.username } target="_blank"><img src={ user.avatar_url + '?thumbnail&size=64' } alt="avatar"/></a> - <div class="title"> - <p class="name">{ user.name }</p> - <p class="username">@{ user.username }</p> - </div> - <div class="description">{ user.description }</div> - <div class="status"> - <div> - <p>投稿</p><a>{ user.posts_count }</a> - </div> - <div> - <p>フォロー</p><a>{ user.following_count }</a> - </div> - <div> - <p>フォロワー</p><a>{ user.followers_count }</a> - </div> - </div> - <mk-follow-button if={ SIGNIN && user.id != I.id } user={ userPromise }/> - </virtual> - <style> - :scope - display block - 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 - - > 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 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> - <script> - import anime from 'animejs'; - - this.mixin('i'); - this.mixin('api'); - - this.u = this.opts.user; - this.user = null; - this.userPromise = - typeof this.u == 'string' ? - new Promise((resolve, reject) => { - this.api('users/show', { - user_id: this.u[0] == '@' ? undefined : this.u, - username: this.u[0] == '@' ? this.u.substr(1) : undefined - }).then(resolve); - }) - : Promise.resolve(this.u); - - this.on('mount', () => { - this.userPromise.then(user => { - this.update({ - user: user - }); - this.open(); - }); - }); - - this.open = () => { - anime({ - targets: this.root, - opacity: 1, - 'margin-top': 0, - duration: 200, - easing: 'easeOutQuad' - }); - }; - - this.close = () => { - anime({ - targets: this.root, - opacity: 0, - 'margin-top': '-8px', - duration: 200, - easing: 'easeOutQuad', - complete: () => this.unmount() - }); - }; - </script> -</mk-user-preview> diff --git a/src/web/app/desktop/tags/user-timeline.tag b/src/web/app/desktop/tags/user-timeline.tag deleted file mode 100644 index 134aeee28c..0000000000 --- a/src/web/app/desktop/tags/user-timeline.tag +++ /dev/null @@ -1,150 +0,0 @@ -<mk-user-timeline> - <header> - <span data-is-active={ mode == 'default' } onclick={ setMode.bind(this, 'default') }>投稿</span><span data-is-active={ mode == 'with-replies' } onclick={ setMode.bind(this, 'with-replies') }>投稿と返信</span> - </header> - <div class="loading" if={ isLoading }> - <mk-ellipsis-icon/> - </div> - <p class="empty" if={ isEmpty }>%fa:R comments%このユーザーはまだ何も投稿していないようです。</p> - <mk-timeline ref="timeline"> - <yield to="footer"> - <virtual if={ !parent.moreLoading }>%fa:moon%</virtual> - <virtual if={ parent.moreLoading }>%fa:spinner .pulse .fw%</virtual> - </yield/> - </mk-timeline> - <style> - :scope - display block - 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> - <script> - import isPromise from '../../common/scripts/is-promise'; - - this.mixin('api'); - - this.user = null; - this.userPromise = isPromise(this.opts.user) - ? this.opts.user - : Promise.resolve(this.opts.user); - this.isLoading = true; - this.isEmpty = false; - this.moreLoading = false; - this.unreadCount = 0; - this.mode = 'default'; - - this.on('mount', () => { - document.addEventListener('keydown', this.onDocumentKeydown); - window.addEventListener('scroll', this.onScroll); - - this.userPromise.then(user => { - this.update({ - user: user - }); - - this.fetch(() => this.trigger('loaded')); - }); - }); - - this.on('unmount', () => { - document.removeEventListener('keydown', this.onDocumentKeydown); - window.removeEventListener('scroll', this.onScroll); - }); - - this.onDocumentKeydown = e => { - if (e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') { - if (e.which == 84) { // [t] - this.refs.timeline.focus(); - } - } - }; - - this.fetch = cb => { - this.api('users/posts', { - user_id: this.user.id, - until_date: this.date ? this.date.getTime() : undefined, - with_replies: this.mode == 'with-replies' - }).then(posts => { - this.update({ - isLoading: false, - isEmpty: posts.length == 0 - }); - this.refs.timeline.setPosts(posts); - if (cb) cb(); - }); - }; - - this.more = () => { - if (this.moreLoading || this.isLoading || this.refs.timeline.posts.length == 0) return; - this.update({ - moreLoading: true - }); - this.api('users/posts', { - user_id: this.user.id, - with_replies: this.mode == 'with-replies', - until_id: this.refs.timeline.tail().id - }).then(posts => { - this.update({ - moreLoading: false - }); - this.refs.timeline.prependPosts(posts); - }); - }; - - this.onScroll = () => { - const current = window.scrollY + window.innerHeight; - if (current > document.body.offsetHeight - 16/*遊び*/) { - this.more(); - } - }; - - this.setMode = mode => { - this.update({ - mode: mode - }); - this.fetch(); - }; - - this.warp = date => { - this.update({ - date: date - }); - - this.fetch(); - }; - </script> -</mk-user-timeline> diff --git a/src/web/app/desktop/tags/user.tag b/src/web/app/desktop/tags/user.tag deleted file mode 100644 index b29d1eaebc..0000000000 --- a/src/web/app/desktop/tags/user.tag +++ /dev/null @@ -1,852 +0,0 @@ -<mk-user> - <div class="user" if={ !fetching }> - <header> - <mk-user-header user={ user }/> - </header> - <mk-user-home if={ page == 'home' } user={ user }/> - <mk-user-graphs if={ page == 'graphs' } user={ user }/> - </div> - <style> - :scope - display block - - > .user - > header - > mk-user-header - overflow hidden - - </style> - <script> - this.mixin('api'); - - this.username = this.opts.user; - this.page = this.opts.page ? this.opts.page : 'home'; - this.fetching = true; - this.user = null; - - this.on('mount', () => { - this.api('users/show', { - username: this.username - }).then(user => { - this.update({ - fetching: false, - user: user - }); - this.trigger('loaded'); - }); - }); - </script> -</mk-user> - -<mk-user-header data-is-dark-background={ user.banner_url != null }> - <div class="banner-container" style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=2048)' : '' }> - <div class="banner" ref="banner" style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=2048)' : '' } onclick={ onUpdateBanner }></div> - </div> - <div class="fade"></div> - <div class="container"> - <img class="avatar" src={ user.avatar_url + '?thumbnail&size=150' } alt="avatar"/> - <div class="title"> - <p class="name" href={ '/' + user.username }>{ user.name }</p> - <p class="username">@{ user.username }</p> - <p class="location" if={ user.profile.location }>%fa:map-marker%{ user.profile.location }</p> - </div> - <footer> - <a href={ '/' + user.username } data-active={ parent.page == 'home' }>%fa:home%概要</a> - <a href={ '/' + user.username + '/media' } data-active={ parent.page == 'media' }>%fa:image%メディア</a> - <a href={ '/' + user.username + '/graphs' } data-active={ parent.page == 'graphs' }>%fa:chart-bar%グラフ</a> - </footer> - </div> - <style> - :scope - $banner-height = 320px - $footer-height = 58px - - display block - 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> - <script> - import updateBanner from '../scripts/update-banner'; - - this.mixin('i'); - - this.user = this.opts.user; - - this.on('mount', () => { - window.addEventListener('load', this.scroll); - window.addEventListener('scroll', this.scroll); - window.addEventListener('resize', this.scroll); - }); - - this.on('unmount', () => { - window.removeEventListener('load', this.scroll); - window.removeEventListener('scroll', this.scroll); - window.removeEventListener('resize', this.scroll); - }); - - this.scroll = () => { - const top = window.scrollY; - - const z = 1.25; // 奥行き(小さいほど奥) - const pos = -(top / z); - this.refs.banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; - - const blur = top / 32 - if (blur <= 10) this.refs.banner.style.filter = `blur(${blur}px)`; - }; - - this.onUpdateBanner = () => { - if (!this.SIGNIN || this.I.id != this.user.id) return; - - updateBanner(this.I, i => { - this.user.banner_url = i.banner_url; - this.update(); - }); - }; - </script> -</mk-user-header> - -<mk-user-profile> - <div class="friend-form" if={ SIGNIN && I.id != user.id }> - <mk-big-follow-button user={ user }/> - <p class="followed" if={ user.is_followed }>%i18n:desktop.tags.mk-user.follows-you%</p> - <p if={ user.is_muted }>%i18n:desktop.tags.mk-user.muted% <a onclick={ unmute }>%i18n:desktop.tags.mk-user.unmute%</a></p> - <p if={ !user.is_muted }><a onclick={ mute }>%i18n:desktop.tags.mk-user.mute%</a></p> - </div> - <div class="description" if={ user.description }>{ user.description }</div> - <div class="birthday" if={ user.profile.birthday }> - <p>%fa:birthday-cake%{ user.profile.birthday.replace('-', '年').replace('-', '月') + '日' } ({ age(user.profile.birthday) }歳)</p> - </div> - <div class="twitter" if={ user.twitter }> - <p>%fa:B twitter%<a href={ 'https://twitter.com/' + user.twitter.screen_name } target="_blank">@{ user.twitter.screen_name }</a></p> - </div> - <div class="status"> - <p class="posts-count">%fa:angle-right%<a>{ user.posts_count }</a><b>ポスト</b></p> - <p class="following">%fa:angle-right%<a onclick={ showFollowing }>{ user.following_count }</a>人を<b>フォロー</b></p> - <p class="followers">%fa:angle-right%<a onclick={ showFollowers }>{ user.followers_count }</a>人の<b>フォロワー</b></p> - </div> - <style> - :scope - display block - 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> - <script> - this.age = require('s-age'); - - this.mixin('i'); - this.mixin('api'); - - this.user = this.opts.user; - - this.showFollowing = () => { - riot.mount(document.body.appendChild(document.createElement('mk-user-following-window')), { - user: this.user - }); - }; - - this.showFollowers = () => { - riot.mount(document.body.appendChild(document.createElement('mk-user-followers-window')), { - user: this.user - }); - }; - - this.mute = () => { - this.api('mute/create', { - user_id: this.user.id - }).then(() => { - this.user.is_muted = true; - this.update(); - }, e => { - alert('error'); - }); - }; - - this.unmute = () => { - this.api('mute/delete', { - user_id: this.user.id - }).then(() => { - this.user.is_muted = false; - this.update(); - }, e => { - alert('error'); - }); - }; - </script> -</mk-user-profile> - -<mk-user-photos> - <p class="title">%fa:camera%%i18n:desktop.tags.mk-user.photos.title%</p> - <p class="initializing" if={ initializing }>%fa:spinner .pulse .fw%%i18n:desktop.tags.mk-user.photos.loading%<mk-ellipsis/></p> - <div class="stream" if={ !initializing && images.length > 0 }> - <virtual each={ image in images }> - <div class="img" style={ 'background-image: url(' + image.url + '?thumbnail&size=256)' }></div> - </virtual> - </div> - <p class="empty" if={ !initializing && images.length == 0 }>%i18n:desktop.tags.mk-user.photos.no-photos%</p> - <style> - :scope - display block - 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> - <script> - import isPromise from '../../common/scripts/is-promise'; - - this.mixin('api'); - - this.images = []; - this.initializing = true; - this.user = null; - this.userPromise = isPromise(this.opts.user) - ? this.opts.user - : Promise.resolve(this.opts.user); - - this.on('mount', () => { - this.userPromise.then(user => { - this.update({ - user: user - }); - - this.api('users/posts', { - user_id: this.user.id, - with_media: true, - limit: 9 - }).then(posts => { - this.initializing = false; - posts.forEach(post => { - post.media.forEach(media => { - if (this.images.length < 9) this.images.push(media); - }); - }); - this.update(); - }); - }); - }); - </script> -</mk-user-photos> - -<mk-user-frequently-replied-users> - <p class="title">%fa:users%%i18n:desktop.tags.mk-user.frequently-replied-users.title%</p> - <p class="initializing" if={ initializing }>%fa:spinner .pulse .fw%%i18n:desktop.tags.mk-user.frequently-replied-users.loading%<mk-ellipsis/></p> - <div class="user" if={ !initializing && users.length != 0 } each={ _user in users }> - <a class="avatar-anchor" href={ '/' + _user.username }> - <img class="avatar" src={ _user.avatar_url + '?thumbnail&size=42' } alt="" data-user-preview={ _user.id }/> - </a> - <div class="body"> - <a class="name" href={ '/' + _user.username } data-user-preview={ _user.id }>{ _user.name }</a> - <p class="username">@{ _user.username }</p> - </div> - <mk-follow-button user={ _user }/> - </div> - <p class="empty" if={ !initializing && users.length == 0 }>%i18n:desktop.tags.mk-user.frequently-replied-users.no-users%</p> - <style> - :scope - display block - 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> - <script> - this.mixin('api'); - - this.user = this.opts.user; - this.initializing = true; - - this.on('mount', () => { - this.api('users/get_frequently_replied_users', { - user_id: this.user.id, - limit: 4 - }).then(docs => { - this.update({ - users: docs.map(doc => doc.user), - initializing: false - }); - }); - }); - </script> -</mk-user-frequently-replied-users> - -<mk-user-followers-you-know> - <p class="title">%fa:users%%i18n:desktop.tags.mk-user.followers-you-know.title%</p> - <p class="initializing" if={ initializing }>%fa:spinner .pulse .fw%%i18n:desktop.tags.mk-user.followers-you-know.loading%<mk-ellipsis/></p> - <div if={ !initializing && users.length > 0 }> - <virtual each={ user in users }> - <a href={ '/' + user.username }><img src={ user.avatar_url + '?thumbnail&size=64' } alt={ user.name }/></a> - </virtual> - </div> - <p class="empty" if={ !initializing && users.length == 0 }>%i18n:desktop.tags.mk-user.followers-you-know.no-users%</p> - <style> - :scope - display block - 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> - <script> - this.mixin('api'); - - this.user = this.opts.user; - this.initializing = true; - - this.on('mount', () => { - this.api('users/followers', { - user_id: this.user.id, - iknow: true, - limit: 16 - }).then(x => { - this.update({ - users: x.users, - initializing: false - }); - }); - }); - </script> -</mk-user-followers-you-know> - -<mk-user-home> - <div> - <div ref="left"> - <mk-user-profile user={ user }/> - <mk-user-photos user={ user }/> - <mk-user-followers-you-know if={ SIGNIN && I.id !== user.id } user={ user }/> - <p>%i18n:desktop.tags.mk-user.last-used-at%: <b><mk-time time={ user.last_used_at }/></b></p> - </div> - </div> - <main> - <mk-post-detail if={ user.pinned_post } post={ user.pinned_post } compact={ true }/> - <mk-user-timeline ref="tl" user={ user }/> - </main> - <div> - <div ref="right"> - <mk-calendar-widget warp={ warp } start={ new Date(user.created_at) }/> - <mk-activity-widget user={ user }/> - <mk-user-frequently-replied-users user={ user }/> - <div class="nav"><mk-nav-links/></div> - </div> - </div> - <style> - :scope - 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) - - > mk-user-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> - <script> - import ScrollFollower from '../scripts/scroll-follower'; - - this.mixin('i'); - - this.user = this.opts.user; - - this.on('mount', () => { - this.refs.tl.on('loaded', () => { - this.trigger('loaded'); - }); - - this.scrollFollowerLeft = new ScrollFollower(this.refs.left, this.parent.root.getBoundingClientRect().top); - this.scrollFollowerRight = new ScrollFollower(this.refs.right, this.parent.root.getBoundingClientRect().top); - }); - - this.on('unmount', () => { - this.scrollFollowerLeft.dispose(); - this.scrollFollowerRight.dispose(); - }); - - this.warp = date => { - this.refs.tl.warp(date); - }; - </script> -</mk-user-home> - -<mk-user-graphs> - <section> - <div> - <h1>%fa:pencil-alt%投稿</h1> - <mk-user-graphs-activity-chart user={ opts.user }/> - </div> - </section> - <section> - <div> - <h1>フォロー/フォロワー</h1> - <mk-user-friends-graph user={ opts.user }/> - </div> - </section> - <section> - <div> - <h1>いいね</h1> - <mk-user-likes-graph user={ opts.user }/> - </div> - </section> - <style> - :scope - display block - - > section - margin 16px 0 - color #666 - border-bottom solid 1px rgba(0, 0, 0, 0.1) - - > div - max-width 1200px - margin 0 auto - padding 0 16px - - > h1 - margin 0 0 16px 0 - padding 0 - font-size 1.3em - - > i - margin-right 8px - - </style> - <script> - this.on('mount', () => { - this.trigger('loaded'); - }); - </script> -</mk-user-graphs> - -<mk-user-graphs-activity-chart> - <svg if={ data } ref="canvas" viewBox="0 0 365 1" preserveAspectRatio="none"> - <g each={ d, i in data.reverse() }> - <rect width="0.8" riot-height={ d.postsH } - riot-x={ i + 0.1 } riot-y={ 1 - d.postsH - d.repliesH - d.repostsH } - fill="#41ddde"/> - <rect width="0.8" riot-height={ d.repliesH } - riot-x={ i + 0.1 } riot-y={ 1 - d.repliesH - d.repostsH } - fill="#f7796c"/> - <rect width="0.8" riot-height={ d.repostsH } - riot-x={ i + 0.1 } riot-y={ 1 - d.repostsH } - fill="#a1de41"/> - </g> - </svg> - <p>直近1年間分の統計です。一番右が現在で、一番左が1年前です。青は通常の投稿、赤は返信、緑はRepostをそれぞれ表しています。</p> - <p> - <span>だいたい*1日に<b>{ averageOfAllTypePostsEachDays }回</b>投稿(返信、Repost含む)しています。</span><br> - <span>だいたい*1日に<b>{ averageOfPostsEachDays }回</b>投稿(通常の)しています。</span><br> - <span>だいたい*1日に<b>{ averageOfRepliesEachDays }回</b>返信しています。</span><br> - <span>だいたい*1日に<b>{ averageOfRepostsEachDays }回</b>Repostしています。</span><br> - </p> - <p>* 中央値</p> - - <style> - :scope - display block - - > svg - display block - width 100% - height 180px - - > rect - transform-origin center - - </style> - <script> - import getMedian from '../../common/scripts/get-median'; - - this.mixin('api'); - - this.user = this.opts.user; - - this.on('mount', () => { - this.api('aggregation/users/activity', { - user_id: this.user.id, - limit: 365 - }).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; - }); - - this.update({ - data, - averageOfAllTypePostsEachDays: getMedian(data.map(d => d.total)), - averageOfPostsEachDays: getMedian(data.map(d => d.posts)), - averageOfRepliesEachDays: getMedian(data.map(d => d.replies)), - averageOfRepostsEachDays: getMedian(data.map(d => d.reposts)) - }); - }); - }); - </script> -</mk-user-graphs-activity-chart> diff --git a/src/web/app/desktop/tags/users-list.tag b/src/web/app/desktop/tags/users-list.tag deleted file mode 100644 index ec9c7d8c7b..0000000000 --- a/src/web/app/desktop/tags/users-list.tag +++ /dev/null @@ -1,138 +0,0 @@ -<mk-users-list> - <nav> - <div> - <span data-is-active={ mode == 'all' } onclick={ setMode.bind(this, 'all') }>すべて<span>{ opts.count }</span></span> - <span if={ SIGNIN && opts.youKnowCount } data-is-active={ mode == 'iknow' } onclick={ setMode.bind(this, 'iknow') }>知り合い<span>{ opts.youKnowCount }</span></span> - </div> - </nav> - <div class="users" if={ !fetching && users.length != 0 }> - <div each={ users }> - <mk-list-user user={ this }/> - </div> - </div> - <button class="more" if={ !fetching && next != null } onclick={ more } disabled={ moreFetching }> - <span if={ !moreFetching }>もっと</span> - <span if={ moreFetching }>読み込み中<mk-ellipsis/></span> - </button> - <p class="no" if={ !fetching && users.length == 0 }>{ opts.noUsers }</p> - <p class="fetching" if={ fetching }>%fa:spinner .pulse .fw%読み込んでいます<mk-ellipsis/></p> - <style> - :scope - display block - 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> - <script> - this.mixin('i'); - - this.limit = 30; - this.mode = 'all'; - - this.fetching = true; - this.moreFetching = false; - - this.on('mount', () => { - this.fetch(() => this.trigger('loaded')); - }); - - this.fetch = cb => { - this.update({ - fetching: true - }); - this.opts.fetch(this.mode == 'iknow', this.limit, null, obj => { - this.update({ - fetching: false, - users: obj.users, - next: obj.next - }); - if (cb) cb(); - }); - }; - - this.more = () => { - this.update({ - moreFetching: true - }); - this.opts.fetch(this.mode == 'iknow', this.limit, this.cursor, obj => { - this.update({ - moreFetching: false, - users: this.users.concat(obj.users), - next: obj.next - }); - }); - }; - - this.setMode = mode => { - this.update({ - mode: mode - }); - this.fetch(); - }; - </script> -</mk-users-list> diff --git a/src/web/app/desktop/tags/widgets/activity.tag b/src/web/app/desktop/tags/widgets/activity.tag deleted file mode 100644 index e8c8a47632..0000000000 --- a/src/web/app/desktop/tags/widgets/activity.tag +++ /dev/null @@ -1,246 +0,0 @@ -<mk-activity-widget data-melt={ design == 2 }> - <virtual if={ design == 0 }> - <p class="title">%fa:chart-bar%%i18n:desktop.tags.mk-activity-widget.title%</p> - <button onclick={ toggle } title="%i18n:desktop.tags.mk-activity-widget.toggle%">%fa:sort%</button> - </virtual> - <p class="initializing" if={ initializing }>%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <mk-activity-widget-calender if={ !initializing && view == 0 } data={ [].concat(activity) }/> - <mk-activity-widget-chart if={ !initializing && view == 1 } data={ [].concat(activity) }/> - <style> - :scope - display block - 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 - - > .initializing - margin 0 - padding 16px - text-align center - color #aaa - - > [data-fa] - margin-right 4px - - </style> - <script> - this.mixin('api'); - - this.design = this.opts.design || 0; - this.view = this.opts.view || 0; - - this.user = this.opts.user; - this.initializing = true; - - this.on('mount', () => { - this.api('aggregation/users/activity', { - user_id: this.user.id, - limit: 20 * 7 - }).then(activity => { - this.update({ - initializing: false, - activity - }); - }); - }); - - this.toggle = () => { - this.view++; - if (this.view == 2) this.view = 0; - this.update(); - this.trigger('view-changed', this.view); - }; - </script> -</mk-activity-widget> - -<mk-activity-widget-calender> - <svg viewBox="0 0 21 7" preserveAspectRatio="none"> - <rect each={ data } class="day" - width="1" height="1" - riot-x={ x } riot-y={ date.weekday } - rx="1" ry="1" - fill="transparent"> - <title>{ date.year }/{ date.month }/{ date.day }<br/>Post: { posts }, Reply: { replies }, Repost: { reposts }</title> - </rect> - <rect each={ data } - riot-width={ v } riot-height={ v } - riot-x={ x + ((1 - v) / 2) } riot-y={ date.weekday + ((1 - v) / 2) } - rx="1" ry="1" - fill={ color } - style="pointer-events: none;"/> - <rect class="today" - width="1" height="1" - riot-x={ data[data.length - 1].x } riot-y={ data[data.length - 1].date.weekday } - rx="1" ry="1" - fill="none" - stroke-width="0.1" - stroke="#f73520"/> - </svg> - <style> - :scope - display block - - > svg - display block - padding 10px - width 100% - - > rect - transform-origin center - - &.day - &:hover - fill rgba(0, 0, 0, 0.05) - - </style> - <script> - this.data = this.opts.data; - 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 = 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> -</mk-activity-widget-calender> - -<mk-activity-widget-chart> - <svg riot-viewBox="0 0 { viewBoxX } { viewBoxY }" preserveAspectRatio="none" onmousedown={ onMousedown }> - <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> - :scope - display block - - > svg - display block - padding 10px - width 100% - cursor all-scroll - </style> - <script> - this.viewBoxX = 140; - this.viewBoxY = 60; - this.zoom = 1; - this.pos = 0; - - 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 * this.zoom) + this.pos},${(1 - (d.posts / peak)) * this.viewBoxY}`).join(' '), - pointsReply: this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' '), - pointsRepost: this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.reposts / peak)) * this.viewBoxY}`).join(' '), - pointsTotal: this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' ') - }); - }; - - this.onMousedown = e => { - e.preventDefault(); - - 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(); - }); - }; - - 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); - } - </script> -</mk-activity-widget-chart> - diff --git a/src/web/app/desktop/tags/widgets/calendar.tag b/src/web/app/desktop/tags/widgets/calendar.tag deleted file mode 100644 index abe9981873..0000000000 --- a/src/web/app/desktop/tags/widgets/calendar.tag +++ /dev/null @@ -1,241 +0,0 @@ -<mk-calendar-widget data-melt={ opts.design == 4 || opts.design == 5 }> - <virtual if={ opts.design == 0 || opts.design == 1 }> - <button onclick={ 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 onclick={ next } title="%i18n:desktop.tags.mk-calendar-widget.next%">%fa:chevron-circle-right%</button> - </virtual> - - <div class="calendar"> - <div class="weekday" if={ opts.design == 0 || opts.design == 2 || opts.design == 4} each={ 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> - <div each={ day, i in Array(paddingDays).fill(0) }></div> - <div class="day" each={ day, i in Array(days).fill(0) } - data-today={ isToday(i + 1) } - data-selected={ isSelected(i + 1) } - data-is-out-of-range={ isOutOfRange(i + 1) } - data-is-donichi={ isDonichi(i + 1) } - onclick={ go.bind(null, i + 1) } - title={ isOutOfRange(i + 1) ? null : '%i18n:desktop.tags.mk-calendar-widget.go%' }><div>{ i + 1 }</div></div> - </div> - <style> - :scope - display block - 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> - <script> - if (this.opts.design == null) this.opts.design = 0; - - 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; - } - - this.today = new Date(); - this.year = this.today.getFullYear(); - this.month = this.today.getMonth() + 1; - this.selected = this.today; - this.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%' - ]; - - this.on('mount', () => { - this.calc(); - }); - - this.isToday = day => { - return this.year == this.today.getFullYear() && this.month == this.today.getMonth() + 1 && day == this.today.getDate(); - }; - - this.isSelected = day => { - return this.year == this.selected.getFullYear() && this.month == this.selected.getMonth() + 1 && day == this.selected.getDate(); - }; - - this.isOutOfRange = day => { - const test = (new Date(this.year, this.month - 1, day)).getTime(); - return test > this.today.getTime() || - (this.opts.start ? test < this.opts.start.getTime() : false); - }; - - this.isDonichi = day => { - const weekday = (new Date(this.year, this.month - 1, day)).getDay(); - return weekday == 0 || weekday == 6; - }; - - this.calc = () => { - let days = eachMonthDays[this.month - 1]; - - // うるう年なら+1日 - if (this.month == 2 && isLeapYear(this.year)) days++; - - const date = new Date(this.year, this.month - 1, 1); - const weekday = date.getDay(); - - this.update({ - paddingDays: weekday, - days: days - }); - }; - - this.prev = () => { - if (this.month == 1) { - this.update({ - year: this.year - 1, - month: 12 - }); - } else { - this.update({ - month: this.month - 1 - }); - } - this.calc(); - }; - - this.next = () => { - if (this.month == 12) { - this.update({ - year: this.year + 1, - month: 1 - }); - } else { - this.update({ - month: this.month + 1 - }); - } - this.calc(); - }; - - this.go = day => { - if (this.isOutOfRange(day)) return; - const date = new Date(this.year, this.month - 1, day, 23, 59, 59, 999); - this.update({ - selected: date - }); - this.opts.warp(date); - }; -</script> -</mk-calendar-widget> diff --git a/src/web/app/desktop/tags/window.tag b/src/web/app/desktop/tags/window.tag deleted file mode 100644 index 5b4b3c83e4..0000000000 --- a/src/web/app/desktop/tags/window.tag +++ /dev/null @@ -1,549 +0,0 @@ -<mk-window data-flexible={ isFlexible } ondragover={ ondragover }> - <div class="bg" ref="bg" show={ isModal } onclick={ bgClick }></div> - <div class="main" ref="main" tabindex="-1" data-is-modal={ isModal } onmousedown={ onBodyMousedown } onkeydown={ onKeydown }> - <div class="body"> - <header ref="header" onmousedown={ onHeaderMousedown }> - <h1 data-yield="header"><yield from="header"/></h1> - <div> - <button class="popout" if={ popoutUrl } onmousedown={ repelMove } onclick={ popout } title="ポップアウト">%fa:R window-restore%</button> - <button class="close" if={ canClose } onmousedown={ repelMove } onclick={ close } title="閉じる">%fa:times%</button> - </div> - </header> - <div class="content" data-yield="content"><yield from="content"/></div> - </div> - <div class="handle top" if={ canResize } onmousedown={ onTopHandleMousedown }></div> - <div class="handle right" if={ canResize } onmousedown={ onRightHandleMousedown }></div> - <div class="handle bottom" if={ canResize } onmousedown={ onBottomHandleMousedown }></div> - <div class="handle left" if={ canResize } onmousedown={ onLeftHandleMousedown }></div> - <div class="handle top-left" if={ canResize } onmousedown={ onTopLeftHandleMousedown }></div> - <div class="handle top-right" if={ canResize } onmousedown={ onTopRightHandleMousedown }></div> - <div class="handle bottom-right" if={ canResize } onmousedown={ onBottomRightHandleMousedown }></div> - <div class="handle bottom-left" if={ canResize } onmousedown={ onBottomLeftHandleMousedown }></div> - </div> - <style> - :scope - display block - - > .bg - display block - position fixed - z-index 2048 - 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 2048 - 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 128 - 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) - - &, * - 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 1.2em - 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> - <script> - import anime from 'animejs'; - import contains from '../../common/scripts/contains'; - - this.minHeight = 40; - this.minWidth = 200; - - this.isModal = this.opts.isModal != null ? this.opts.isModal : false; - this.canClose = this.opts.canClose != null ? this.opts.canClose : true; - this.popoutUrl = this.opts.popout; - this.isFlexible = this.opts.height == null; - this.canResize = !this.isFlexible; - - this.on('mount', () => { - this.refs.main.style.width = this.opts.width || '530px'; - this.refs.main.style.height = this.opts.height || 'auto'; - - this.refs.main.style.top = '15%'; - this.refs.main.style.left = (window.innerWidth / 2) - (this.refs.main.offsetWidth / 2) + 'px'; - - this.refs.header.addEventListener('contextmenu', e => { - e.preventDefault(); - }); - - window.addEventListener('resize', this.onBrowserResize); - - this.open(); - }); - - this.on('unmount', () => { - window.removeEventListener('resize', this.onBrowserResize); - }); - - this.onBrowserResize = () => { - const position = this.refs.main.getBoundingClientRect(); - const browserWidth = window.innerWidth; - const browserHeight = window.innerHeight; - const windowWidth = this.refs.main.offsetWidth; - const windowHeight = this.refs.main.offsetHeight; - if (position.left < 0) this.refs.main.style.left = 0; - if (position.top < 0) this.refs.main.style.top = 0; - if (position.left + windowWidth > browserWidth) this.refs.main.style.left = browserWidth - windowWidth + 'px'; - if (position.top + windowHeight > browserHeight) this.refs.main.style.top = browserHeight - windowHeight + 'px'; - }; - - this.open = () => { - this.trigger('opening'); - - this.top(); - - if (this.isModal) { - this.refs.bg.style.pointerEvents = 'auto'; - anime({ - targets: this.refs.bg, - opacity: 1, - duration: 100, - easing: 'linear' - }); - } - - this.refs.main.style.pointerEvents = 'auto'; - anime({ - targets: this.refs.main, - opacity: 1, - scale: [1.1, 1], - duration: 200, - easing: 'easeOutQuad' - }); - - //this.refs.main.focus(); - - setTimeout(() => { - this.trigger('opened'); - }, 300); - }; - - this.popout = () => { - const position = this.refs.main.getBoundingClientRect(); - - const width = parseInt(getComputedStyle(this.refs.main, '').width, 10); - const height = parseInt(getComputedStyle(this.refs.main, '').height, 10); - const x = window.screenX + position.left; - const y = window.screenY + position.top; - - const url = typeof this.popoutUrl == 'function' ? this.popoutUrl() : this.popoutUrl; - - window.open(url, url, - `height=${height},width=${width},left=${x},top=${y}`); - - this.close(); - }; - - this.close = () => { - this.trigger('closing'); - - if (this.isModal) { - this.refs.bg.style.pointerEvents = 'none'; - anime({ - targets: this.refs.bg, - opacity: 0, - duration: 300, - easing: 'linear' - }); - } - - this.refs.main.style.pointerEvents = 'none'; - - anime({ - targets: this.refs.main, - opacity: 0, - scale: 0.8, - duration: 300, - easing: [0.5, -0.5, 1, 0.5] - }); - - setTimeout(() => { - this.trigger('closed'); - }, 300); - }; - - // 最前面へ移動します - this.top = () => { - let z = 0; - - const ws = document.querySelectorAll('mk-window'); - ws.forEach(w => { - if (w == this.root) return; - const m = w.querySelector(':scope > .main'); - const mz = Number(document.defaultView.getComputedStyle(m, null).zIndex); - if (mz > z) z = mz; - }); - - if (z > 0) { - this.refs.main.style.zIndex = z + 1; - if (this.isModal) this.refs.bg.style.zIndex = z + 1; - } - }; - - this.repelMove = e => { - e.stopPropagation(); - return true; - }; - - this.bgClick = () => { - if (this.canClose) this.close(); - }; - - this.onBodyMousedown = () => { - this.top(); - }; - - // ヘッダー掴み時 - this.onHeaderMousedown = e => { - e.preventDefault(); - - if (!contains(this.refs.main, document.activeElement)) this.refs.main.focus(); - - const position = this.refs.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 = this.refs.main.offsetWidth; - const windowHeight = this.refs.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; - - this.refs.main.style.left = moveLeft + 'px'; - this.refs.main.style.top = moveTop + 'px'; - }); - }; - - // 上ハンドル掴み時 - this.onTopHandleMousedown = e => { - e.preventDefault(); - - const base = e.clientY; - const height = parseInt(getComputedStyle(this.refs.main, '').height, 10); - const top = parseInt(getComputedStyle(this.refs.main, '').top, 10); - - // 動かした時 - dragListen(me => { - const move = me.clientY - base; - if (top + move > 0) { - if (height + -move > this.minHeight) { - this.applyTransformHeight(height + -move); - this.applyTransformTop(top + move); - } else { // 最小の高さより小さくなろうとした時 - this.applyTransformHeight(this.minHeight); - this.applyTransformTop(top + (height - this.minHeight)); - } - } else { // 上のはみ出し時 - this.applyTransformHeight(top + height); - this.applyTransformTop(0); - } - }); - }; - - // 右ハンドル掴み時 - this.onRightHandleMousedown = e => { - e.preventDefault(); - - const base = e.clientX; - const width = parseInt(getComputedStyle(this.refs.main, '').width, 10); - const left = parseInt(getComputedStyle(this.refs.main, '').left, 10); - const browserWidth = window.innerWidth; - - // 動かした時 - dragListen(me => { - const move = me.clientX - base; - if (left + width + move < browserWidth) { - if (width + move > this.minWidth) { - this.applyTransformWidth(width + move); - } else { // 最小の幅より小さくなろうとした時 - this.applyTransformWidth(this.minWidth); - } - } else { // 右のはみ出し時 - this.applyTransformWidth(browserWidth - left); - } - }); - }; - - // 下ハンドル掴み時 - this.onBottomHandleMousedown = e => { - e.preventDefault(); - - const base = e.clientY; - const height = parseInt(getComputedStyle(this.refs.main, '').height, 10); - const top = parseInt(getComputedStyle(this.refs.main, '').top, 10); - const browserHeight = window.innerHeight; - - // 動かした時 - dragListen(me => { - const move = me.clientY - base; - if (top + height + move < browserHeight) { - if (height + move > this.minHeight) { - this.applyTransformHeight(height + move); - } else { // 最小の高さより小さくなろうとした時 - this.applyTransformHeight(this.minHeight); - } - } else { // 下のはみ出し時 - this.applyTransformHeight(browserHeight - top); - } - }); - }; - - // 左ハンドル掴み時 - this.onLeftHandleMousedown = e => { - e.preventDefault(); - - const base = e.clientX; - const width = parseInt(getComputedStyle(this.refs.main, '').width, 10); - const left = parseInt(getComputedStyle(this.refs.main, '').left, 10); - - // 動かした時 - dragListen(me => { - const move = me.clientX - base; - if (left + move > 0) { - if (width + -move > this.minWidth) { - this.applyTransformWidth(width + -move); - this.applyTransformLeft(left + move); - } else { // 最小の幅より小さくなろうとした時 - this.applyTransformWidth(this.minWidth); - this.applyTransformLeft(left + (width - this.minWidth)); - } - } else { // 左のはみ出し時 - this.applyTransformWidth(left + width); - this.applyTransformLeft(0); - } - }); - }; - - // 左上ハンドル掴み時 - this.onTopLeftHandleMousedown = e => { - this.onTopHandleMousedown(e); - this.onLeftHandleMousedown(e); - }; - - // 右上ハンドル掴み時 - this.onTopRightHandleMousedown = e => { - this.onTopHandleMousedown(e); - this.onRightHandleMousedown(e); - }; - - // 右下ハンドル掴み時 - this.onBottomRightHandleMousedown = e => { - this.onBottomHandleMousedown(e); - this.onRightHandleMousedown(e); - }; - - // 左下ハンドル掴み時 - this.onBottomLeftHandleMousedown = e => { - this.onBottomHandleMousedown(e); - this.onLeftHandleMousedown(e); - }; - - // 高さを適用 - this.applyTransformHeight = height => { - this.refs.main.style.height = height + 'px'; - }; - - // 幅を適用 - this.applyTransformWidth = width => { - this.refs.main.style.width = width + 'px'; - }; - - // Y座標を適用 - this.applyTransformTop = top => { - this.refs.main.style.top = top + 'px'; - }; - - // X座標を適用 - this.applyTransformLeft = left => { - this.refs.main.style.left = left + 'px'; - }; - - 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); - } - - this.ondragover = e => { - e.dataTransfer.dropEffect = 'none'; - }; - - this.onKeydown = e => { - if (e.which == 27) { // Esc - if (this.canClose) { - e.preventDefault(); - e.stopPropagation(); - this.close(); - } - } - }; - - </script> -</mk-window> diff --git a/src/web/app/desktop/views/components/activity.calendar.vue b/src/web/app/desktop/views/components/activity.calendar.vue new file mode 100644 index 0000000000..72233e9aca --- /dev/null +++ b/src/web/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/web/app/desktop/views/components/activity.chart.vue b/src/web/app/desktop/views/components/activity.chart.vue new file mode 100644 index 0000000000..5057786ed4 --- /dev/null +++ b/src/web/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/web/app/desktop/views/components/activity.vue b/src/web/app/desktop/views/components/activity.vue new file mode 100644 index 0000000000..33b53eb700 --- /dev/null +++ b/src/web/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', { + user_id: 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/web/app/desktop/tags/analog-clock.tag b/src/web/app/desktop/views/components/analog-clock.vue similarity index 74% rename from src/web/app/desktop/tags/analog-clock.tag rename to src/web/app/desktop/views/components/analog-clock.vue index c0489d3feb..81eec81598 100644 --- a/src/web/app/desktop/tags/analog-clock.tag +++ b/src/web/app/desktop/views/components/analog-clock.vue @@ -1,36 +1,41 @@ -<mk-analog-clock> - <canvas ref="canvas" width="256" height="256"></canvas> - <style> - :scope - > canvas - display block - width 256px - height 256px - </style> - <script> - const Vec2 = function(x, y) { - this.x = x; - this.y = y; +<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; - this.on('mount', () => { - this.draw() - this.clock = setInterval(this.draw, 1000); - }); - - this.on('unmount', () => { - clearInterval(this.clock); - }); - - this.draw = () => { const now = new Date(); const s = now.getSeconds(); const m = now.getMinutes(); const h = now.getHours(); - const ctx = this.refs.canvas.getContext('2d'); - const canvW = this.refs.canvas.width; - const canvH = this.refs.canvas.height; + const ctx = canv.getContext('2d'); + const canvW = canv.width; + const canvH = canv.height; ctx.clearRect(0, 0, canvW, canvH); { // 背景 @@ -72,7 +77,7 @@ const length = Math.min(canvW, canvH) / 4; const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); ctx.beginPath(); - ctx.strokeStyle = _THEME_COLOR_; + 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); @@ -90,6 +95,14 @@ ctx.lineTo(canvW / 2 + uv.x * length, canvH / 2 + uv.y * length); ctx.stroke(); } - }; - </script> -</mk-analog-clock> + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-analog-clock + display block + width 256px + height 256px +</style> diff --git a/src/web/app/desktop/views/components/autocomplete.vue b/src/web/app/desktop/views/components/autocomplete.vue new file mode 100644 index 0000000000..a99d405e82 --- /dev/null +++ b/src/web/app/desktop/views/components/autocomplete.vue @@ -0,0 +1,190 @@ +<template> +<div class="mk-autocomplete"> + <ol class="users" ref="users" v-if="users.length > 0"> + <li v-for="user in users" @click="complete(user)" @keydown="onKeydown" tabindex="-1"> + <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=32`" alt=""/> + <span class="name">{{ user.name }}</span> + <span class="username">@{{ user.username }}</span> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import contains from '../../../common/scripts/contains'; + +export default Vue.extend({ + props: ['q', 'textarea', 'complete', 'close'], + data() { + return { + fetching: true, + users: [], + select: -1 + } + }, + mounted() { + this.textarea.addEventListener('keydown', this.onKeydown); + + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + + (this as any).api('users/search_by_username', { + query: this.q, + limit: 30 + }).then(users => { + this.users = users; + this.fetching = false; + }); + }, + beforeDestroy() { + this.textarea.removeEventListener('keydown', this.onKeydown); + + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + }, + methods: { + 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.complete(this.users[this.select]); + } 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: + this.close(); + } + }, + + selectNext() { + if (++this.select >= this.users.length) this.select = 0; + this.applySelect(); + }, + + selectPrev() { + if (--this.select < 0) this.select = this.users.length - 1; + this.applySelect(); + }, + + applySelect() { + const els = (this.$refs.users as Element).children; + + Array.from(els).forEach(el => { + el.removeAttribute('data-selected'); + }); + + els[this.select].setAttribute('data-selected', 'true'); + (els[this.select] as any).focus(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-autocomplete + position absolute + z-index 65535 + margin-top calc(1em + 8px) + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + + > .users + 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'] + 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 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 100% + + .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) + +</style> diff --git a/src/web/app/desktop/views/components/calendar.vue b/src/web/app/desktop/views/components/calendar.vue new file mode 100644 index 0000000000..08b08f8d42 --- /dev/null +++ b/src/web/app/desktop/views/components/calendar.vue @@ -0,0 +1,250 @@ +<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> +.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/web/app/desktop/views/components/choose-file-from-drive-window.vue b/src/web/app/desktop/views/components/choose-file-from-drive-window.vue new file mode 100644 index 0000000000..2322827459 --- /dev/null +++ b/src/web/app/desktop/views/components/choose-file-from-drive-window.vue @@ -0,0 +1,178 @@ +<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> +.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/web/app/desktop/views/components/choose-folder-from-drive-window.vue b/src/web/app/desktop/views/components/choose-folder-from-drive-window.vue new file mode 100644 index 0000000000..8111ffcf0d --- /dev/null +++ b/src/web/app/desktop/views/components/choose-folder-from-drive-window.vue @@ -0,0 +1,112 @@ +<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> +.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/web/app/desktop/views/components/context-menu.menu.vue b/src/web/app/desktop/views/components/context-menu.menu.vue new file mode 100644 index 0000000000..e2c34a5915 --- /dev/null +++ b/src/web/app/desktop/views/components/context-menu.menu.vue @@ -0,0 +1,119 @@ +<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> +.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/web/app/desktop/views/components/context-menu.vue b/src/web/app/desktop/views/components/context-menu.vue new file mode 100644 index 0000000000..8bd9945840 --- /dev/null +++ b/src/web/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/web/app/desktop/views/components/crop-window.vue b/src/web/app/desktop/views/components/crop-window.vue new file mode 100644 index 0000000000..27d89a9ff9 --- /dev/null +++ b/src/web/app/desktop/views/components/crop-window.vue @@ -0,0 +1,176 @@ +<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> +.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/web/app/desktop/views/components/dialog.vue b/src/web/app/desktop/views/components/dialog.vue new file mode 100644 index 0000000000..28f22f7b62 --- /dev/null +++ b/src/web/app/desktop/views/components/dialog.vue @@ -0,0 +1,159 @@ +<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', 'text', 'buttons', 'modal']/*{ + title: { + type: String + }, + text: { + type: String + }, + buttons: { + type: Array + }, + 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> +.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> +.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/web/app/desktop/views/components/drive-file.vue b/src/web/app/desktop/views/components/drive-file.vue new file mode 100644 index 0000000000..ffdf7ef57e --- /dev/null +++ b/src/web/app/desktop/views/components/drive-file.vue @@ -0,0 +1,319 @@ +<template> +<div class="mk-drive-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.avatar_id == 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.banner_id == 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.average_color + ? `rgb(${this.file.properties.average_color.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('text', JSON.stringify({ + type: 'file', + id: this.file.id, + file: this.file + })); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend(e) { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + onThumbnailLoaded() { + if (this.file.properties.average_color) { + anime({ + targets: this.$refs.thumbnail, + backgroundColor: `rgba(${this.file.properties.average_color.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', { + file_id: 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> +.mk-drive-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/web/app/desktop/views/components/drive-folder.vue b/src/web/app/desktop/views/components/drive-folder.vue new file mode 100644 index 0000000000..efb9df30f8 --- /dev/null +++ b/src/web/app/desktop/views/components/drive-folder.vue @@ -0,0 +1,267 @@ +<template> +<div class="mk-drive-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) { + // ドラッグされてきたものがファイルだったら + if (e.dataTransfer.effectAllowed === 'all') { + e.dataTransfer.dropEffect = 'copy'; + } else { + e.dataTransfer.dropEffect = 'move'; + } + } else { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + } + return false; + }, + + 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 false; + }; + + // データ取得 + const data = e.dataTransfer.getData('text'); + if (data == null) return false; + + // パース + // TODO: Validate JSON + const obj = JSON.parse(data); + + // (ドライブの)ファイルだったら + if (obj.type == 'file') { + const file = obj.id; + this.browser.removeFile(file); + (this as any).api('drive/files/update', { + file_id: file, + folder_id: this.folder.id + }); + // (ドライブの)フォルダーだったら + } else if (obj.type == 'folder') { + const folder = obj.id; + // 移動先が自分自身ならreject + if (folder == this.folder.id) return false; + this.browser.removeFolder(folder); + (this as any).api('drive/folders/update', { + folder_id: folder, + parent_id: this.folder.id + }).then(() => { + // something + }).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); + } + }); + } + + return false; + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text', JSON.stringify({ + type: 'folder', + id: this.folder.id + })); + 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', { + folder_id: this.folder.id, + name: name + }); + }); + }, + + deleteFolder() { + alert('not implemented yet'); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-drive-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/web/app/desktop/tags/drive/nav-folder.tag b/src/web/app/desktop/views/components/drive-nav-folder.vue similarity index 61% rename from src/web/app/desktop/tags/drive/nav-folder.tag rename to src/web/app/desktop/views/components/drive-nav-folder.vue index 43a648b52b..44821087af 100644 --- a/src/web/app/desktop/tags/drive/nav-folder.tag +++ b/src/web/app/desktop/views/components/drive-nav-folder.vue @@ -1,35 +1,43 @@ -<mk-drive-browser-nav-folder data-draghover={ draghover } onclick={ onclick } ondragover={ ondragover } ondragenter={ ondragenter } ondragleave={ ondragleave } ondrop={ ondrop }> - <virtual if={ folder == null }>%fa:cloud%</virtual><span>{ folder == null ? '%i18n:desktop.tags.mk-drive-browser-nav-folder.drive%' : folder.name }</span> - <style> - :scope - &[data-draghover] - background #eee +<template> +<div class="mk-drive-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> - </style> - <script> - this.mixin('api'); - - this.folder = this.opts.folder ? this.opts.folder : null; - this.browser = this.parent; - - this.hover = false; - - this.onclick = () => { +<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); - }; - - this.onmouseover = () => { - this.hover = true - }; - - this.onmouseout = () => { - this.hover = false - }; - - this.ondragover = e => { - e.preventDefault(); - e.stopPropagation(); - + }, + onMouseover() { + this.hover = true; + }, + onMouseout() { + this.hover = false; + }, + onDragover(e) { // このフォルダがルートかつカレントディレクトリならドロップ禁止 if (this.folder == null && this.browser.folder == null) { e.dataTransfer.dropEffect = 'none'; @@ -40,18 +48,14 @@ e.dataTransfer.dropEffect = 'move'; } return false; - }; - - this.ondragenter = () => { + }, + onDragenter() { if (this.folder || this.browser.folder) this.draghover = true; - }; - - this.ondragleave = () => { + }, + onDragleave() { if (this.folder || this.browser.folder) this.draghover = false; - }; - - this.ondrop = e => { - e.stopPropagation(); + }, + onDrop(e) { this.draghover = false; // ファイルだったら @@ -74,7 +78,7 @@ if (obj.type == 'file') { const file = obj.id; this.browser.removeFile(file); - this.api('drive/files/update', { + (this as any).api('drive/files/update', { file_id: file, folder_id: this.folder ? this.folder.id : null }); @@ -84,13 +88,21 @@ // 移動先が自分自身ならreject if (this.folder && folder == this.folder.id) return false; this.browser.removeFolder(folder); - this.api('drive/folders/update', { + (this as any).api('drive/folders/update', { folder_id: folder, parent_id: this.folder ? this.folder.id : null }); } return false; - }; - </script> -</mk-drive-browser-nav-folder> + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-drive-nav-folder + &[data-draghover] + background #eee + +</style> diff --git a/src/web/app/desktop/views/components/drive-window.vue b/src/web/app/desktop/views/components/drive-window.vue new file mode 100644 index 0000000000..8ae48cf39f --- /dev/null +++ b/src/web/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).folder; + 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/web/app/desktop/views/components/drive.vue b/src/web/app/desktop/views/components/drive.vue new file mode 100644 index 0000000000..0dcf077017 --- /dev/null +++ b/src/web/app/desktop/views/components/drive.vue @@ -0,0 +1,751 @@ +<template> +<div class="mk-drive"> + <nav> + <div class="path" @contextmenu.prevent.stop="() => {}"> + <mk-drive-nav-folder :class="{ current: folder == null }"/> + <template v-for="folder in hierarchyFolders"> + <span class="separator">%fa:angle-right%</span> + <mk-drive-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.prevent="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"> + <mk-drive-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"> + <mk-drive-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 contains from '../../../common/scripts/contains'; +import contextmenu from '../../api/contextmenu'; + +export default Vue.extend({ + 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.folder_id) { + this.removeFile(file); + } else { + this.addFile(file, true); + } + }, + + onStreamDriveFolderCreated(folder) { + this.addFolder(folder, true); + }, + + onStreamDriveFolderUpdated(folder) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parent_id) { + this.removeFolder(folder); + } else { + this.addFolder(folder, true); + } + }, + + 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 = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + this.draghover = true; + } 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 false; + } + + // データ取得 + const data = e.dataTransfer.getData('text'); + if (data == null) return false; + + // パース + // TODO: JSONじゃなかったら中断 + const obj = JSON.parse(data); + + // (ドライブの)ファイルだったら + if (obj.type == 'file') { + const file = obj.id; + if (this.files.some(f => f.id == file)) return false; + this.removeFile(file); + (this as any).api('drive/files/update', { + file_id: file, + folder_id: this.folder ? this.folder.id : null + }); + // (ドライブの)フォルダーだったら + } else if (obj.type == 'folder') { + const folder = obj.id; + // 移動先が自分自身ならreject + if (this.folder && folder == this.folder.id) return false; + if (this.folders.some(f => f.id == folder)) return false; + this.removeFolder(folder); + (this as any).api('drive/folders/update', { + folder_id: folder, + parent_id: this.folder ? this.folder.id : null + }).then(() => { + // something + }).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); + } + }); + } + + return false; + }, + + 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, + folder_id: 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, + folder_id: 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) { + (this as any).os.new(MkDriveWindow, { + folder: folder + }); + }, + + 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', { + folder_id: 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.parent_id) 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.folder_id) return; + + if (this.files.some(f => f.id == file.id)) { + const exist = this.files.map(f => f.id).indexOf(file.id); + Vue.set(this.files, exist, file); + return; + } + + if (unshift) { + this.files.unshift(file); + } else { + this.files.push(file); + } + }, + + removeFolder(folder) { + if (typeof folder == 'object') folder = folder.id; + this.folders = this.folders.filter(f => f.id != folder); + }, + + removeFile(file) { + if (typeof file == 'object') file = file.id; + this.files = this.files.filter(f => f.id != file); + }, + + appendFile(file) { + this.addFile(file); + }, + + appendFolder(folder) { + this.addFolder(folder); + }, + + prependFile(file) { + this.addFile(file, true); + }, + + prependFolder(folder) { + this.addFolder(folder, true); + }, + + goRoot() { + // 既に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', { + folder_id: this.folder ? this.folder.id : null, + limit: foldersMax + 1 + }).then(folders => { + if (folders.length == foldersMax + 1) { + this.moreFolders = true; + folders.pop(); + } + fetchedFolders = folders; + complete(); + }); + + // ファイル一覧取得 + (this as any).api('drive/files', { + folder_id: this.folder ? this.folder.id : null, + limit: filesMax + 1 + }).then(files => { + if (files.length == filesMax + 1) { + this.moreFiles = true; + files.pop(); + } + fetchedFiles = files; + complete(); + }); + + let flag = false; + const complete = () => { + if (flag) { + fetchedFolders.forEach(this.appendFolder); + fetchedFiles.forEach(this.appendFile); + this.fetching = false; + } else { + flag = true; + } + }; + }, + + fetchMoreFiles() { + this.fetching = true; + + const max = 30; + + // ファイル一覧取得 + (this as any).api('drive/files', { + folder_id: 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> +.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/web/app/desktop/views/components/ellipsis-icon.vue b/src/web/app/desktop/views/components/ellipsis-icon.vue new file mode 100644 index 0000000000..c54a7db29d --- /dev/null +++ b/src/web/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/web/app/desktop/views/components/follow-button.vue b/src/web/app/desktop/views/components/follow-button.vue new file mode 100644 index 0000000000..9056307bbf --- /dev/null +++ b/src/web/app/desktop/views/components/follow-button.vue @@ -0,0 +1,162 @@ +<template> +<button class="mk-follow-button" + :class="{ wait, follow: !user.is_following, unfollow: user.is_following, big: size == 'big' }" + @click="onClick" + :disabled="wait" + :title="user.is_following ? 'フォロー解除' : 'フォローする'" +> + <template v-if="!wait && user.is_following"> + <template v-if="size == 'compact'">%fa:minus%</template> + <template v-if="size == 'big'">%fa:minus%フォロー解除</template> + </template> + <template v-if="!wait && !user.is_following"> + <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.is_following = user.is_following; + } + }, + + onUnfollow(user) { + if (user.id == this.user.id) { + this.user.is_following = user.is_following; + } + }, + + onClick() { + this.wait = true; + if (this.user.is_following) { + (this as any).api('following/delete', { + user_id: this.user.id + }).then(() => { + this.user.is_following = false; + }).catch(err => { + console.error(err); + }).then(() => { + this.wait = false; + }); + } else { + (this as any).api('following/create', { + user_id: this.user.id + }).then(() => { + this.user.is_following = true; + }).catch(err => { + console.error(err); + }).then(() => { + this.wait = false; + }); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.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/web/app/desktop/views/components/followers-window.vue b/src/web/app/desktop/views/components/followers-window.vue new file mode 100644 index 0000000000..d41d356f9b --- /dev/null +++ b/src/web/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.avatar_url}?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/web/app/desktop/views/components/followers.vue b/src/web/app/desktop/views/components/followers.vue new file mode 100644 index 0000000000..4541a00072 --- /dev/null +++ b/src/web/app/desktop/views/components/followers.vue @@ -0,0 +1,26 @@ +<template> +<mk-users-list + :fetch="fetch" + :count="user.followers_count" + :you-know-count="user.followers_you_know_count" +> + フォロワーはいないようです。 +</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', { + user_id: this.user.id, + iknow: iknow, + limit: limit, + cursor: cursor ? cursor : undefined + }).then(cb); + } + } +}); +</script> diff --git a/src/web/app/desktop/views/components/following-window.vue b/src/web/app/desktop/views/components/following-window.vue new file mode 100644 index 0000000000..c516b3b17b --- /dev/null +++ b/src/web/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.avatar_url}?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/web/app/desktop/views/components/following.vue b/src/web/app/desktop/views/components/following.vue new file mode 100644 index 0000000000..e0b9f11695 --- /dev/null +++ b/src/web/app/desktop/views/components/following.vue @@ -0,0 +1,26 @@ +<template> +<mk-users-list + :fetch="fetch" + :count="user.following_count" + :you-know-count="user.following_you_know_count" +> + フォロー中のユーザーはいないようです。 +</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', { + user_id: this.user.id, + iknow: iknow, + limit: limit, + cursor: cursor ? cursor : undefined + }).then(cb); + } + } +}); +</script> diff --git a/src/web/app/desktop/views/components/friends-maker.vue b/src/web/app/desktop/views/components/friends-maker.vue new file mode 100644 index 0000000000..ab35efc75a --- /dev/null +++ b/src/web/app/desktop/views/components/friends-maker.vue @@ -0,0 +1,168 @@ +<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="`/${user.username}`"> + <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=42`" alt="" v-user-preview="user.id"/> + </router-link> + <div class="body"> + <router-link class="name" :to="`/${user.username}`" v-user-preview="user.id">{{ user.name }}</router-link> + <p class="username">@{{ user.username }}</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'; +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(); + } + } +}); +</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/web/app/desktop/views/components/home.vue b/src/web/app/desktop/views/components/home.vue new file mode 100644 index 0000000000..8a61c378ed --- /dev/null +++ b/src/web/app/desktop/views/components/home.vue @@ -0,0 +1,327 @@ +<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"/> + </div> + </x-draggable> + <div class="main"> + <a @click="hint">カスタマイズのヒント</a> + <div> + <mk-post-form v-if="os.i.client_settings.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" :widget="widget" @chosen="warp"/> + </div> + <div class="main"> + <mk-post-form v-if="os.i.client_settings.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 { + widgetAdderSelected: null, + trash: [], + widgets: { + left: [], + right: [] + } + }; + }, + computed: { + home: { + get(): any[] { + //#region 互換性のため + (this as any).os.i.client_settings.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.client_settings.home; + }, + set(value) { + (this as any).os.i.client_settings.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', i => { + this.widgets.left = this.left; + this.widgets.right = this.right; + }, { + deep: true + }); + }, + 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'); + }, + 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)[0].warp(date); + } + } +}); +</script> + +<style lang="stylus" scoped> +.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/web/app/desktop/views/components/images-image-dialog.vue b/src/web/app/desktop/views/components/images-image-dialog.vue new file mode 100644 index 0000000000..60afa7af82 --- /dev/null +++ b/src/web/app/desktop/views/components/images-image-dialog.vue @@ -0,0 +1,69 @@ +<template> +<div class="mk-images-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-images-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/web/app/desktop/views/components/images-image.vue b/src/web/app/desktop/views/components/images-image.vue new file mode 100644 index 0000000000..5b7dc41739 --- /dev/null +++ b/src/web/app/desktop/views/components/images-image.vue @@ -0,0 +1,63 @@ +<template> +<a class="mk-images-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 MkImagesImageDialog from './images-image-dialog.vue'; + +export default Vue.extend({ + props: ['image'], + computed: { + style(): any { + return { + 'background-color': this.image.properties.average_color ? `rgb(${this.image.properties.average_color.join(',')})` : 'transparent', + 'background-image': `url(${this.image.url}?thumbnail&size=512)` + }; + } + }, + 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(MkImagesImageDialog, { + image: this.image + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-images-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/web/app/desktop/views/components/index.ts b/src/web/app/desktop/views/components/index.ts new file mode 100644 index 0000000000..fc30bb729e --- /dev/null +++ b/src/web/app/desktop/views/components/index.ts @@ -0,0 +1,105 @@ +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 imagesImage from './images-image.vue'; +import imagesImageDialog from './images-image-dialog.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 driveFile from './drive-file.vue'; +import driveFolder from './drive-folder.vue'; +import driveNavFolder from './drive-nav-folder.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 wNav from './widgets/nav.vue'; +import wCalendar from './widgets/calendar.vue'; +import wPhotoStream from './widgets/photo-stream.vue'; +import wSlideshow from './widgets/slideshow.vue'; +import wTips from './widgets/tips.vue'; +import wDonation from './widgets/donation.vue'; +import wNotifications from './widgets/notifications.vue'; +import wBroadcast from './widgets/broadcast.vue'; +import wTimemachine from './widgets/timemachine.vue'; +import wProfile from './widgets/profile.vue'; +import wServer from './widgets/server.vue'; +import wActivity from './widgets/activity.vue'; +import wRss from './widgets/rss.vue'; +import wTrends from './widgets/trends.vue'; +import wVersion from './widgets/version.vue'; +import wUsers from './widgets/users.vue'; +import wPolls from './widgets/polls.vue'; +import wPostForm from './widgets/post-form.vue'; +import wMessaging from './widgets/messaging.vue'; +import wChannel from './widgets/channel.vue'; +import wAccessLog from './widgets/access-log.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-images-image', imagesImage); +Vue.component('mk-images-image-dialog', imagesImageDialog); +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-drive-file', driveFile); +Vue.component('mk-drive-folder', driveFolder); +Vue.component('mk-drive-nav-folder', driveNavFolder); +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('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-notifications', wNotifications); +Vue.component('mkw-broadcast', wBroadcast); +Vue.component('mkw-timemachine', wTimemachine); +Vue.component('mkw-profile', wProfile); +Vue.component('mkw-server', wServer); +Vue.component('mkw-activity', wActivity); +Vue.component('mkw-rss', wRss); +Vue.component('mkw-trends', wTrends); +Vue.component('mkw-version', wVersion); +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-access-log', wAccessLog); diff --git a/src/web/app/desktop/views/components/input-dialog.vue b/src/web/app/desktop/views/components/input-dialog.vue new file mode 100644 index 0000000000..a735ce0f31 --- /dev/null +++ b/src/web/app/desktop/views/components/input-dialog.vue @@ -0,0 +1,179 @@ +<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(() => { + console.log(this); + (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> +.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/web/app/desktop/views/components/mentions.vue b/src/web/app/desktop/views/components/mentions.vue new file mode 100644 index 0000000000..28ba59f2b1 --- /dev/null +++ b/src/web/app/desktop/views/components/mentions.vue @@ -0,0 +1,123 @@ +<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', + until_id: this.posts[this.posts.length - 1].id + }).then(posts => { + this.posts = this.posts.concat(posts); + this.moreFetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.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/web/app/desktop/views/components/messaging-room-window.vue b/src/web/app/desktop/views/components/messaging-room-window.vue new file mode 100644 index 0000000000..66a9aa0036 --- /dev/null +++ b/src/web/app/desktop/views/components/messaging-room-window.vue @@ -0,0 +1,31 @@ +<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'; + +export default Vue.extend({ + props: ['user'], + computed: { + popout(): string { + return `${url}/i/messaging/${this.user.username}`; + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +.content + height 100% + overflow auto + +</style> diff --git a/src/web/app/desktop/views/components/messaging-window.vue b/src/web/app/desktop/views/components/messaging-window.vue new file mode 100644 index 0000000000..ac27465987 --- /dev/null +++ b/src/web/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/web/app/desktop/views/components/notifications.vue b/src/web/app/desktop/views/components/notifications.vue new file mode 100644 index 0000000000..bcd7cf35fe --- /dev/null +++ b/src/web/app/desktop/views/components/notifications.vue @@ -0,0 +1,315 @@ +<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.created_at"/> + <template v-if="notification.type == 'reaction'"> + <a class="avatar-anchor" :href="`/${notification.user.username}`" v-user-preview="notification.user.id"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </a> + <div class="text"> + <p> + <mk-reaction-icon :reaction="notification.reaction"/> + <a :href="`/${notification.user.username}`" v-user-preview="notification.user.id">{{ notification.user.name }}</a> + </p> + <a class="post-ref" :href="`/${notification.post.user.username}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right% + </a> + </div> + </template> + <template v-if="notification.type == 'repost'"> + <a class="avatar-anchor" :href="`/${notification.post.user.username}`" v-user-preview="notification.post.user_id"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </a> + <div class="text"> + <p>%fa:retweet% + <a :href="`/${notification.post.user.username}`" v-user-preview="notification.post.user_id">{{ notification.post.user.name }}</a> + </p> + <a class="post-ref" :href="`/${notification.post.user.username}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post.repost) }}%fa:quote-right% + </a> + </div> + </template> + <template v-if="notification.type == 'quote'"> + <a class="avatar-anchor" :href="`/${notification.post.user.username}`" v-user-preview="notification.post.user_id"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </a> + <div class="text"> + <p>%fa:quote-left% + <a :href="`/${notification.post.user.username}`" v-user-preview="notification.post.user_id">{{ notification.post.user.name }}</a> + </p> + <a class="post-preview" :href="`/${notification.post.user.username}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</a> + </div> + </template> + <template v-if="notification.type == 'follow'"> + <a class="avatar-anchor" :href="`/${notification.user.username}`" v-user-preview="notification.user.id"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </a> + <div class="text"> + <p>%fa:user-plus% + <a :href="`/${notification.user.username}`" v-user-preview="notification.user.id">{{ notification.user.name }}</a> + </p> + </div> + </template> + <template v-if="notification.type == 'reply'"> + <a class="avatar-anchor" :href="`/${notification.post.user.username}`" v-user-preview="notification.post.user_id"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </a> + <div class="text"> + <p>%fa:reply% + <a :href="`/${notification.post.user.username}`" v-user-preview="notification.post.user_id">{{ notification.post.user.name }}</a> + </p> + <a class="post-preview" :href="`/${notification.post.user.username}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</a> + </div> + </template> + <template v-if="notification.type == 'mention'"> + <a class="avatar-anchor" :href="`/${notification.post.user.username}`" v-user-preview="notification.post.user_id"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </a> + <div class="text"> + <p>%fa:at% + <a :href="`/${notification.post.user.username}`" v-user-preview="notification.post.user_id">{{ notification.post.user.name }}</a> + </p> + <a class="post-preview" :href="`/${notification.post.user.username}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</a> + </div> + </template> + <template v-if="notification.type == 'poll_vote'"> + <a class="avatar-anchor" :href="`/${notification.user.username}`" v-user-preview="notification.user.id"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </a> + <div class="text"> + <p>%fa:chart-pie%<a :href="`/${notification.user.username}`" v-user-preview="notification.user.id">{{ notification.user.name }}</a></p> + <a class="post-ref" :href="`/${notification.post.user.username}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right% + </a> + </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 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.created_at).getDate(); + const month = new Date(notification.created_at).getMonth() + 1; + notification._date = date; + notification._datetext = `${month}月 ${date}日`; + return notification; + }); + } + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('notification', this.onNotification); + + const max = 10; + + (this as any).api('i/notifications', { + limit: max + 1 + }).then(notifications => { + if (notifications.length == max + 1) { + this.moreNotifications = true; + notifications.pop(); + } + + this.notifications = notifications; + this.fetching = false; + }); + }, + beforeDestroy() { + this.connection.off('notification', this.onNotification); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + fetchMoreNotifications() { + this.fetchingMoreNotifications = true; + + const max = 30; + + (this as any).api('i/notifications', { + limit: max + 1, + until_id: this.notifications[this.notifications.length - 1].id + }).then(notifications => { + if (notifications.length == max + 1) { + this.moreNotifications = true; + notifications.pop(); + } else { + this.moreNotifications = false; + } + this.notifications = this.notifications.concat(notifications); + this.fetchingMoreNotifications = false; + }); + }, + onNotification(notification) { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.connection.send({ + type: 'read_notification', + id: notification.id + }); + + this.notifications.unshift(notification); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-notifications + > .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/web/app/desktop/views/components/post-detail.sub.vue b/src/web/app/desktop/views/components/post-detail.sub.vue new file mode 100644 index 0000000000..69ced0925f --- /dev/null +++ b/src/web/app/desktop/views/components/post-detail.sub.vue @@ -0,0 +1,125 @@ +<template> +<div class="sub" :title="title"> + <a class="avatar-anchor" href={ '/' + post.user.username }> + <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar" v-user-preview={ post.user_id }/> + </a> + <div class="main"> + <header> + <div class="left"> + <a class="name" href={ '/' + post.user.username } v-user-preview={ post.user_id }>{ post.user.name }</a> + <span class="username">@{ post.user.username }</span> + </div> + <div class="right"> + <a class="time" href={ '/' + post.user.username + '/' + post.id }> + <mk-time time={ post.created_at }/> + </a> + </div> + </header> + <div class="body"> + <mk-post-html v-if="post.ast" :ast="post.ast" :i="os.i"/> + <div class="media" v-if="post.media"> + <mk-images images={ post.media }/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import dateStringify from '../../../common/scripts/date-stringify'; + +export default Vue.extend({ + props: ['post'], + computed: { + title(): string { + return dateStringify(this.post.created_at); + } + } +}); +</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 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1em + color #717171 + + > .mk-url-preview + margin-top 8px + +</style> diff --git a/src/web/app/desktop/views/components/post-detail.vue b/src/web/app/desktop/views/components/post-detail.vue new file mode 100644 index 0000000000..c453867dfb --- /dev/null +++ b/src/web/app/desktop/views/components/post-detail.vue @@ -0,0 +1,347 @@ +<template> +<div class="mk-post-detail" :title="title"> + <button + class="read-more" + v-if="p.reply && p.reply.reply_id && 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="`/${post.user.username}`" v-user-preview="post.user_id"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=32`" alt="avatar"/> + </router-link> + %fa:retweet% + <router-link class="name" :href="`/${post.user.username}`">{{ post.user.name }}</router-link> + がRepost + </p> + </div> + <article> + <router-link class="avatar-anchor" :to="`/${p.user.username}`"> + <img class="avatar" :src="`${p.user.avatar_url}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/> + </router-link> + <header> + <router-link class="name" :to="`/${p.user.username}`" v-user-preview="p.user.id">{{ p.user.name }}</router-link> + <span class="username">@{{ p.user.username }}</span> + <router-link class="time" :to="`/${p.user.username}/${p.id}`"> + <mk-time :time="p.created_at"/> + </router-link> + </header> + <div class="body"> + <mk-post-html :class="$style.text" v-if="p.ast" :ast="p.ast" :i="os.i"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <div class="media" v-if="p.media"> + <mk-images :images="p.media"/> + </div> + <mk-poll v-if="p.poll" :post="p"/> + </div> + <footer> + <mk-reactions-viewer :post="p"/> + <button @click="reply" title="返信"> + %fa:reply%<p class="count" v-if="p.replies_count > 0">{{ p.replies_count }}</p> + </button> + <button @click="repost" title="Repost"> + %fa:retweet%<p class="count" v-if="p.repost_count > 0">{{ p.repost_count }}</p> + </button> + <button :class="{ reacted: p.my_reaction != null }" @click="react" ref="reactButton" title="リアクション"> + %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 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 + } + }, + data() { + return { + context: [], + contextFetching: false, + replies: [], + }; + }, + computed: { + isRepost(): boolean { + return this.post.repost != null; + }, + p(): any { + return this.isRepost ? this.post.repost : this.post; + }, + reactionsCount(): number { + return this.p.reaction_counts + ? Object.keys(this.p.reaction_counts) + .map(key => this.p.reaction_counts[key]) + .reduce((a, b) => a + b) + : 0; + }, + title(): string { + return dateStringify(this.p.created_at); + }, + urls(): string[] { + if (this.p.ast) { + return this.p.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + }, + mounted() { + // Get replies + if (!this.compact) { + (this as any).api('posts/replies', { + post_id: this.p.id, + limit: 8 + }).then(replies => { + this.replies = replies; + }); + } + }, + methods: { + fetchContext() { + this.contextFetching = true; + + // Fetch context + (this as any).api('posts/context', { + post_id: this.p.reply_id + }).then(context => { + this.contextFetching = false; + this.context = context.reverse(); + }); + }, + reply() { + (this as any).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> +.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 + + > .mk-url-preview + margin-top 8px + + > 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/web/app/desktop/views/components/post-form-window.vue b/src/web/app/desktop/views/components/post-form-window.vue new file mode 100644 index 0000000000..4427f59829 --- /dev/null +++ b/src/web/app/desktop/views/components/post-form-window.vue @@ -0,0 +1,63 @@ +<template> +<mk-window ref="window" is-modal @closed="$destroy"> + <span slot="header"> + <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"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['reply'], + data() { + return { + uploadings: [], + media: [] + }; + }, + mounted() { + this.$nextTick(() => { + (this.$refs.form as any).focus(); + }); + }, + methods: { + onChangeUploadings(files) { + this.uploadings = files; + }, + onChangeMedia(media) { + this.media = media; + }, + onPosted() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +.count + margin-left 8px + opacity 0.8 + + &:before + content '(' + + &:after + content ')' + +.postPreview + margin 16px 22px + +</style> diff --git a/src/web/app/desktop/views/components/post-form.vue b/src/web/app/desktop/views/components/post-form.vue new file mode 100644 index 0000000000..d38ed9a046 --- /dev/null +++ b/src/web/app/desktop/views/components/post-form.vue @@ -0,0 +1,503 @@ +<template> +<div class="mk-post-form" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.prevent.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 + ></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> + <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, + 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) { + 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) { + Array.from(e.dataTransfer.files).forEach(this.upload); + return; + } + + // データ取得 + const data = e.dataTransfer.getData('text'); + if (data == null) return; + + try { + // パース + const obj = JSON.parse(data); + + // (ドライブの)ファイルだったら + if (obj.type == 'file') { + this.files.push(obj.file); + this.$emit('change-attached-media', this.files); + } + } catch (e) { } + }, + post() { + this.posting = true; + + (this as any).api('posts/create', { + text: this.text == '' ? undefined : this.text, + media_ids: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + reply_id: this.reply ? this.reply.id : undefined, + repost_id: this.repost ? this.repost.id : undefined, + poll: this.poll ? (this.$refs.poll as any).get() : undefined + }).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] = { + updated_at: 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> +.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 + 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/web/app/desktop/views/components/post-preview.vue b/src/web/app/desktop/views/components/post-preview.vue new file mode 100644 index 0000000000..6a0a60e4af --- /dev/null +++ b/src/web/app/desktop/views/components/post-preview.vue @@ -0,0 +1,98 @@ +<template> +<div class="mk-post-preview" :title="title"> + <a class="avatar-anchor" :href="`/${post.user.username}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar" v-user-preview="post.user_id"/> + </a> + <div class="main"> + <header> + <a class="name" :href="`/${post.user.username}`" v-user-preview="post.user_id">{{ post.user.name }}</a> + <span class="username">@{{ post.user.username }}</span> + <a class="time" :href="`/${post.user.username}/${post.id}`"> + <mk-time :time="post.created_at"/></a> + </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'; + +export default Vue.extend({ + props: ['post'], + computed: { + title(): string { + return dateStringify(this.post.created_at); + } + } +}); +</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/web/app/desktop/views/components/posts.post.sub.vue b/src/web/app/desktop/views/components/posts.post.sub.vue new file mode 100644 index 0000000000..f920775168 --- /dev/null +++ b/src/web/app/desktop/views/components/posts.post.sub.vue @@ -0,0 +1,108 @@ +<template> +<div class="sub" :title="title"> + <a class="avatar-anchor" :href="`/${post.user.username}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar" v-user-preview="post.user_id"/> + </a> + <div class="main"> + <header> + <a class="name" :href="`/${post.user.username}`" v-user-preview="post.user_id">{{ post.user.name }}</a> + <span class="username">@{{ post.user.username }}</span> + <a class="created-at" :href="`/${post.user.username}/${post.id}`"> + <mk-time :time="post.created_at"/> + </a> + </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'; + +export default Vue.extend({ + props: ['post'], + computed: { + title(): string { + return dateStringify(this.post.created_at); + } + } +}); +</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/web/app/desktop/views/components/posts.post.vue b/src/web/app/desktop/views/components/posts.post.vue new file mode 100644 index 0000000000..6fe097909b --- /dev/null +++ b/src/web/app/desktop/views/components/posts.post.vue @@ -0,0 +1,504 @@ +<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="`/${post.user.username}`" v-user-preview="post.user_id"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=32`" alt="avatar"/> + </router-link> + %fa:retweet% + {{ '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('{')) }} + <a class="name" :href="`/${post.user.username}`" v-user-preview="post.user_id">{{ post.user.name }}</a> + {{ '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr('%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1) }} + </p> + <mk-time :time="post.created_at"/> + </div> + <article> + <router-link class="avatar-anchor" :to="`/${p.user.username}`"> + <img class="avatar" :src="`${p.user.avatar_url}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/${p.user.username}`" v-user-preview="p.user.id">{{ p.user.name }}</router-link> + <span class="is-bot" v-if="p.user.is_bot">bot</span> + <span class="username">@{{ p.user.username }}</span> + <div class="info"> + <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span> + <router-link class="created-at" :to="url"> + <mk-time :time="p.created_at"/> + </router-link> + </div> + </header> + <div class="body"> + <div class="text" ref="text"> + <p class="channel" v-if="p.channel"> + <a :href="`${_CH_URL_}/${p.channel.id}`" target="_blank">{{ p.channel.title }}</a>: + </p> + <a class="reply" v-if="p.reply">%fa:reply%</a> + <mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i"/> + <a class="quote" v-if="p.repost">RP:</a> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + </div> + <div class="media" v-if="p.media"> + <mk-images :images="p.media"/> + </div> + <mk-poll v-if="p.poll" :post="p" ref="pollViewer"/> + <div class="repost" v-if="p.repost">%fa:quote-right -flip-h% + <mk-post-preview class="repost" :post="p.repost"/> + </div> + </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.replies_count > 0">{{ p.replies_count }}</p> + </button> + <button @click="repost" title="%i18n:desktop.tags.mk-timeline-post.repost%"> + %fa:retweet%<p class="count" v-if="p.repost_count > 0">{{ p.repost_count }}</p> + </button> + <button :class="{ reacted: p.my_reaction != null }" @click="react" ref="reactButton" title="%i18n: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 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: { + isRepost(): boolean { + return (this.post.repost && + this.post.text == null && + this.post.media_ids == null && + this.post.poll == null); + }, + p(): any { + return this.isRepost ? this.post.repost : this.post; + }, + reactionsCount(): number { + return this.p.reaction_counts + ? Object.keys(this.p.reaction_counts) + .map(key => this.p.reaction_counts[key]) + .reduce((a, b) => a + b) + : 0; + }, + title(): string { + return dateStringify(this.p.created_at); + }, + url(): string { + return `/${this.p.user.username}/${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() { + 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); + } + }, + beforeDestroy() { + this.decapture(true); + 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.post.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.post.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); + } + }, + 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> +.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 + margin-bottom 4px + white-space nowrap + line-height 1.4 + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + color #777 + 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 + text-align right + font-size 0.9em + + > .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 + + > .dummy + display none + + .mk-url-preview + margin-top 8px + + > .channel + margin 0 + + > .reply + margin-right 8px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + + 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 + + > .mk-poll + font-size 80% + + > .repost + margin 8px 0 + + > [data-fa]:first-child + position absolute + top -8px + left -8px + z-index 1 + color #c0dac6 + font-size 28px + background #fff + + > .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> + diff --git a/src/web/app/desktop/views/components/posts.vue b/src/web/app/desktop/views/components/posts.vue new file mode 100644 index 0000000000..ec36889ec8 --- /dev/null +++ b/src/web/app/desktop/views/components/posts.vue @@ -0,0 +1,79 @@ +<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.created_at).getDate(); + const month = new Date(post.created_at).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 + padding 16px + text-align center + color #ccc + border-top solid 1px #eaeaea + border-bottom-left-radius 4px + border-bottom-right-radius 4px + +</style> diff --git a/src/web/app/desktop/views/components/progress-dialog.vue b/src/web/app/desktop/views/components/progress-dialog.vue new file mode 100644 index 0000000000..ed49b19d71 --- /dev/null +++ b/src/web/app/desktop/views/components/progress-dialog.vue @@ -0,0 +1,93 @@ +<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> +.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/web/app/desktop/views/components/repost-form-window.vue b/src/web/app/desktop/views/components/repost-form-window.vue new file mode 100644 index 0000000000..7db5adbff3 --- /dev/null +++ b/src/web/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/web/app/desktop/views/components/repost-form.vue b/src/web/app/desktop/views/components/repost-form.vue new file mode 100644 index 0000000000..5bf7eaaf03 --- /dev/null +++ b/src/web/app/desktop/views/components/repost-form.vue @@ -0,0 +1,129 @@ +<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', { + repost_id: 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> +.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/web/app/desktop/views/components/settings-window.vue b/src/web/app/desktop/views/components/settings-window.vue new file mode 100644 index 0000000000..d5be177dcc --- /dev/null +++ b/src/web/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/web/app/desktop/views/components/settings.2fa.vue b/src/web/app/desktop/views/components/settings.2fa.vue new file mode 100644 index 0000000000..87783e799d --- /dev/null +++ b/src/web/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.two_factor_enabled"><button @click="register" class="ui primary">%i18n:desktop.tags.mk-2fa-setting.register%</button></p> + <template v-if="os.i.two_factor_enabled"> + <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.two_factor_enabled = 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.two_factor_enabled = 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/web/app/desktop/views/components/settings.api.vue b/src/web/app/desktop/views/components/settings.api.vue new file mode 100644 index 0000000000..5831f82075 --- /dev/null +++ b/src/web/app/desktop/views/components/settings.api.vue @@ -0,0 +1,40 @@ +<template> +<div class="root api"> + <p>Token: <code>{{ os.i.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/web/app/desktop/views/components/settings.mute.vue b/src/web/app/desktop/views/components/settings.mute.vue new file mode 100644 index 0000000000..0768b54ef8 --- /dev/null +++ b/src/web/app/desktop/views/components/settings.mute.vue @@ -0,0 +1,31 @@ +<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> @{{ user.username }}</p> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + fetching: true, + users: [] + }; + }, + mounted() { + (this as any).api('mute/list').then(x => { + this.users = x.users; + this.fetching = false; + }); + } +}); +</script> diff --git a/src/web/app/desktop/views/components/settings.password.vue b/src/web/app/desktop/views/components/settings.password.vue new file mode 100644 index 0000000000..be3f0370d6 --- /dev/null +++ b/src/web/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', { + current_password: currentPassword, + new_password: newPassword + }).then(() => { + (this as any).apis.notify('%i18n:desktop.tags.mk-password-setting.changed%'); + }); + }); + }); + }); + } + } +}); +</script> diff --git a/src/web/app/desktop/views/components/settings.profile.vue b/src/web/app/desktop/views/components/settings.profile.vue new file mode 100644 index 0000000000..97a382d798 --- /dev/null +++ b/src/web/app/desktop/views/components/settings.profile.vue @@ -0,0 +1,78 @@ +<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.avatar_url}?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> + <input v-model="birthday" type="date" class="ui"/> + </label> + <button class="ui primary" @click="save">%i18n:desktop.tags.mk-profile-setting.save%</button> +</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.profile.location; + this.description = (this as any).os.i.description; + this.birthday = (this as any).os.i.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('プロフィールを更新しました'); + }); + } + } +}); +</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/web/app/desktop/views/components/settings.vue b/src/web/app/desktop/views/components/settings.vue new file mode 100644 index 0000000000..c210997c38 --- /dev/null +++ b/src/web/app/desktop/views/components/settings.vue @@ -0,0 +1,178 @@ +<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> + <div> + <button class="ui button" @click="customizeHome">ホームをカスタマイズ</button> + </div> + <label> + <input type="checkbox" v-model="showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl"> + <span>タイムライン上部に投稿フォームを表示する</span> + </label> + </section> + + <section class="drive" v-show="page == 'drive'"> + <h1>%i18n:desktop.tags.mk-settings.drive%</h1> + <mk-drive-setting/> + </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> + <mk-authorized-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> + <mk-signin-history/> + </section> + + <section class="api" v-show="page == 'api'"> + <h1>API</h1> + <x-api/> + </section> + + <section class="other" v-show="page == 'other'"> + <h1>%i18n:desktop.tags.mk-settings.license%</h1> + %license% + </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'; + +export default Vue.extend({ + components: { + XProfile, + XMute, + XPassword, + X2fa, + XApi + }, + data() { + return { + page: 'profile', + + showPostFormOnTopOfTl: false + }; + }, + created() { + this.showPostFormOnTopOfTl = (this as any).os.i.client_settings.showPostFormOnTopOfTl; + }, + methods: { + customizeHome() { + this.$router.push('/i/customize-home'); + this.$emit('done'); + }, + onChangeShowPostFormOnTopOfTl() { + (this as any).api('i/update_client_setting', { + name: 'showPostFormOnTopOfTl', + value: this.showPostFormOnTopOfTl + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.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 + display block + 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/web/app/desktop/views/components/sub-post-content.vue b/src/web/app/desktop/views/components/sub-post-content.vue new file mode 100644 index 0000000000..f048eb4f0f --- /dev/null +++ b/src/web/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.reply_id">%fa:reply%</a> + <mk-post-html :ast="post.ast" :i="os.i"/> + <a class="quote" v-if="post.repost_id" :href="`/post:${post.repost_id}`">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-images :images="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 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + + mk-poll + font-size 80% + +</style> diff --git a/src/web/app/desktop/views/components/timeline.vue b/src/web/app/desktop/views/components/timeline.vue new file mode 100644 index 0000000000..eef62104eb --- /dev/null +++ b/src/web/app/desktop/views/components/timeline.vue @@ -0,0 +1,134 @@ +<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"> + <div slot="footer"> + <template v-if="!moreFetching">%fa:comments%</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({ + data() { + return { + fetching: true, + moreFetching: false, + posts: [], + connection: null, + connectionId: null, + date: null + }; + }, + computed: { + alone(): boolean { + return (this as any).os.i.following_count == 0; + } + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('post', this.onPost); + this.connection.on('follow', this.onChangeFollowing); + this.connection.on('unfollow', this.onChangeFollowing); + + 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', { + until_date: this.date ? this.date.getTime() : undefined + }).then(posts => { + this.posts = posts; + this.fetching = false; + this.$emit('loaded'); + if (cb) cb(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0) return; + this.moreFetching = true; + (this as any).api('posts/timeline', { + until_id: this.posts[this.posts.length - 1].id + }).then(posts => { + this.posts = this.posts.concat(posts); + this.moreFetching = false; + }); + }, + onPost(post) { + this.posts.unshift(post); + }, + onChangeFollowing() { + this.fetch(); + }, + onScroll() { + 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/web/app/desktop/views/components/ui-notification.vue b/src/web/app/desktop/views/components/ui-notification.vue new file mode 100644 index 0000000000..9983f02c5e --- /dev/null +++ b/src/web/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/web/app/desktop/views/components/ui.header.account.vue b/src/web/app/desktop/views/components/ui.header.account.vue new file mode 100644 index 0000000000..af58e81a04 --- /dev/null +++ b/src/web/app/desktop/views/components/ui.header.account.vue @@ -0,0 +1,212 @@ +<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.avatar_url }?thumbnail&size=64`" alt="avatar"/> + </button> + <div class="menu" v-if="isOpen"> + <ul> + <li> + <a :href="`/${ os.i.username }`">%fa:user%%i18n:desktop.tags.mk-ui-header-account.profile%%fa:angle-right%</a> + </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> +</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'; +import signout from '../../../common/scripts/signout'; + +export default Vue.extend({ + data() { + return { + isOpen: false, + signout + }; + }, + 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); + } + } +}); +</script> + +<style lang="stylus" scoped> +.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 + +</style> diff --git a/src/web/app/desktop/views/components/ui.header.clock.vue b/src/web/app/desktop/views/components/ui.header.clock.vue new file mode 100644 index 0000000000..cd23a67506 --- /dev/null +++ b/src/web/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/web/app/desktop/views/components/ui.header.nav.vue b/src/web/app/desktop/views/components/ui.header.nav.vue new file mode 100644 index 0000000000..c102d5b3f5 --- /dev/null +++ b/src/web/app/desktop/views/components/ui.header.nav.vue @@ -0,0 +1,154 @@ +<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> + </template> + <li class="ch"> + <a :href="chUrl" target="_blank"> + %fa:tv% + <p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p> + </a> + </li> + <li class="info"> + <a href="https://twitter.com/misskey_xyz" target="_blank"> + %fa:info% + <p>%i18n:desktop.tags.mk-ui-header-nav.info%</p> + </a> + </li> + </ul> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { chUrl } from '../../../config'; +import MkMessagingWindow from './messaging-window.vue'; + +export default Vue.extend({ + data() { + return { + hasUnreadMessagingMessages: 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); + + // 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 as any).os.stream.dispose(this.connectionId); + } + }, + methods: { + onReadAllMessagingMessages() { + this.hasUnreadMessagingMessages = false; + }, + + onUnreadMessagingMessage() { + this.hasUnreadMessagingMessages = true; + }, + + messaging() { + (this as any).os.new(MkMessagingWindow); + } + } +}); +</script> + +<style lang="stylus" scoped> +.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/web/app/desktop/views/components/ui.header.notifications.vue b/src/web/app/desktop/views/components/ui.header.notifications.vue new file mode 100644 index 0000000000..5467dda856 --- /dev/null +++ b/src/web/app/desktop/views/components/ui.header.notifications.vue @@ -0,0 +1,156 @@ +<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> +.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/web/app/desktop/views/components/ui.header.post.vue b/src/web/app/desktop/views/components/ui.header.post.vue new file mode 100644 index 0000000000..e8ed380f06 --- /dev/null +++ b/src/web/app/desktop/views/components/ui.header.post.vue @@ -0,0 +1,52 @@ +<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> +.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/web/app/desktop/views/components/ui.header.search.vue b/src/web/app/desktop/views/components/ui.header.search.vue new file mode 100644 index 0000000000..c063de6bb0 --- /dev/null +++ b/src/web/app/desktop/views/components/ui.header.search.vue @@ -0,0 +1,68 @@ +<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> +.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/web/app/desktop/views/components/ui.header.vue b/src/web/app/desktop/views/components/ui.header.vue new file mode 100644 index 0000000000..99de05facb --- /dev/null +++ b/src/web/app/desktop/views/components/ui.header.vue @@ -0,0 +1,107 @@ +<template> +<div class="header"> + <mk-special-message/> + <div class="main"> + <div class="backdrop"></div> + <div class="main"> + <div class="container"> + <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 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, + } +}); +</script> + +<style lang="stylus" scoped> +.header + position -webkit-sticky + position sticky + top 0 + z-index 1024 + width 100% + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + > .main + + > .backdrop + position absolute + top 0 + z-index 1023 + width 100% + height 48px + backdrop-filter blur(12px) + background #f7f7f7 + + &:after + content "" + 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 + + > .main + z-index 1024 + margin 0 + padding 0 + background-clip content-box + font-size 0.9rem + user-select none + + > .container + display flex + width 100% + max-width 1300px + margin 0 auto + + > .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/web/app/desktop/views/components/ui.vue b/src/web/app/desktop/views/components/ui.vue new file mode 100644 index 0000000000..87f932ff14 --- /dev/null +++ b/src/web/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/web/app/desktop/views/components/user-preview.vue b/src/web/app/desktop/views/components/user-preview.vue new file mode 100644 index 0000000000..2a4bd7cf75 --- /dev/null +++ b/src/web/app/desktop/views/components/user-preview.vue @@ -0,0 +1,163 @@ +<template> +<div class="mk-user-preview"> + <template v-if="u != null"> + <div class="banner" :style="u.banner_url ? `background-image: url(${u.banner_url}?thumbnail&size=512)` : ''"></div> + <router-link class="avatar" :to="`/${u.username}`"> + <img :src="`${u.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="title"> + <router-link class="name" :to="`/${u.username}`">{{ u.name }}</router-link> + <p class="username">@{{ u.username }}</p> + </div> + <div class="description">{{ u.description }}</div> + <div class="status"> + <div> + <p>投稿</p><a>{{ u.posts_count }}</a> + </div> + <div> + <p>フォロー</p><a>{{ u.following_count }}</a> + </div> + <div> + <p>フォロワー</p><a>{{ u.followers_count }}</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'; + +export default Vue.extend({ + props: { + user: { + type: [Object, String], + required: true + } + }, + data() { + return { + u: null + }; + }, + mounted() { + if (typeof this.user == 'object') { + this.u = this.user; + this.$nextTick(() => { + this.open(); + }); + } else { + (this as any).api('users/show', { + user_id: this.user[0] == '@' ? undefined : this.user, + username: this.user[0] == '@' ? this.user.substr(1) : undefined + }).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> +.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/web/app/desktop/views/components/users-list.item.vue b/src/web/app/desktop/views/components/users-list.item.vue new file mode 100644 index 0000000000..374f55b410 --- /dev/null +++ b/src/web/app/desktop/views/components/users-list.item.vue @@ -0,0 +1,100 @@ +<template> +<div class="root item"> + <router-link class="avatar-anchor" :to="`/${user.username}`" v-user-preview="user.id"> + <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/${user.username}`" v-user-preview="user.id">{{ user.name }}</router-link> + <span class="username">@{{ user.username }}</span> + </header> + <div class="body"> + <p class="followed" v-if="user.is_followed">フォローされています</p> + <div class="description">{{ user.description }}</div> + </div> + </div> + <mk-follow-button :user="user"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['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/web/app/desktop/views/components/users-list.vue b/src/web/app/desktop/views/components/users-list.vue new file mode 100644 index 0000000000..fd15f478d2 --- /dev/null +++ b/src/web/app/desktop/views/components/users-list.vue @@ -0,0 +1,141 @@ +<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> +.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/web/app/desktop/views/components/widgets/access-log.vue b/src/web/app/desktop/views/components/widgets/access-log.vue new file mode 100644 index 0000000000..a04da1daaf --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/access-log.vue @@ -0,0 +1,108 @@ +<template> +<div class="mkw-access-log"> + <template v-if="props.design == 0"> + <p class="title">%fa:server%%i18n:desktop.tags.mk-access-log-home-widget.title%</p> + </template> + <div ref="log"> + <p v-for="req in requests"> + <span class="ip" :style="`color:${ req.fg }; background:${ req.bg }`">{{ req.ip }}</span> + <b>{{ req.method }}</b> + <span>{{ req.path }}</span> + </p> + </div> +</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" scoped> +.mkw-access-log + overflow hidden + 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 + + > div + 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) + + > .ip + margin-right 4px + padding 0 4px + + > b + margin-right 4px + +</style> diff --git a/src/web/app/desktop/views/components/widgets/activity.vue b/src/web/app/desktop/views/components/widgets/activity.vue new file mode 100644 index 0000000000..2ff5fe4f03 --- /dev/null +++ b/src/web/app/desktop/views/components/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/web/app/desktop/views/components/widgets/broadcast.vue b/src/web/app/desktop/views/components/widgets/broadcast.vue new file mode 100644 index 0000000000..e4b7e25321 --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/broadcast.vue @@ -0,0 +1,153 @@ +<template> +<div class="mkw-broadcast" :data-found="broadcasts.length != 0" :data-melt="props.design == 1"> + <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 + +</style> diff --git a/src/web/app/desktop/views/components/widgets/calendar.vue b/src/web/app/desktop/views/components/widgets/calendar.vue new file mode 100644 index 0000000000..c16602db46 --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/calendar.vue @@ -0,0 +1,192 @@ +<template> +<div class="mkw-calendar" + :data-melt="props.design == 1" + :data-special="special" +> + <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.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> +.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 + + &: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/web/app/desktop/views/components/widgets/channel.channel.form.vue b/src/web/app/desktop/views/components/widgets/channel.channel.form.vue new file mode 100644 index 0000000000..392ba5924b --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/channel.channel.form.vue @@ -0,0 +1,67 @@ +<template> +<div class="form"> + <input v-model="text" :disabled="wait" @keydown="onKeydown" placeholder="書いて"> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + text: '', + wait: false + }; + }, + methods: { + onKeydown(e) { + if (e.which == 10 || e.which == 13) this.post(); + }, + post() { + this.wait = true; + + let reply = null; + + if (/^>>([0-9]+) /.test(this.text)) { + const index = this.text.match(/^>>([0-9]+) /)[1]; + reply = (this.$parent as any).posts.find(p => p.index.toString() == index); + this.text = this.text.replace(/^>>([0-9]+) /, ''); + } + + (this as any).api('posts/create', { + text: this.text, + reply_id: reply ? reply.id : undefined, + channel_id: (this.$parent as any).channel.id + }).then(data => { + this.text = ''; + }).catch(err => { + alert('失敗した'); + }).then(() => { + this.wait = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.form + width 100% + height 38px + padding 4px + border-top solid 1px #ddd + + > input + padding 0 8px + width 100% + height 100% + font-size 14px + color #55595c + border solid 1px #dadada + border-radius 4px + + &:hover + &:focus + border-color #aeaeae + +</style> diff --git a/src/web/app/desktop/views/components/widgets/channel.channel.post.vue b/src/web/app/desktop/views/components/widgets/channel.channel.post.vue new file mode 100644 index 0000000000..faaf0fb731 --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/channel.channel.post.vue @@ -0,0 +1,64 @@ +<template> +<div class="post"> + <header> + <a class="index" @click="reply">{{ post.index }}:</a> + <router-link class="name" :to="`/${post.user.username}`" v-user-preview="post.user.id"><b>{{ post.user.name }}</b></router-link> + <span>ID:<i>{{ post.user.username }}</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'; +export default Vue.extend({ + props: ['post'], + 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/web/app/desktop/views/components/widgets/channel.channel.vue b/src/web/app/desktop/views/components/widgets/channel.channel.vue new file mode 100644 index 0000000000..a28b4aeb94 --- /dev/null +++ b/src/web/app/desktop/views/components/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-stream'; +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.$nextTick(() => { + this.zap(); + }); + }, + beforeDestroy() { + this.disconnect(); + }, + methods: { + zap() { + this.fetching = true; + + (this as any).api('channels/posts', { + channel_id: this.channel.id + }).then(posts => { + this.posts = posts; + this.fetching = false; + + this.scrollToBottom(); + + this.disconnect(); + this.connection = new ChannelStream(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/web/app/desktop/views/components/widgets/channel.vue b/src/web/app/desktop/views/components/widgets/channel.vue new file mode 100644 index 0000000000..5c3afd9ecf --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/channel.vue @@ -0,0 +1,107 @@ +<template> +<div class="mkw-channel"> + <template v-if="!props.compact"> + <p class="title">%fa:tv%{{ channel ? channel.title : '%i18n:desktop.tags.mk-channel-home-widget.title%' }}</p> + <button @click="settings" title="%i18n:desktop.tags.mk-channel-home-widget.settings%">%fa:cog%</button> + </template> + <p class="get-started" v-if="props.channel == null">%i18n:desktop.tags.mk-channel-home-widget.get-started%</p> + <x-channel class="channel" :channel="channel" v-if="channel != null"/> +</div> +</template> + +<script lang="ts"> +import define from '../../../../common/define-widget'; +import XChannel from './channel.channel.vue'; + +export default define({ + name: 'server', + props: () => ({ + channel: null, + compact: false + }) +}).extend({ + components: { + XChannel + }, + data() { + return { + fetching: true, + channel: null + }; + }, + mounted() { + if (this.props.channel) { + this.zap(); + } + }, + methods: { + func() { + this.props.compact = !this.props.compact; + }, + settings() { + const id = window.prompt('チャンネルID'); + if (!id) return; + this.props.channel = id; + this.zap(); + }, + zap() { + this.fetching = true; + + (this as any).api('channels/show', { + channel_id: this.props.channel + }).then(channel => { + this.channel = channel; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-channel + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + + > .title + z-index 2 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .get-started + margin 0 + padding 16px + text-align center + color #aaa + + > .channel + height 200px + +</style> diff --git a/src/web/app/desktop/views/components/widgets/donation.vue b/src/web/app/desktop/views/components/widgets/donation.vue new file mode 100644 index 0000000000..fbab0fca6c --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/donation.vue @@ -0,0 +1,45 @@ +<template> +<div class="mkw-donation"> + <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 + +</style> diff --git a/src/web/app/desktop/views/components/widgets/messaging.vue b/src/web/app/desktop/views/components/widgets/messaging.vue new file mode 100644 index 0000000000..ae7d6934af --- /dev/null +++ b/src/web/app/desktop/views/components/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 '../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/web/app/desktop/views/components/widgets/nav.vue b/src/web/app/desktop/views/components/widgets/nav.vue new file mode 100644 index 0000000000..5e04c266cf --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/nav.vue @@ -0,0 +1,29 @@ +<template> +<div class="mkw-nav"> + <mk-nav/> +</div> +</template> + +<script lang="ts"> +import define from '../../../../common/define-widget'; +export default define({ + name: 'nav' +}); +</script> + +<style lang="stylus" scoped> +.mkw-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/web/app/desktop/views/components/widgets/notifications.vue b/src/web/app/desktop/views/components/widgets/notifications.vue new file mode 100644 index 0000000000..978cf5218e --- /dev/null +++ b/src/web/app/desktop/views/components/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/web/app/desktop/views/components/widgets/photo-stream.vue b/src/web/app/desktop/views/components/widgets/photo-stream.vue new file mode 100644 index 0000000000..04b71975b3 --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/photo-stream.vue @@ -0,0 +1,122 @@ +<template> +<div class="mkw-photo-stream" :data-melt="props.design == 2"> + <p class="title" v-if="props.design == 0">%fa:camera%%i18n:desktop.tags.mk-photo-stream-home-widget.title%</p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <div class="stream" v-if="!fetching && images.length > 0"> + <div v-for="image in images" :key="image.id" 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-photo-stream-home-widget.no-photos%</p> +</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" scoped> +.mkw-photo-stream + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-melt] + background transparent !important + border none !important + + > .stream + padding 0 + + > .img + border solid 4px transparent + border-radius 8px + + > .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 + + > .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/web/app/desktop/views/components/widgets/polls.vue b/src/web/app/desktop/views/components/widgets/polls.vue new file mode 100644 index 0000000000..f1b34ceed0 --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/polls.vue @@ -0,0 +1,122 @@ +<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="`/${ poll.user.username }/${ poll.id }`">{{ poll.text }}</router-link></p> + <p v-if="!poll.text"><router-link to="`/${ poll.user.username }/${ 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'; +export default define({ + name: 'polls', + props: () => ({ + compact: false + }) +}).extend({ + 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/web/app/desktop/views/components/widgets/post-form.vue b/src/web/app/desktop/views/components/widgets/post-form.vue new file mode 100644 index 0000000000..ab87ba7217 --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/post-form.vue @@ -0,0 +1,109 @@ +<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> +.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/web/app/desktop/views/components/widgets/profile.vue b/src/web/app/desktop/views/components/widgets/profile.vue new file mode 100644 index 0000000000..68cf469788 --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/profile.vue @@ -0,0 +1,125 @@ +<template> +<div class="mkw-profile" + :data-compact="props.design == 1 || props.design == 2" + :data-melt="props.design == 2" +> + <div class="banner" + :style="os.i.banner_url ? `background-image: url(${os.i.banner_url}?thumbnail&size=256)` : ''" + title="クリックでバナー編集" + @click="os.apis.updateBanner" + ></div> + <img class="avatar" + :src="`${os.i.avatar_url}?thumbnail&size=96`" + @click="os.apis.updateAvatar" + alt="avatar" + title="クリックでアバター編集" + v-user-preview="os.i.id" + /> + <router-link class="name" :to="`/${os.i.username}`">{{ os.i.name }}</router-link> + <p class="username">@{{ os.i.username }}</p> +</div> +</template> + +<script lang="ts"> +import define from '../../../../common/define-widget'; +export default define({ + name: 'profile', + props: () => ({ + design: 0 + }) +}).extend({ + methods: { + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-profile + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-compact] + > .banner:before + content "" + display block + width 100% + height 100% + background rgba(0, 0, 0, 0.5) + + > .avatar + top ((100px - 58px) / 2) + left ((100px - 58px) / 2) + border none + border-radius 100% + box-shadow 0 0 16px rgba(0, 0, 0, 0.5) + + > .name + position absolute + top 0 + left 92px + margin 0 + line-height 100px + color #fff + text-shadow 0 0 8px rgba(0, 0, 0, 0.5) + + > .username + display none + + &[data-melt] + background transparent !important + border none !important + + > .banner + visibility hidden + + > .avatar + box-shadow none + + > .name + color #666 + text-shadow none + + > .banner + height 100px + background-color #f5f5f5 + background-size cover + background-position center + cursor pointer + + > .avatar + display block + position absolute + top 76px + left 16px + width 58px + height 58px + margin 0 + border solid 3px #fff + border-radius 8px + vertical-align bottom + cursor pointer + + > .name + display block + margin 10px 0 0 84px + line-height 16px + font-weight bold + color #555 + + > .username + display block + margin 4px 0 8px 84px + line-height 16px + font-size 0.9em + color #999 + +</style> diff --git a/src/web/app/desktop/views/components/widgets/rss.vue b/src/web/app/desktop/views/components/widgets/rss.vue new file mode 100644 index 0000000000..3507129716 --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/rss.vue @@ -0,0 +1,111 @@ +<template> +<div class="mkw-rss"> + <template v-if="!props.compact"> + <p class="title">%fa:rss-square%RSS</p> + <button title="設定">%fa:cog%</button> + </template> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <div class="feed" v-else> + <a v-for="item in items" :href="item.link" target="_blank">{{ item.title }}</a> + </div> +</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; + }); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-rss + 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 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + > button + position absolute + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .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 + +</style> diff --git a/src/web/app/desktop/views/components/widgets/server.cpu-memory.vue b/src/web/app/desktop/views/components/widgets/server.cpu-memory.vue new file mode 100644 index 0000000000..d75a142568 --- /dev/null +++ b/src/web/app/desktop/views/components/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/web/app/desktop/views/components/widgets/server.cpu.vue b/src/web/app/desktop/views/components/widgets/server.cpu.vue new file mode 100644 index 0000000000..596c856da8 --- /dev/null +++ b/src/web/app/desktop/views/components/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/web/app/desktop/views/components/widgets/server.disk.vue b/src/web/app/desktop/views/components/widgets/server.disk.vue new file mode 100644 index 0000000000..2af1982a96 --- /dev/null +++ b/src/web/app/desktop/views/components/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/web/app/desktop/views/components/widgets/server.info.vue b/src/web/app/desktop/views/components/widgets/server.info.vue new file mode 100644 index 0000000000..bed6a1b743 --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/server.info.vue @@ -0,0 +1,25 @@ +<template> +<div class="info"> + <p>Maintainer: <b>{{ meta.maintainer }}</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/web/app/desktop/views/components/widgets/server.memory.vue b/src/web/app/desktop/views/components/widgets/server.memory.vue new file mode 100644 index 0000000000..834a62671d --- /dev/null +++ b/src/web/app/desktop/views/components/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/web/app/desktop/views/components/widgets/server.pie.vue b/src/web/app/desktop/views/components/widgets/server.pie.vue new file mode 100644 index 0000000000..ce2cff1d00 --- /dev/null +++ b/src/web/app/desktop/views/components/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/web/app/desktop/views/components/widgets/server.uptimes.vue b/src/web/app/desktop/views/components/widgets/server.uptimes.vue new file mode 100644 index 0000000000..06713d83ce --- /dev/null +++ b/src/web/app/desktop/views/components/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/web/app/desktop/views/components/widgets/server.vue b/src/web/app/desktop/views/components/widgets/server.vue new file mode 100644 index 0000000000..1c0da84225 --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/server.vue @@ -0,0 +1,131 @@ +<template> +<div class="mkw-server" :data-melt="props.design == 2"> + <template v-if="props.design == 0"> + <p class="title">%fa:server%%i18n:desktop.tags.mk-server-home-widget.title%</p> + <button @click="toggle" title="%i18n:desktop.tags.mk-server-home-widget.toggle%">%fa:sort%</button> + </template> + <p class="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> +</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" scoped> +.mkw-server + 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/web/app/desktop/views/components/widgets/slideshow.vue b/src/web/app/desktop/views/components/widgets/slideshow.vue new file mode 100644 index 0000000000..c2f4eb70d3 --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/slideshow.vue @@ -0,0 +1,153 @@ +<template> +<div class="mkw-slideshow"> + <div @click="choose"> + <p v-if="props.folder === undefined">クリックしてフォルダを指定してください</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> + <button @click="resize">%fa:expand%</button> +</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: { + 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: () => { + (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', { + folder_id: 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 + + &:hover > button + display block + + > button + position absolute + left 0 + bottom 0 + display none + padding 4px + font-size 24px + color #fff + text-shadow 0 0 8px #000 + + > div + width 100% + height 100% + cursor pointer + + > * + 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/web/app/desktop/views/components/widgets/timemachine.vue b/src/web/app/desktop/views/components/widgets/timemachine.vue new file mode 100644 index 0000000000..7420482168 --- /dev/null +++ b/src/web/app/desktop/views/components/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/web/app/desktop/views/components/widgets/tips.vue b/src/web/app/desktop/views/components/widgets/tips.vue new file mode 100644 index 0000000000..2991fbc3b9 --- /dev/null +++ b/src/web/app/desktop/views/components/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/web/app/desktop/views/components/widgets/trends.vue b/src/web/app/desktop/views/components/widgets/trends.vue new file mode 100644 index 0000000000..934351b8a5 --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/trends.vue @@ -0,0 +1,128 @@ +<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="`/${ post.user.username }/${ post.id }`">{{ post.text }}</router-link></p> + <p class="author">―<router-link :to="`/${ post.user.username }`">@{{ post.user.username }}</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'; +export default define({ + name: 'trends', + props: () => ({ + compact: false + }) +}).extend({ + 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/web/app/desktop/views/components/widgets/users.vue b/src/web/app/desktop/views/components/widgets/users.vue new file mode 100644 index 0000000000..f3a1509cfd --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/users.vue @@ -0,0 +1,170 @@ +<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="`/${_user.username}`"> + <img class="avatar" :src="`${_user.avatar_url}?thumbnail&size=42`" alt="" v-user-preview="_user.id"/> + </router-link> + <div class="body"> + <router-link class="name" :to="`/${_user.username}`" v-user-preview="_user.id">{{ _user.name }}</router-link> + <p class="username">@{{ _user.username }}</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'; + +const limit = 3; + +export default define({ + name: 'users', + props: () => ({ + compact: false + }) +}).extend({ + data() { + return { + users: [], + fetching: true, + page: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + 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/web/app/desktop/views/components/widgets/version.vue b/src/web/app/desktop/views/components/widgets/version.vue new file mode 100644 index 0000000000..ad2b27bc40 --- /dev/null +++ b/src/web/app/desktop/views/components/widgets/version.vue @@ -0,0 +1,28 @@ +<template> +<p>ver {{ v }} (葵 aoi)</p> +</template> + +<script lang="ts"> +import { version } from '../../../../config'; +import define from '../../../../common/define-widget'; +export default define({ + name: 'version' +}).extend({ + data() { + return { + v: version + }; + } +}); +</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/web/app/desktop/views/components/window.vue b/src/web/app/desktop/views/components/window.vue new file mode 100644 index 0000000000..1dba9a25aa --- /dev/null +++ b/src/web/app/desktop/views/components/window.vue @@ -0,0 +1,591 @@ +<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" @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] + } + }, + + computed: { + isFlexible(): boolean { + return this.height == null; + }, + canResize(): boolean { + return !this.isFlexible; + } + }, + + created() { + // ウィンドウをウィンドウシステムに登録 + (this as any).os.windows.add(this); + }, + + mounted() { + 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 main = this.$refs.main as any; + + 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; + + const url = typeof this.popoutUrl == 'function' ? this.popoutUrl() : this.popoutUrl; + + window.open(url, url, + `height=${height}, width=${width}, left=${x}, top=${y}`); + + this.close(); + }, + + // 最前面へ移動 + 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> +.mk-window + display block + + > .bg + display block + position fixed + z-index 2048 + 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 2048 + 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 128 + 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) + + &, * + 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/web/app/desktop/scripts/autocomplete.ts b/src/web/app/desktop/views/directives/autocomplete.ts similarity index 78% rename from src/web/app/desktop/scripts/autocomplete.ts rename to src/web/app/desktop/views/directives/autocomplete.ts index 9df7aae08d..53fa5a4df2 100644 --- a/src/web/app/desktop/scripts/autocomplete.ts +++ b/src/web/app/desktop/views/directives/autocomplete.ts @@ -1,5 +1,18 @@ -import getCaretCoordinates = require('textarea-caret'); -import * as riot from 'riot'; +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); + self.x.attach(); + }, + + unbind(el, binding, vn) { + const self = el._autoCompleteDirective_; + self.x.detach(); + } +}; /** * オートコンプリートを管理するクラス。 @@ -65,7 +78,15 @@ class Autocomplete { this.close(); // サジェスト要素作成 - const tag = document.createElement('mk-autocomplete-suggestion'); + this.suggestion = new MkAutocomplete({ + propsData: { + textarea: this.textarea, + complete: this.complete, + close: this.close, + type: type, + q: q + } + }).$mount(); // ~ サジェストを表示すべき位置を計算 ~ @@ -76,20 +97,11 @@ class Autocomplete { const x = rect.left + window.pageXOffset + caretPosition.left; const y = rect.top + window.pageYOffset + caretPosition.top; - tag.style.left = x + 'px'; - tag.style.top = y + 'px'; + this.suggestion.$el.style.left = x + 'px'; + this.suggestion.$el.style.top = y + 'px'; // 要素追加 - const el = document.body.appendChild(tag); - - // マウント - this.suggestion = (riot as any).mount(el, { - textarea: this.textarea, - complete: this.complete, - close: this.close, - type: type, - q: q - })[0]; + document.body.appendChild(this.suggestion.$el); } /** @@ -98,7 +110,7 @@ class Autocomplete { private close() { if (this.suggestion == null) return; - this.suggestion.unmount(); + this.suggestion.$destroy(); this.suggestion = null; this.textarea.focus(); @@ -128,5 +140,3 @@ class Autocomplete { this.textarea.setSelectionRange(pos, pos); } } - -export default Autocomplete; diff --git a/src/web/app/desktop/views/directives/index.ts b/src/web/app/desktop/views/directives/index.ts new file mode 100644 index 0000000000..3d0c73b6b2 --- /dev/null +++ b/src/web/app/desktop/views/directives/index.ts @@ -0,0 +1,8 @@ +import Vue from 'vue'; + +import userPreview from './user-preview'; +import autocomplete from './autocomplete'; + +Vue.directive('userPreview', userPreview); +Vue.directive('user-preview', userPreview); +Vue.directive('autocomplete', autocomplete); diff --git a/src/web/app/desktop/views/directives/user-preview.ts b/src/web/app/desktop/views/directives/user-preview.ts new file mode 100644 index 0000000000..8a4035881a --- /dev/null +++ b/src/web/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/web/app/desktop/views/pages/drive.vue b/src/web/app/desktop/views/pages/drive.vue new file mode 100644 index 0000000000..353f59b703 --- /dev/null +++ b/src/web/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/web/app/desktop/views/pages/home-customize.vue b/src/web/app/desktop/views/pages/home-customize.vue new file mode 100644 index 0000000000..8aa06be57f --- /dev/null +++ b/src/web/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/web/app/desktop/views/pages/home.vue b/src/web/app/desktop/views/pages/home.vue new file mode 100644 index 0000000000..e1464bab1d --- /dev/null +++ b/src/web/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.user_id != (this as any).os.i.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${getPostSummary(post)}`; + } + }, + + onVisibilitychange() { + if (!document.hidden) { + this.unreadCount = 0; + document.title = 'Misskey'; + } + } + } +}); +</script> diff --git a/src/web/app/desktop/views/pages/index.vue b/src/web/app/desktop/views/pages/index.vue new file mode 100644 index 0000000000..0ea47d913b --- /dev/null +++ b/src/web/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/web/app/desktop/views/pages/messaging-room.vue b/src/web/app/desktop/views/pages/messaging-room.vue new file mode 100644 index 0000000000..d71a93b244 --- /dev/null +++ b/src/web/app/desktop/views/pages/messaging-room.vue @@ -0,0 +1,51 @@ +<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'; + +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', { + username: this.$route.params.username + }).then(user => { + this.user = user; + this.fetching = false; + + document.title = 'メッセージ: ' + this.user.name; + + Progress.done(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-messaging-room-page + background #fff + +</style> diff --git a/src/web/app/desktop/views/pages/post.vue b/src/web/app/desktop/views/pages/post.vue new file mode 100644 index 0000000000..c7b8729b72 --- /dev/null +++ b/src/web/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', { + post_id: 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/web/app/desktop/views/pages/search.vue b/src/web/app/desktop/views/pages/search.vue new file mode 100644 index 0000000000..b8e8db2e79 --- /dev/null +++ b/src/web/app/desktop/views/pages/search.vue @@ -0,0 +1,115 @@ +<template> +<mk-ui> + <header :class="$style.header"> + <h1>{{ query }}</h1> + </header> + <div :class="$style.loading" v-if="fetching"> + <mk-ellipsis-icon/> + </div> + <p :class="$style.empty" v-if="empty">%fa:search%「{{ query }}」に関する投稿は見つかりませんでした。</p> + <mk-posts ref="timeline" :class="$style.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 = 30; + +export default Vue.extend({ + props: ['query'], + data() { + return { + fetching: true, + moreFetching: false, + offset: 0, + posts: [] + }; + }, + computed: { + empty(): boolean { + return this.posts.length == 0; + } + }, + mounted() { + Progress.start(); + + document.addEventListener('keydown', this.onDocumentKeydown); + window.addEventListener('scroll', this.onScroll); + + (this as any).api('posts/search', parse(this.query)).then(posts => { + this.posts = posts; + this.fetching = false; + }); + }, + 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(); + } + } + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0) return; + this.offset += limit; + this.moreFetching = true; + return (this as any).api('posts/search', Object.assign({}, parse(this.query), { + limit: limit, + offset: this.offset + })).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(); + } + } +}); +</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/web/app/desktop/views/pages/selectdrive.vue b/src/web/app/desktop/views/pages/selectdrive.vue new file mode 100644 index 0000000000..b1f00da2b6 --- /dev/null +++ b/src/web/app/desktop/views/pages/selectdrive.vue @@ -0,0 +1,175 @@ +<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> +.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/web/app/desktop/views/pages/user/user.followers-you-know.vue b/src/web/app/desktop/views/pages/user/user.followers-you-know.vue new file mode 100644 index 0000000000..015b12d3d4 --- /dev/null +++ b/src/web/app/desktop/views/pages/user/user.followers-you-know.vue @@ -0,0 +1,79 @@ +<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="`/${user.username}`" :key="user.id"> + <img :src="`${user.avatar_url}?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'; +export default Vue.extend({ + props: ['user'], + data() { + return { + users: [], + fetching: true + }; + }, + mounted() { + (this as any).api('users/followers', { + user_id: 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/web/app/desktop/views/pages/user/user.friends.vue b/src/web/app/desktop/views/pages/user/user.friends.vue new file mode 100644 index 0000000000..d27009a82d --- /dev/null +++ b/src/web/app/desktop/views/pages/user/user.friends.vue @@ -0,0 +1,119 @@ +<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="`/${friend.username}`"> + <img class="avatar" :src="`${friend.avatar_url}?thumbnail&size=42`" alt="" v-user-preview="friend.id"/> + </router-link> + <div class="body"> + <router-link class="name" :to="`/${friend.username}`" v-user-preview="friend.id">{{ friend.name }}</router-link> + <p class="username">@{{ friend.username }}</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'; +export default Vue.extend({ + props: ['user'], + data() { + return { + users: [], + fetching: true + }; + }, + mounted() { + (this as any).api('users/get_frequently_replied_users', { + user_id: 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/web/app/desktop/views/pages/user/user.header.vue b/src/web/app/desktop/views/pages/user/user.header.vue new file mode 100644 index 0000000000..6c8375f163 --- /dev/null +++ b/src/web/app/desktop/views/pages/user/user.header.vue @@ -0,0 +1,188 @@ +<template> +<div class="header" :data-is-dark-background="user.banner_url != null"> + <div class="banner-container" :style="user.banner_url ? `background-image: url(${user.banner_url}?thumbnail&size=2048)` : ''"> + <div class="banner" ref="banner" :style="user.banner_url ? `background-image: url(${user.banner_url}?thumbnail&size=2048)` : ''" @click="onBannerClick"></div> + </div> + <div class="fade"></div> + <div class="container"> + <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=150`" alt="avatar"/> + <div class="title"> + <p class="name">{{ user.name }}</p> + <p class="username">@{{ user.username }}</p> + <p class="location" v-if="user.profile.location">%fa:map-marker%{{ user.profile.location }}</p> + </div> + <footer> + <router-link :to="`/${user.username}`" :data-active="$parent.page == 'home'">%fa:home%概要</router-link> + <router-link :to="`/${user.username}/media`" :data-active="$parent.page == 'media'">%fa:image%メディア</router-link> + <router-link :to="`/${user.username}/graphs`" :data-active="$parent.page == 'graphs'">%fa:chart-bar%グラフ</router-link> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['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.banner_url = i.banner_url; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.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/web/app/desktop/views/pages/user/user.home.vue b/src/web/app/desktop/views/pages/user/user.home.vue new file mode 100644 index 0000000000..dbf02bd07c --- /dev/null +++ b/src/web/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>%i18n:desktop.tags.mk-user.last-used-at%: <b><mk-time :time="user.last_used_at"/></b></p> + </div> + </div> + <main> + <mk-post-detail v-if="user.pinned_post" :post="user.pinned_post" compact/> + <x-timeline class="timeline" ref="tl" :user="user"/> + </main> + <div> + <div ref="right"> + <mk-calendar @chosen="warp" :start="new Date(user.created_at)"/> + <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/web/app/desktop/views/pages/user/user.photos.vue b/src/web/app/desktop/views/pages/user/user.photos.vue new file mode 100644 index 0000000000..db29a9945a --- /dev/null +++ b/src/web/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', { + user_id: this.user.id, + with_media: 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/web/app/desktop/views/pages/user/user.profile.vue b/src/web/app/desktop/views/pages/user/user.profile.vue new file mode 100644 index 0000000000..ceca829ace --- /dev/null +++ b/src/web/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.is_followed">%i18n:desktop.tags.mk-user.follows-you%</p> + <p v-if="user.is_muted">%i18n:desktop.tags.mk-user.muted% <a @click="unmute">%i18n:desktop.tags.mk-user.unmute%</a></p> + <p v-if="!user.is_muted"><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.profile.birthday"> + <p>%fa:birthday-cake%{{ user.profile.birthday.replace('-', '年').replace('-', '月') + '日' }} ({{ age }}歳)</p> + </div> + <div class="twitter" v-if="user.twitter"> + <p>%fa:B twitter%<a :href="`https://twitter.com/${user.twitter.screen_name}`" target="_blank">@{{ user.twitter.screen_name }}</a></p> + </div> + <div class="status"> + <p class="posts-count">%fa:angle-right%<a>{{ user.posts_count }}</a><b>投稿</b></p> + <p class="following">%fa:angle-right%<a @click="showFollowing">{{ user.following_count }}</a>人を<b>フォロー</b></p> + <p class="followers">%fa:angle-right%<a @click="showFollowers">{{ user.followers_count }}</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.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', { + user_id: this.user.id + }).then(() => { + this.user.is_muted = true; + }, () => { + alert('error'); + }); + }, + + unmute() { + (this as any).api('mute/delete', { + user_id: this.user.id + }).then(() => { + this.user.is_muted = 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/web/app/desktop/views/pages/user/user.timeline.vue b/src/web/app/desktop/views/pages/user/user.timeline.vue new file mode 100644 index 0000000000..d8fff6ce6b --- /dev/null +++ b/src/web/app/desktop/views/pages/user/user.timeline.vue @@ -0,0 +1,137 @@ +<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', { + user_id: this.user.id, + until_date: 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', { + user_id: this.user.id, + with_replies: this.mode == 'with-replies', + until_id: 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> +.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/web/app/desktop/views/pages/user/user.vue b/src/web/app/desktop/views/pages/user/user.vue new file mode 100644 index 0000000000..1ce3fa27e7 --- /dev/null +++ b/src/web/app/desktop/views/pages/user/user.vue @@ -0,0 +1,54 @@ +<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 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', { + username: this.$route.params.user + }).then(user => { + this.user = user; + this.fetching = false; + Progress.done(); + document.title = user.name + ' | Misskey'; + }); + } + } +}); +</script> + diff --git a/src/web/app/desktop/views/pages/welcome.vue b/src/web/app/desktop/views/pages/welcome.vue new file mode 100644 index 0000000000..f359ce008e --- /dev/null +++ b/src/web/app/desktop/views/pages/welcome.vue @@ -0,0 +1,163 @@ +<template> +<div class="mk-welcome"> + <main> + <div> + <h1>Share<br>Everything!</h1> + <p>ようこそ! <b>Misskey</b>はTwitter風ミニブログSNSです――思ったこと、共有したいことをシンプルに書き残せます。タイムラインを見れば、皆の反応や皆がどう思っているのかもすぐにわかります。<a>詳しく...</a></p> + <p><button class="signup" @click="signup">はじめる</button><button class="signin" @click="signin">ログイン</button></p> + </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 { copyright } from '../../../config'; + +export default Vue.extend({ + data() { + return { + copyright + }; + }, + methods: { + signup() { + this.$modal.show('signup'); + }, + signin() { + this.$modal.show('signin'); + } + } +}); +</script> + +<style> +#wait { + right: auto; + left: 15px; +} +</style> + +<style lang="stylus" scoped> +.mk-welcome + display flex + flex-direction column + flex 1 + background #eee + $width = 1000px + + > main + display flex + flex 1 + max-width $width + margin 0 auto + padding 80px 0 0 0 + + > div:first-child + margin 0 auto 0 0 + width calc(100% - 500px) + color #777 + + > h1 + margin 0 + font-weight normal + font-variant small-caps + letter-spacing 12px + + > p + margin 0.5em 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 + &:focus + color #444 + + &:hover + color #444 + + &:active + color #333 + + > div:last-child + margin 0 0 0 auto + + > footer + color #666 + background #fff + + > div + max-width $width + margin 0 auto + padding 42px 0 + text-align center + + > .c + margin 16px 0 0 0 + font-size 10px + +</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> diff --git a/src/web/app/dev/tags/new-app-form.tag b/src/web/app/dev/tags/new-app-form.tag index fdd442a836..cf3c440079 100644 --- a/src/web/app/dev/tags/new-app-form.tag +++ b/src/web/app/dev/tags/new-app-form.tag @@ -10,13 +10,13 @@ <label> <p class="caption">Named ID</p> <input ref="nid" type="text" pattern="^[a-zA-Z0-9-]{3,30}$" placeholder="ex) misskey-for-ios" autocomplete="off" required="required" onkeyup={ onChangeNid }/> - <p class="info" if={ nidState == 'wait' } style="color:#999">%fa:spinner .pulse .fw%確認しています...</p> - <p class="info" if={ nidState == 'ok' } style="color:#3CB7B5">%fa:fw check%利用できます</p> - <p class="info" if={ nidState == 'unavailable' } style="color:#FF1161">%fa:fw exclamation-triangle%既に利用されています</p> - <p class="info" if={ nidState == 'error' } style="color:#FF1161">%fa:fw exclamation-triangle%通信エラー</p> - <p class="info" if={ nidState == 'invalid-format' } style="color:#FF1161">%fa:fw exclamation-triangle%a~z、A~Z、0~9、-(ハイフン)が使えます</p> - <p class="info" if={ nidState == 'min-range' } style="color:#FF1161">%fa:fw exclamation-triangle%3文字以上でお願いします!</p> - <p class="info" if={ nidState == 'max-range' } style="color:#FF1161">%fa:fw exclamation-triangle%30文字以内でお願いします</p> + <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> </label> </section> <section class="description"> @@ -73,9 +73,9 @@ </div> <p>%fa:exclamation-triangle%アプリ作成後も変更できますが、新たな権限を付与する場合、その時点で関連付けられているユーザーキーはすべて無効になります。</p> </section> - <button onclick={ onsubmit }>アプリ作成</button> + <button @click="onsubmit">アプリ作成</button> </form> - <style> + <style lang="stylus" scoped> :scope display block overflow hidden @@ -177,13 +177,13 @@ border-radius 3px </style> - <script> + <script lang="typescript"> this.mixin('api'); this.nidState = null; this.onChangeNid = () => { - const nid = this.refs.nid.value; + const nid = this.$refs.nid.value; if (nid == '') { this.update({ @@ -209,7 +209,7 @@ nidState: 'wait' }); - this.api('app/name_id/available', { + this.$root.$data.os.api('app/name_id/available', { name_id: nid }).then(result => { this.update({ @@ -223,19 +223,19 @@ }; this.onsubmit = () => { - const name = this.refs.name.value; - const nid = this.refs.nid.value; - const description = this.refs.description.value; - const cb = this.refs.cb.value; + const name = this.$refs.name.value; + const nid = this.$refs.nid.value; + const description = this.$refs.description.value; + const cb = this.$refs.cb.value; const permission = []; - this.refs.permission.querySelectorAll('input').forEach(el => { + this.$refs.permission.querySelectorAll('input').forEach(el => { if (el.checked) permission.push(el.value); }); const locker = document.body.appendChild(document.createElement('mk-locker')); - this.api('app/create', { + this.$root.$data.os.api('app/create', { name: name, name_id: nid, description: description, diff --git a/src/web/app/dev/tags/pages/app.tag b/src/web/app/dev/tags/pages/app.tag index b25e0d8595..982549ed2b 100644 --- a/src/web/app/dev/tags/pages/app.tag +++ b/src/web/app/dev/tags/pages/app.tag @@ -1,6 +1,6 @@ <mk-app-page> - <p if={ fetching }>読み込み中</p> - <main if={ !fetching }> + <p v-if="fetching">読み込み中</p> + <main v-if="!fetching"> <header> <h1>{ app.name }</h1> </header> @@ -9,17 +9,17 @@ <input value={ app.secret } readonly="readonly"/> </div> </main> - <style> + <style lang="stylus" scoped> :scope display block </style> - <script> + <script lang="typescript"> this.mixin('api'); this.fetching = true; this.on('mount', () => { - this.api('app/show', { + this.$root.$data.os.api('app/show', { app_id: this.opts.app }).then(app => { this.update({ diff --git a/src/web/app/dev/tags/pages/apps.tag b/src/web/app/dev/tags/pages/apps.tag index 43db70fcf2..6ae6031e64 100644 --- a/src/web/app/dev/tags/pages/apps.tag +++ b/src/web/app/dev/tags/pages/apps.tag @@ -1,26 +1,26 @@ <mk-apps-page> <h1>アプリを管理</h1><a href="/app/new">アプリ作成</a> <div class="apps"> - <p if={ fetching }>読み込み中</p> - <virtual if={ !fetching }> - <p if={ apps.length == 0 }>アプリなし</p> - <ul if={ apps.length > 0 }> + <p v-if="fetching">読み込み中</p> + <template v-if="!fetching"> + <p v-if="apps.length == 0">アプリなし</p> + <ul v-if="apps.length > 0"> <li each={ app in apps }><a href={ '/app/' + app.id }> <p class="name">{ app.name }</p></a></li> </ul> - </virtual> + </template> </div> - <style> + <style lang="stylus" scoped> :scope display block </style> - <script> + <script lang="typescript"> this.mixin('api'); this.fetching = true; this.on('mount', () => { - this.api('my/apps').then(apps => { + this.$root.$data.os.api('my/apps').then(apps => { this.fetching = false this.apps = apps this.update({ diff --git a/src/web/app/dev/tags/pages/index.tag b/src/web/app/dev/tags/pages/index.tag index f863876fa7..ca270b3774 100644 --- a/src/web/app/dev/tags/pages/index.tag +++ b/src/web/app/dev/tags/pages/index.tag @@ -1,5 +1,5 @@ <mk-index><a href="/apps">アプリ</a> - <style> + <style lang="stylus" scoped> :scope display block </style> diff --git a/src/web/app/dev/tags/pages/new-app.tag b/src/web/app/dev/tags/pages/new-app.tag index 238b6865e1..26185f278b 100644 --- a/src/web/app/dev/tags/pages/new-app.tag +++ b/src/web/app/dev/tags/pages/new-app.tag @@ -6,7 +6,7 @@ </header> <mk-new-app-form/> </main> - <style> + <style lang="stylus" scoped> :scope display block padding 64px 0 diff --git a/src/web/app/init.ts b/src/web/app/init.ts index 154b1ba0f0..ac567c5023 100644 --- a/src/web/app/init.ts +++ b/src/web/app/init.ts @@ -5,13 +5,36 @@ declare const _VERSION_: string; declare const _LANG_: string; declare const _HOST_: string; -declare const __CONSTS__: any; +//declare const __CONSTS__: any; + +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import VModal from 'vue-js-modal'; + +Vue.use(VueRouter); +Vue.use(VModal); + +// Register global directives +require('./common/views/directives'); + +// Register global components +require('./common/views/components'); + +// Register global filters +require('./common/filters'); + +Vue.mixin({ + destroyed(this: any) { + if (this.$el.parentNode) { + this.$el.parentNode.removeChild(this.$el); + } + } +}); + +import App from './app.vue'; -import * as riot from 'riot'; import checkForUpdate from './common/scripts/check-for-update'; -import mixin from './common/mixins'; -import MiOS from './common/mios'; -require('./common/tags'); +import MiOS, { API } from './common/mios'; /** * APP ENTRY POINT! @@ -27,21 +50,21 @@ if (_HOST_ != 'localhost') { document.domain = _HOST_; } -{ // Set lang attr - const html = document.documentElement; - html.setAttribute('lang', _LANG_); -} +//#region Set lang attr +const html = document.documentElement; +html.setAttribute('lang', _LANG_); +//#endregion -{ // 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); -} +//#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 // Set global configuration -(riot as any).mixin(__CONSTS__); +//(riot as any).mixin(__CONSTS__); // iOSでプライベートモードだとlocalStorageが使えないので既存のメソッドを上書きする try { @@ -57,31 +80,58 @@ if (localStorage.getItem('should-refresh') == 'true') { } // MiOSを初期化してコールバックする -export default (callback, sw = false) => { - const mios = new MiOS(sw); - - mios.init(() => { - // ミックスイン初期化 - mixin(mios); - - // ローディング画面クリア - const ini = document.getElementById('ini'); - ini.parentNode.removeChild(ini); +export default (callback: (launch: (api: (os: MiOS) => API) => [Vue, MiOS]) => void, sw = false) => { + const os = new MiOS(sw); + os.init(() => { // アプリ基底要素マウント - const app = document.createElement('div'); - app.setAttribute('id', 'app'); - document.body.appendChild(app); + document.body.innerHTML = '<div id="app"></div>'; + + const app = new Vue({ + router: new VueRouter({ + mode: 'history' + }), + created() { + this.$watch('os.i', i => { + // キャッシュ更新 + localStorage.setItem('me', JSON.stringify(i)); + }, { + deep: true + }); + }, + render: createEl => createEl(App) + }); + + os.app = app; + + const launch = (api: (os: MiOS) => API) => { + os.apis = api(os); + + Vue.mixin({ + data() { + return { + os, + api: os.api, + apis: os.apis + }; + } + }); + + // マウント + app.$mount('#app'); + + return [app, os] as [Vue, MiOS]; + }; try { - callback(mios); + callback(launch); } catch (e) { panic(e); } // 更新チェック setTimeout(() => { - checkForUpdate(mios); + checkForUpdate(os); }, 3000); }); }; diff --git a/src/web/app/mobile/api/choose-drive-file.ts b/src/web/app/mobile/api/choose-drive-file.ts new file mode 100644 index 0000000000..b1a78f2364 --- /dev/null +++ b/src/web/app/mobile/api/choose-drive-file.ts @@ -0,0 +1,18 @@ +import Chooser from '../views/components/drive-file-chooser.vue'; + +export default function(opts) { + return new Promise((res, rej) => { + const o = opts || {}; + const w = new Chooser({ + propsData: { + title: o.title, + multiple: o.multiple, + initFolder: o.currentFolder + } + }).$mount(); + w.$once('selected', file => { + res(file); + }); + document.body.appendChild(w.$el); + }); +} diff --git a/src/web/app/mobile/api/choose-drive-folder.ts b/src/web/app/mobile/api/choose-drive-folder.ts new file mode 100644 index 0000000000..d1f97d1487 --- /dev/null +++ b/src/web/app/mobile/api/choose-drive-folder.ts @@ -0,0 +1,17 @@ +import Chooser from '../views/components/drive-folder-chooser.vue'; + +export default function(opts) { + return new Promise((res, rej) => { + const o = opts || {}; + const w = new Chooser({ + propsData: { + title: o.title, + initFolder: o.currentFolder + } + }).$mount(); + w.$once('selected', folder => { + res(folder); + }); + document.body.appendChild(w.$el); + }); +} diff --git a/src/web/app/mobile/api/dialog.ts b/src/web/app/mobile/api/dialog.ts new file mode 100644 index 0000000000..a2378767be --- /dev/null +++ b/src/web/app/mobile/api/dialog.ts @@ -0,0 +1,5 @@ +export default function(opts) { + return new Promise<string>((res, rej) => { + alert('dialog not implemented yet'); + }); +} diff --git a/src/web/app/mobile/api/input.ts b/src/web/app/mobile/api/input.ts new file mode 100644 index 0000000000..fcff68cfb6 --- /dev/null +++ b/src/web/app/mobile/api/input.ts @@ -0,0 +1,5 @@ +export default function(opts) { + return new Promise<string>((res, rej) => { + alert('input not implemented yet'); + }); +} diff --git a/src/web/app/mobile/api/notify.ts b/src/web/app/mobile/api/notify.ts new file mode 100644 index 0000000000..82780d196f --- /dev/null +++ b/src/web/app/mobile/api/notify.ts @@ -0,0 +1,3 @@ +export default function(message) { + alert(message); +} diff --git a/src/web/app/mobile/api/post.ts b/src/web/app/mobile/api/post.ts new file mode 100644 index 0000000000..3b14e0c1d4 --- /dev/null +++ b/src/web/app/mobile/api/post.ts @@ -0,0 +1,41 @@ +import PostForm from '../views/components/post-form.vue'; +//import RepostForm from '../views/components/repost-form.vue'; +import getPostSummary from '../../../../common/get-post-summary'; + +export default (os) => (opts) => { + const o = opts || {}; + + if (o.repost) { + /*const vm = new RepostForm({ + propsData: { + repost: o.repost + } + }).$mount(); + vm.$once('cancel', recover); + vm.$once('post', recover); + document.body.appendChild(vm.$el);*/ + + const text = window.prompt(`「${getPostSummary(o.repost)}」をRepost`); + if (text == null) return; + os.api('posts/create', { + repost_id: o.repost.id, + text: text == '' ? undefined : text + }); + } else { + const app = document.getElementById('app'); + app.style.display = 'none'; + + function recover() { + app.style.display = 'block'; + } + + const vm = new PostForm({ + propsData: { + reply: o.reply + } + }).$mount(); + vm.$once('cancel', recover); + vm.$once('post', recover); + document.body.appendChild(vm.$el); + } +}; diff --git a/src/web/app/mobile/router.ts b/src/web/app/mobile/router.ts deleted file mode 100644 index afb9aa6201..0000000000 --- a/src/web/app/mobile/router.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Mobile App Router - */ - -import * as riot from 'riot'; -import * as route from 'page'; -import MiOS from '../common/mios'; -let page = null; - -export default (mios: MiOS) => { - route('/', index); - route('/selectdrive', selectDrive); - route('/i/notifications', notifications); - route('/i/messaging', messaging); - route('/i/messaging/:username', messaging); - route('/i/drive', drive); - route('/i/drive/folder/:folder', drive); - route('/i/drive/file/:file', drive); - route('/i/settings', settings); - route('/i/settings/profile', settingsProfile); - route('/i/settings/signin-history', settingsSignin); - route('/i/settings/twitter', settingsTwitter); - route('/i/settings/authorized-apps', settingsAuthorizedApps); - route('/post/new', newPost); - route('/post::post', post); - route('/search', search); - route('/:user', user.bind(null, 'overview')); - route('/:user/graphs', user.bind(null, 'graphs')); - route('/:user/followers', userFollowers); - route('/:user/following', userFollowing); - route('/:user/:post', post); - route('*', notFound); - - function index() { - mios.isSignedin ? home() : entrance(); - } - - function home() { - mount(document.createElement('mk-home-page')); - } - - function entrance() { - mount(document.createElement('mk-entrance')); - } - - function notifications() { - mount(document.createElement('mk-notifications-page')); - } - - function messaging(ctx) { - if (ctx.params.username) { - const el = document.createElement('mk-messaging-room-page'); - el.setAttribute('username', ctx.params.username); - mount(el); - } else { - mount(document.createElement('mk-messaging-page')); - } - } - - function newPost() { - mount(document.createElement('mk-new-post-page')); - } - - function settings() { - mount(document.createElement('mk-settings-page')); - } - - function settingsProfile() { - mount(document.createElement('mk-profile-setting-page')); - } - - function settingsSignin() { - mount(document.createElement('mk-signin-history-page')); - } - - function settingsTwitter() { - mount(document.createElement('mk-twitter-setting-page')); - } - - function settingsAuthorizedApps() { - mount(document.createElement('mk-authorized-apps-page')); - } - - function search(ctx) { - const el = document.createElement('mk-search-page'); - el.setAttribute('query', ctx.querystring.substr(2)); - mount(el); - } - - function user(page, ctx) { - const el = document.createElement('mk-user-page'); - el.setAttribute('user', ctx.params.user); - el.setAttribute('page', page); - mount(el); - } - - function userFollowing(ctx) { - const el = document.createElement('mk-user-following-page'); - el.setAttribute('user', ctx.params.user); - mount(el); - } - - function userFollowers(ctx) { - const el = document.createElement('mk-user-followers-page'); - el.setAttribute('user', ctx.params.user); - mount(el); - } - - function post(ctx) { - const el = document.createElement('mk-post-page'); - el.setAttribute('post', ctx.params.post); - mount(el); - } - - function drive(ctx) { - const el = document.createElement('mk-drive-page'); - if (ctx.params.folder) el.setAttribute('folder', ctx.params.folder); - if (ctx.params.file) el.setAttribute('file', ctx.params.file); - mount(el); - } - - function selectDrive() { - mount(document.createElement('mk-selectdrive-page')); - } - - function notFound() { - mount(document.createElement('mk-not-found')); - } - - (riot as any).mixin('page', { - page: route - }); - - // EXEC - (route as any)(); -}; - -function mount(content) { - document.documentElement.style.background = '#fff'; - if (page) page.unmount(); - const body = document.getElementById('app'); - page = riot.mount(body.appendChild(content))[0]; -} diff --git a/src/web/app/mobile/script.ts b/src/web/app/mobile/script.ts index 4dfff8f72f..fe73155c7c 100644 --- a/src/web/app/mobile/script.ts +++ b/src/web/app/mobile/script.ts @@ -5,18 +5,70 @@ // Style import './style.styl'; -require('./tags'); import init from '../init'; -import route from './router'; -import MiOS from '../common/mios'; + +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'; /** * init */ -init((mios: MiOS) => { +init((launch) => { + // Register directives + require('./views/directives'); + + // Register components + require('./views/components'); + // http://qiita.com/junya/items/3ff380878f26ca447f85 document.body.setAttribute('ontouchstart', ''); - // Start routing - route(mios); + // Launch the app + const [app] = launch(os => ({ + chooseDriveFolder, + chooseDriveFile, + dialog, + input, + post: post(os), + notify + })); + + // Routing + app.$router.addRoutes([ + { 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/:username', 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: '/:user', component: MkUser }, + { path: '/:user/followers', component: MkFollowers }, + { path: '/:user/following', component: MkFollowing }, + { path: '/:user/:post', component: MkPost } + ]); }, true); diff --git a/src/web/app/mobile/tags/drive-folder-selector.tag b/src/web/app/mobile/tags/drive-folder-selector.tag deleted file mode 100644 index 35d0208a07..0000000000 --- a/src/web/app/mobile/tags/drive-folder-selector.tag +++ /dev/null @@ -1,69 +0,0 @@ -<mk-drive-folder-selector> - <div class="body"> - <header> - <h1>%i18n:mobile.tags.mk-drive-folder-selector.select-folder%</h1> - <button class="close" onclick={ cancel }>%fa:times%</button> - <button class="ok" onclick={ ok }>%fa:check%</button> - </header> - <mk-drive ref="browser" select-folder={ true }/> - </div> - <style> - :scope - display block - 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> - <script> - this.cancel = () => { - this.trigger('canceled'); - this.unmount(); - }; - - this.ok = () => { - this.trigger('selected', this.refs.browser.folder); - this.unmount(); - }; - </script> -</mk-drive-folder-selector> diff --git a/src/web/app/mobile/tags/drive-selector.tag b/src/web/app/mobile/tags/drive-selector.tag deleted file mode 100644 index f8bc49dab0..0000000000 --- a/src/web/app/mobile/tags/drive-selector.tag +++ /dev/null @@ -1,88 +0,0 @@ -<mk-drive-selector> - <div class="body"> - <header> - <h1>%i18n:mobile.tags.mk-drive-selector.select-file%<span class="count" if={ files.length > 0 }>({ files.length })</span></h1> - <button class="close" onclick={ cancel }>%fa:times%</button> - <button if={ opts.multiple } class="ok" onclick={ ok }>%fa:check%</button> - </header> - <mk-drive ref="browser" select-file={ true } multiple={ opts.multiple }/> - </div> - <style> - :scope - display block - 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> - <script> - this.files = []; - - this.on('mount', () => { - this.refs.browser.on('change-selection', files => { - this.update({ - files: files - }); - }); - - this.refs.browser.on('selected', file => { - this.trigger('selected', file); - this.unmount(); - }); - }); - - this.cancel = () => { - this.trigger('canceled'); - this.unmount(); - }; - - this.ok = () => { - this.trigger('selected', this.files); - this.unmount(); - }; - </script> -</mk-drive-selector> diff --git a/src/web/app/mobile/tags/drive.tag b/src/web/app/mobile/tags/drive.tag deleted file mode 100644 index 2a3ff23bfa..0000000000 --- a/src/web/app/mobile/tags/drive.tag +++ /dev/null @@ -1,580 +0,0 @@ -<mk-drive> - <nav ref="nav"> - <a onclick={ goRoot } href="/i/drive">%fa:cloud%%i18n:mobile.tags.mk-drive.drive%</a> - <virtual each={ folder in hierarchyFolders }> - <span>%fa:angle-right%</span> - <a onclick={ move } href="/i/drive/folder/{ folder.id }">{ folder.name }</a> - </virtual> - <virtual if={ folder != null }> - <span>%fa:angle-right%</span> - <p>{ folder.name }</p> - </virtual> - <virtual if={ file != null }> - <span>%fa:angle-right%</span> - <p>{ file.name }</p> - </virtual> - </nav> - <mk-uploader ref="uploader"/> - <div class="browser { fetching: fetching }" if={ file == null }> - <div class="info" if={ info }> - <p if={ folder == null }>{ (info.usage / info.capacity * 100).toFixed(1) }% %i18n:mobile.tags.mk-drive.used%</p> - <p if={ folder != null && (folder.folders_count > 0 || folder.files_count > 0) }> - <virtual if={ folder.folders_count > 0 }>{ folder.folders_count } %i18n:mobile.tags.mk-drive.folder-count%</virtual> - <virtual if={ folder.folders_count > 0 && folder.files_count > 0 }>%i18n:mobile.tags.mk-drive.count-separator%</virtual> - <virtual if={ folder.files_count > 0 }>{ folder.files_count } %i18n:mobile.tags.mk-drive.file-count%</virtual> - </p> - </div> - <div class="folders" if={ folders.length > 0 }> - <virtual each={ folder in folders }> - <mk-drive-folder folder={ folder }/> - </virtual> - <p if={ moreFolders }>%i18n:mobile.tags.mk-drive.load-more%</p> - </div> - <div class="files" if={ files.length > 0 }> - <virtual each={ file in files }> - <mk-drive-file file={ file }/> - </virtual> - <button class="more" if={ moreFiles } onclick={ fetchMoreFiles }> - { fetchingMoreFiles ? '%i18n:common.loading%' : '%i18n:mobile.tags.mk-drive.load-more%' } - </button> - </div> - <div class="empty" if={ files.length == 0 && folders.length == 0 && !fetching }> - <p if={ folder == null }>%i18n:mobile.tags.mk-drive.nothing-in-drive%</p> - <p if={ folder != null }>%i18n:mobile.tags.mk-drive.folder-is-empty%</p> - </div> - </div> - <div class="fetching" 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" type="file" multiple="multiple" onchange={ changeLocalFile }/> - <mk-drive-file-viewer if={ file != null } file={ file }/> - <style> - :scope - display block - 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 - > mk-drive-folder - border-bottom solid 1px #eee - - > .files - > mk-drive-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); - } - } - - > [ref='file'] - display none - - </style> - <script> - this.mixin('i'); - this.mixin('api'); - - this.mixin('drive-stream'); - this.connection = this.driveStream.getConnection(); - this.connectionId = this.driveStream.use(); - - this.files = []; - this.folders = []; - this.hierarchyFolders = []; - this.selectedFiles = []; - - // 現在の階層(フォルダ) - // * null でルートを表す - this.folder = null; - - this.file = null; - - this.isFileSelectMode = this.opts.selectFile; - this.multiple = this.opts.multiple; - - this.on('mount', () => { - 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.opts.folder) { - this.cd(this.opts.folder, true); - } else if (this.opts.file) { - this.cf(this.opts.file, true); - } else { - this.fetch(); - } - - if (this.opts.isNaked) { - this.refs.nav.style.top = `${this.opts.top}px`; - } - }); - - this.on('unmount', () => { - 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.driveStream.dispose(this.connectionId); - }); - - this.onStreamDriveFileCreated = file => { - this.addFile(file, true); - }; - - this.onStreamDriveFileUpdated = file => { - const current = this.folder ? this.folder.id : null; - if (current != file.folder_id) { - this.removeFile(file); - } else { - this.addFile(file, true); - } - }; - - this.onStreamDriveFolderCreated = folder => { - this.addFolder(folder, true); - }; - - this.onStreamDriveFolderUpdated = folder => { - const current = this.folder ? this.folder.id : null; - if (current != folder.parent_id) { - this.removeFolder(folder); - } else { - this.addFolder(folder, true); - } - }; - - this.move = ev => { - ev.preventDefault(); - this.cd(ev.item.folder); - return false; - }; - - this.cd = (target, silent = false) => { - this.file = null; - - if (target == null) { - this.goRoot(); - return; - } else if (typeof target == 'object') target = target.id; - - this.update({ - fetching: true - }); - - this.api('drive/folders/show', { - folder_id: target - }).then(folder => { - this.folder = folder; - this.hierarchyFolders = []; - - if (folder.parent) dive(folder.parent); - - this.update(); - this.trigger('open-folder', this.folder, silent); - this.fetch(); - }); - }; - - this.addFolder = (folder, unshift = false) => { - const current = this.folder ? this.folder.id : null; - // 追加しようとしているフォルダが、今居る階層とは違う階層のものだったら中断 - if (current != folder.parent_id) return; - - // 追加しようとしているフォルダを既に所有してたら中断 - if (this.folders.some(f => f.id == folder.id)) return; - - if (unshift) { - this.folders.unshift(folder); - } else { - this.folders.push(folder); - } - - this.update(); - }; - - this.addFile = (file, unshift = false) => { - const current = this.folder ? this.folder.id : null; - // 追加しようとしているファイルが、今居る階層とは違う階層のものだったら中断 - if (current != file.folder_id) return; - - if (this.files.some(f => f.id == file.id)) { - const exist = this.files.map(f => f.id).indexOf(file.id); - this.files[exist] = file; - this.update(); - return; - } - - if (unshift) { - this.files.unshift(file); - } else { - this.files.push(file); - } - - this.update(); - }; - - this.removeFolder = folder => { - if (typeof folder == 'object') folder = folder.id; - this.folders = this.folders.filter(f => f.id != folder); - this.update(); - }; - - this.removeFile = file => { - if (typeof file == 'object') file = file.id; - this.files = this.files.filter(f => f.id != file); - this.update(); - }; - - this.appendFile = file => this.addFile(file); - this.appendFolder = file => this.addFolder(file); - this.prependFile = file => this.addFile(file, true); - this.prependFolder = file => this.addFolder(file, true); - - this.goRoot = ev => { - ev.preventDefault(); - - if (this.folder || this.file) { - this.update({ - file: null, - folder: null, - hierarchyFolders: [] - }); - this.trigger('move-root'); - this.fetch(); - } - - return false; - }; - - this.fetch = () => { - this.update({ - folders: [], - files: [], - moreFolders: false, - moreFiles: false, - fetching: true - }); - - this.trigger('begin-fetch'); - - let fetchedFolders = null; - let fetchedFiles = null; - - const foldersMax = 20; - const filesMax = 20; - - // フォルダ一覧取得 - this.api('drive/folders', { - folder_id: this.folder ? this.folder.id : null, - limit: foldersMax + 1 - }).then(folders => { - if (folders.length == foldersMax + 1) { - this.moreFolders = true; - folders.pop(); - } - fetchedFolders = folders; - complete(); - }); - - // ファイル一覧取得 - this.api('drive/files', { - folder_id: this.folder ? this.folder.id : null, - limit: filesMax + 1 - }).then(files => { - if (files.length == filesMax + 1) { - this.moreFiles = true; - files.pop(); - } - fetchedFiles = files; - complete(); - }); - - let flag = false; - const complete = () => { - if (flag) { - fetchedFolders.forEach(this.appendFolder); - fetchedFiles.forEach(this.appendFile); - this.update({ - fetching: false - }); - // 一連の読み込みが完了したイベントを発行 - this.trigger('fetched'); - } else { - flag = true; - // 一連の読み込みが半分完了したイベントを発行 - this.trigger('fetch-mid'); - } - }; - - if (this.folder == null) { - // Fetch addtional drive info - this.api('drive').then(info => { - this.update({ info }); - }); - } - }; - - this.fetchMoreFiles = () => { - this.update({ - fetching: true, - fetchingMoreFiles: true - }); - - const max = 30; - - // ファイル一覧取得 - this.api('drive/files', { - folder_id: this.folder ? this.folder.id : null, - limit: max + 1, - until_id: this.files[this.files.length - 1].id - }).then(files => { - if (files.length == max + 1) { - this.moreFiles = true; - files.pop(); - } else { - this.moreFiles = false; - } - files.forEach(this.appendFile); - this.update({ - fetching: false, - fetchingMoreFiles: false - }); - }); - }; - - this.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.update(); - this.trigger('change-selection', this.selectedFiles); - } else { - this.trigger('selected', file); - } - } else { - this.cf(file); - } - }; - - this.cf = (file, silent = false) => { - if (typeof file == 'object') file = file.id; - - this.update({ - fetching: true - }); - - this.api('drive/files/show', { - file_id: file - }).then(file => { - this.fetching = false; - this.file = file; - this.folder = null; - this.hierarchyFolders = []; - - if (file.folder) dive(file.folder); - - this.update(); - this.trigger('open-file', this.file, silent); - }); - }; - - const dive = folder => { - this.hierarchyFolders.unshift(folder); - if (folder.parent) dive(folder.parent); - }; - - this.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; - } - }; - - this.selectLocalFile = () => { - this.refs.file.click(); - }; - - this.createFolder = () => { - const name = window.prompt('フォルダー名'); - if (name == null || name == '') return; - this.api('drive/folders/create', { - name: name, - parent_id: this.folder ? this.folder.id : undefined - }).then(folder => { - this.addFolder(folder, true); - this.update(); - }); - }; - - this.renameFolder = () => { - if (this.folder == null) { - alert('現在いる場所はルートで、フォルダではないため名前の変更はできません。名前を変更したいフォルダに移動してからやってください。'); - return; - } - const name = window.prompt('フォルダー名', this.folder.name); - if (name == null || name == '') return; - this.api('drive/folders/update', { - name: name, - folder_id: this.folder.id - }).then(folder => { - this.cd(folder); - }); - }; - - this.moveFolder = () => { - if (this.folder == null) { - alert('現在いる場所はルートで、フォルダではないため移動はできません。移動したいフォルダに移動してからやってください。'); - return; - } - const dialog = riot.mount(document.body.appendChild(document.createElement('mk-drive-folder-selector')))[0]; - dialog.one('selected', folder => { - this.api('drive/folders/update', { - parent_id: folder ? folder.id : null, - folder_id: this.folder.id - }).then(folder => { - this.cd(folder); - }); - }); - }; - - this.urlUpload = () => { - const url = window.prompt('アップロードしたいファイルのURL'); - if (url == null || url == '') return; - this.api('drive/files/upload_from_url', { - url: url, - folder_id: this.folder ? this.folder.id : undefined - }); - alert('アップロードをリクエストしました。アップロードが完了するまで時間がかかる場合があります。'); - }; - - this.changeLocalFile = () => { - Array.from(this.refs.file.files).forEach(f => this.refs.uploader.upload(f, this.folder)); - }; - </script> -</mk-drive> diff --git a/src/web/app/mobile/tags/drive/file-viewer.tag b/src/web/app/mobile/tags/drive/file-viewer.tag deleted file mode 100644 index 259873d95c..0000000000 --- a/src/web/app/mobile/tags/drive/file-viewer.tag +++ /dev/null @@ -1,282 +0,0 @@ -<mk-drive-file-viewer> - <div class="preview"> - <img if={ kind == 'image' } ref="img" - src={ file.url } - alt={ file.name } - title={ file.name } - onload={ onImageLoaded } - style="background-color:rgb({ file.properties.average_color.join(',') })"> - <virtual if={ kind != 'image' }>%fa:file%</virtual> - <footer 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">{ bytesToSize(file.datasize) }</span> - <span class="separator"></span> - <span class="created-at" onclick={ showCreatedAt }>%fa:R clock%<mk-time time={ file.created_at }/></span> - </div> - </div> - <div class="menu"> - <div> - <a href={ file.url + '?download' } download={ file.name }> - %fa:download%%i18n:mobile.tags.mk-drive-file-viewer.download% - </a> - <button onclick={ rename }> - %fa:pencil-alt%%i18n:mobile.tags.mk-drive-file-viewer.rename% - </button> - <button onclick={ move }> - %fa:R folder-open%%i18n:mobile.tags.mk-drive-file-viewer.move% - </button> - </div> - </div> - <div class="exif" 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> - <style> - :scope - display block - - > .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> - <script> - import EXIF from 'exif-js'; - import hljs from 'highlight.js'; - import bytesToSize from '../../../common/scripts/bytes-to-size'; - import gcd from '../../../common/scripts/gcd'; - - this.bytesToSize = bytesToSize; - this.gcd = gcd; - - this.mixin('api'); - - this.file = this.opts.file; - this.kind = this.file.type.split('/')[0]; - - this.onImageLoaded = () => { - const self = this; - EXIF.getData(this.refs.img, function() { - const allMetaData = EXIF.getAllTags(this); - self.update({ - exif: allMetaData - }); - hljs.highlightBlock(self.refs.exif); - }); - }; - - this.rename = () => { - const name = window.prompt('名前を変更', this.file.name); - if (name == null || name == '' || name == this.file.name) return; - this.api('drive/files/update', { - file_id: this.file.id, - name: name - }).then(() => { - this.parent.cf(this.file, true); - }); - }; - - this.move = () => { - const dialog = riot.mount(document.body.appendChild(document.createElement('mk-drive-folder-selector')))[0]; - dialog.one('selected', folder => { - this.api('drive/files/update', { - file_id: this.file.id, - folder_id: folder == null ? null : folder.id - }).then(() => { - this.parent.cf(this.file, true); - }); - }); - }; - - this.showCreatedAt = () => { - alert(new Date(this.file.created_at).toLocaleString()); - }; - </script> -</mk-drive-file-viewer> diff --git a/src/web/app/mobile/tags/drive/file.tag b/src/web/app/mobile/tags/drive/file.tag deleted file mode 100644 index 684df7dd08..0000000000 --- a/src/web/app/mobile/tags/drive/file.tag +++ /dev/null @@ -1,151 +0,0 @@ -<mk-drive-file data-is-selected={ isSelected }> - <a onclick={ onclick } href="/i/drive/file/{ file.id }"> - <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" 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">{ bytesToSize(file.datasize) }</p> - <p class="separator"></p> - <p class="created-at"> - %fa:R clock%<mk-time time={ file.created_at }/> - </p> - </footer> - </div> - </div> - </a> - <style> - :scope - display block - - > a - 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> - <script> - import bytesToSize from '../../../common/scripts/bytes-to-size'; - this.bytesToSize = bytesToSize; - - this.browser = this.parent; - this.file = this.opts.file; - this.thumbnail = { - 'background-color': this.file.properties.average_color ? `rgb(${this.file.properties.average_color.join(',')})` : 'transparent', - 'background-image': `url(${this.file.url}?thumbnail&size=128)` - }; - this.isSelected = this.browser.selectedFiles.some(f => f.id == this.file.id); - - this.browser.on('change-selection', selections => { - this.isSelected = selections.some(f => f.id == this.file.id); - }); - - this.onclick = ev => { - ev.preventDefault(); - this.browser.chooseFile(this.file); - return false; - }; - </script> -</mk-drive-file> diff --git a/src/web/app/mobile/tags/drive/folder.tag b/src/web/app/mobile/tags/drive/folder.tag deleted file mode 100644 index 6125e0b254..0000000000 --- a/src/web/app/mobile/tags/drive/folder.tag +++ /dev/null @@ -1,53 +0,0 @@ -<mk-drive-folder> - <a onclick={ onclick } href="/i/drive/folder/{ folder.id }"> - <div class="container"> - <p class="name">%fa:folder%{ folder.name }</p>%fa:angle-right% - </div> - </a> - <style> - :scope - display block - - > a - 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> - <script> - this.browser = this.parent; - this.folder = this.opts.folder; - - this.onclick = ev => { - ev.preventDefault(); - this.browser.cd(this.folder); - return false; - }; - </script> -</mk-drive-folder> diff --git a/src/web/app/mobile/tags/follow-button.tag b/src/web/app/mobile/tags/follow-button.tag deleted file mode 100644 index 5b710bfa9d..0000000000 --- a/src/web/app/mobile/tags/follow-button.tag +++ /dev/null @@ -1,131 +0,0 @@ -<mk-follow-button> - <button class={ wait: wait, follow: !user.is_following, unfollow: user.is_following } if={ !init } onclick={ onclick } disabled={ wait }> - <virtual if={ !wait && user.is_following }>%fa:minus%</virtual> - <virtual if={ !wait && !user.is_following }>%fa:plus%</virtual> - <virtual if={ wait }>%fa:spinner .pulse .fw%</virtual>{ user.is_following ? '%i18n:mobile.tags.mk-follow-button.unfollow%' : '%i18n:mobile.tags.mk-follow-button.follow%' } - </button> - <div class="init" if={ init }>%fa:spinner .pulse .fw%</div> - <style> - :scope - display block - - > button - > .init - 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 - - &.init - cursor wait !important - opacity 0.7 - - > [data-fa] - margin-right 4px - - </style> - <script> - import isPromise from '../../common/scripts/is-promise'; - - this.mixin('i'); - this.mixin('api'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.user = null; - this.userPromise = isPromise(this.opts.user) - ? this.opts.user - : Promise.resolve(this.opts.user); - this.init = true; - this.wait = false; - - this.on('mount', () => { - this.userPromise.then(user => { - this.update({ - init: false, - user: user - }); - this.connection.on('follow', this.onStreamFollow); - this.connection.on('unfollow', this.onStreamUnfollow); - }); - }); - - this.on('unmount', () => { - this.connection.off('follow', this.onStreamFollow); - this.connection.off('unfollow', this.onStreamUnfollow); - this.stream.dispose(this.connectionId); - }); - - this.onStreamFollow = user => { - if (user.id == this.user.id) { - this.update({ - user: user - }); - } - }; - - this.onStreamUnfollow = user => { - if (user.id == this.user.id) { - this.update({ - user: user - }); - } - }; - - this.onclick = () => { - this.wait = true; - if (this.user.is_following) { - this.api('following/delete', { - user_id: this.user.id - }).then(() => { - this.user.is_following = false; - }).catch(err => { - console.error(err); - }).then(() => { - this.wait = false; - this.update(); - }); - } else { - this.api('following/create', { - user_id: this.user.id - }).then(() => { - this.user.is_following = true; - }).catch(err => { - console.error(err); - }).then(() => { - this.wait = false; - this.update(); - }); - } - }; - </script> -</mk-follow-button> diff --git a/src/web/app/mobile/tags/home-timeline.tag b/src/web/app/mobile/tags/home-timeline.tag deleted file mode 100644 index 397d2b3980..0000000000 --- a/src/web/app/mobile/tags/home-timeline.tag +++ /dev/null @@ -1,69 +0,0 @@ -<mk-home-timeline> - <mk-init-following if={ noFollowing } /> - <mk-timeline ref="timeline" init={ init } more={ more } empty={ '%i18n:mobile.tags.mk-home-timeline.empty-timeline%' }/> - <style> - :scope - display block - - > mk-init-following - margin-bottom 8px - - </style> - <script> - this.mixin('i'); - this.mixin('api'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.noFollowing = this.I.following_count == 0; - - this.init = new Promise((res, rej) => { - this.api('posts/timeline').then(posts => { - res(posts); - this.trigger('loaded'); - }); - }); - - this.fetch = () => { - this.api('posts/timeline').then(posts => { - this.refs.timeline.setPosts(posts); - }); - }; - - this.on('mount', () => { - this.connection.on('post', this.onStreamPost); - this.connection.on('follow', this.onStreamFollow); - this.connection.on('unfollow', this.onStreamUnfollow); - }); - - this.on('unmount', () => { - this.connection.off('post', this.onStreamPost); - this.connection.off('follow', this.onStreamFollow); - this.connection.off('unfollow', this.onStreamUnfollow); - this.stream.dispose(this.connectionId); - }); - - this.more = () => { - return this.api('posts/timeline', { - until_id: this.refs.timeline.tail().id - }); - }; - - this.onStreamPost = post => { - this.update({ - isEmpty: false - }); - this.refs.timeline.addPost(post); - }; - - this.onStreamFollow = () => { - this.fetch(); - }; - - this.onStreamUnfollow = () => { - this.fetch(); - }; - </script> -</mk-home-timeline> diff --git a/src/web/app/mobile/tags/home.tag b/src/web/app/mobile/tags/home.tag deleted file mode 100644 index d92e3ae4e5..0000000000 --- a/src/web/app/mobile/tags/home.tag +++ /dev/null @@ -1,23 +0,0 @@ -<mk-home> - <mk-home-timeline ref="tl"/> - <style> - :scope - display block - - > mk-home-timeline - max-width 600px - margin 0 auto - padding 8px - - @media (min-width 500px) - padding 16px - - </style> - <script> - this.on('mount', () => { - this.refs.tl.on('loaded', () => { - this.trigger('loaded'); - }); - }); - </script> -</mk-home> diff --git a/src/web/app/mobile/tags/images.tag b/src/web/app/mobile/tags/images.tag deleted file mode 100644 index 5899364aef..0000000000 --- a/src/web/app/mobile/tags/images.tag +++ /dev/null @@ -1,82 +0,0 @@ -<mk-images> - <virtual each={ image in images }> - <mk-images-image image={ image }/> - </virtual> - <style> - :scope - display grid - grid-gap 4px - height 256px - - @media (max-width 500px) - height 192px - </style> - <script> - this.images = this.opts.images; - - this.on('mount', () => { - if (this.images.length == 1) { - this.root.style.gridTemplateRows = '1fr'; - - this.tags['mk-images-image'].root.style.gridColumn = '1 / 2'; - this.tags['mk-images-image'].root.style.gridRow = '1 / 2'; - } else if (this.images.length == 2) { - this.root.style.gridTemplateColumns = '1fr 1fr'; - this.root.style.gridTemplateRows = '1fr'; - - this.tags['mk-images-image'][0].root.style.gridColumn = '1 / 2'; - this.tags['mk-images-image'][0].root.style.gridRow = '1 / 2'; - this.tags['mk-images-image'][1].root.style.gridColumn = '2 / 3'; - this.tags['mk-images-image'][1].root.style.gridRow = '1 / 2'; - } else if (this.images.length == 3) { - this.root.style.gridTemplateColumns = '1fr 0.5fr'; - this.root.style.gridTemplateRows = '1fr 1fr'; - - this.tags['mk-images-image'][0].root.style.gridColumn = '1 / 2'; - this.tags['mk-images-image'][0].root.style.gridRow = '1 / 3'; - this.tags['mk-images-image'][1].root.style.gridColumn = '2 / 3'; - this.tags['mk-images-image'][1].root.style.gridRow = '1 / 2'; - this.tags['mk-images-image'][2].root.style.gridColumn = '2 / 3'; - this.tags['mk-images-image'][2].root.style.gridRow = '2 / 3'; - } else if (this.images.length == 4) { - this.root.style.gridTemplateColumns = '1fr 1fr'; - this.root.style.gridTemplateRows = '1fr 1fr'; - - this.tags['mk-images-image'][0].root.style.gridColumn = '1 / 2'; - this.tags['mk-images-image'][0].root.style.gridRow = '1 / 2'; - this.tags['mk-images-image'][1].root.style.gridColumn = '2 / 3'; - this.tags['mk-images-image'][1].root.style.gridRow = '1 / 2'; - this.tags['mk-images-image'][2].root.style.gridColumn = '1 / 2'; - this.tags['mk-images-image'][2].root.style.gridRow = '2 / 3'; - this.tags['mk-images-image'][3].root.style.gridColumn = '2 / 3'; - this.tags['mk-images-image'][3].root.style.gridRow = '2 / 3'; - } - }); - </script> -</mk-images> - -<mk-images-image> - <a ref="view" href={ image.url } target="_blank" style={ styles } title={ image.name }></a> - <style> - :scope - display block - overflow hidden - border-radius 4px - - > a - display block - overflow hidden - width 100% - height 100% - background-position center - background-size cover - - </style> - <script> - this.image = this.opts.image; - this.styles = { - 'background-color': this.image.properties.average_color ? `rgb(${this.image.properties.average_color.join(',')})` : 'transparent', - 'background-image': `url(${this.image.url}?thumbnail&size=512)` - }; - </script> -</mk-images-image> diff --git a/src/web/app/mobile/tags/index.ts b/src/web/app/mobile/tags/index.ts deleted file mode 100644 index 20934cdd8d..0000000000 --- a/src/web/app/mobile/tags/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -require('./ui.tag'); -require('./page/entrance.tag'); -require('./page/entrance/signin.tag'); -require('./page/entrance/signup.tag'); -require('./page/home.tag'); -require('./page/drive.tag'); -require('./page/notifications.tag'); -require('./page/user.tag'); -require('./page/user-followers.tag'); -require('./page/user-following.tag'); -require('./page/post.tag'); -require('./page/new-post.tag'); -require('./page/search.tag'); -require('./page/settings.tag'); -require('./page/settings/profile.tag'); -require('./page/settings/signin.tag'); -require('./page/settings/authorized-apps.tag'); -require('./page/settings/twitter.tag'); -require('./page/messaging.tag'); -require('./page/messaging-room.tag'); -require('./page/selectdrive.tag'); -require('./home.tag'); -require('./home-timeline.tag'); -require('./timeline.tag'); -require('./post-preview.tag'); -require('./sub-post-content.tag'); -require('./images.tag'); -require('./drive.tag'); -require('./drive-selector.tag'); -require('./drive-folder-selector.tag'); -require('./drive/file.tag'); -require('./drive/folder.tag'); -require('./drive/file-viewer.tag'); -require('./post-form.tag'); -require('./notification.tag'); -require('./notifications.tag'); -require('./notify.tag'); -require('./notification-preview.tag'); -require('./search.tag'); -require('./search-posts.tag'); -require('./post-detail.tag'); -require('./user.tag'); -require('./user-timeline.tag'); -require('./follow-button.tag'); -require('./user-preview.tag'); -require('./users-list.tag'); -require('./user-following.tag'); -require('./user-followers.tag'); -require('./init-following.tag'); -require('./user-card.tag'); diff --git a/src/web/app/mobile/tags/init-following.tag b/src/web/app/mobile/tags/init-following.tag deleted file mode 100644 index 105a1f70d3..0000000000 --- a/src/web/app/mobile/tags/init-following.tag +++ /dev/null @@ -1,130 +0,0 @@ -<mk-init-following> - <p class="title">気になるユーザーをフォロー:</p> - <div class="users" if={ !fetching && users.length > 0 }> - <virtual each={ users }> - <mk-user-card user={ this } /> - </virtual> - </div> - <p class="empty" if={ !fetching && users.length == 0 }>おすすめのユーザーは見つかりませんでした。</p> - <p class="fetching" if={ fetching }>%fa:spinner .pulse .fw%読み込んでいます<mk-ellipsis/></p> - <a class="refresh" onclick={ refresh }>もっと見る</a> - <button class="close" onclick={ close } title="閉じる">%fa:times%</button> - <style> - :scope - display block - 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> - <script> - this.mixin('api'); - - this.users = null; - this.fetching = true; - - this.limit = 6; - this.page = 0; - - this.on('mount', () => { - this.fetch(); - }); - - this.fetch = () => { - this.update({ - fetching: true, - users: null - }); - - this.api('users/recommendation', { - limit: this.limit, - offset: this.limit * this.page - }).then(users => { - this.fetching = false - this.users = users - this.update({ - fetching: false, - users: users - }); - }); - }; - - this.refresh = () => { - if (this.users.length < this.limit) { - this.page = 0; - } else { - this.page++; - } - this.fetch(); - }; - - this.close = () => { - this.unmount(); - }; - </script> -</mk-init-following> diff --git a/src/web/app/mobile/tags/notification-preview.tag b/src/web/app/mobile/tags/notification-preview.tag deleted file mode 100644 index ab923ea9d7..0000000000 --- a/src/web/app/mobile/tags/notification-preview.tag +++ /dev/null @@ -1,110 +0,0 @@ -<mk-notification-preview class={ notification.type }> - <virtual if={ notification.type == 'reaction' }> - <img class="avatar" src={ notification.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - <div class="text"> - <p><mk-reaction-icon reaction={ notification.reaction }/>{ notification.user.name }</p> - <p class="post-ref">%fa:quote-left%{ getPostSummary(notification.post) }%fa:quote-right%</p> - </div> - </virtual> - <virtual if={ notification.type == 'repost' }> - <img class="avatar" src={ notification.post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - <div class="text"> - <p>%fa:retweet%{ notification.post.user.name }</p> - <p class="post-ref">%fa:quote-left%{ getPostSummary(notification.post.repost) }%fa:quote-right%</p> - </div> - </virtual> - <virtual if={ notification.type == 'quote' }> - <img class="avatar" src={ notification.post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - <div class="text"> - <p>%fa:quote-left%{ notification.post.user.name }</p> - <p class="post-preview">{ getPostSummary(notification.post) }</p> - </div> - </virtual> - <virtual if={ notification.type == 'follow' }> - <img class="avatar" src={ notification.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - <div class="text"> - <p>%fa:user-plus%{ notification.user.name }</p> - </div> - </virtual> - <virtual if={ notification.type == 'reply' }> - <img class="avatar" src={ notification.post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - <div class="text"> - <p>%fa:reply%{ notification.post.user.name }</p> - <p class="post-preview">{ getPostSummary(notification.post) }</p> - </div> - </virtual> - <virtual if={ notification.type == 'mention' }> - <img class="avatar" src={ notification.post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - <div class="text"> - <p>%fa:at%{ notification.post.user.name }</p> - <p class="post-preview">{ getPostSummary(notification.post) }</p> - </div> - </virtual> - <virtual if={ notification.type == 'poll_vote' }> - <img class="avatar" src={ notification.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - <div class="text"> - <p>%fa:chart-pie%{ notification.user.name }</p> - <p class="post-ref">%fa:quote-left%{ getPostSummary(notification.post) }%fa:quote-right%</p> - </div> - </virtual> - <style> - :scope - display block - 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> - <script> - import getPostSummary from '../../../../common/get-post-summary.ts'; - this.getPostSummary = getPostSummary; - this.notification = this.opts.notification; - </script> -</mk-notification-preview> diff --git a/src/web/app/mobile/tags/notification.tag b/src/web/app/mobile/tags/notification.tag deleted file mode 100644 index de44caea2a..0000000000 --- a/src/web/app/mobile/tags/notification.tag +++ /dev/null @@ -1,169 +0,0 @@ -<mk-notification class={ notification.type }> - <mk-time time={ notification.created_at }/> - <virtual if={ notification.type == 'reaction' }> - <a class="avatar-anchor" href={ '/' + notification.user.username }> - <img class="avatar" src={ notification.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <div class="text"> - <p> - <mk-reaction-icon reaction={ notification.reaction }/> - <a href={ '/' + notification.user.username }>{ notification.user.name }</a> - </p> - <a class="post-ref" href={ '/' + notification.post.user.username + '/' + notification.post.id }> - %fa:quote-left%{ getPostSummary(notification.post) }%fa:quote-right% - </a> - </div> - </virtual> - <virtual if={ notification.type == 'repost' }> - <a class="avatar-anchor" href={ '/' + notification.post.user.username }> - <img class="avatar" src={ notification.post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <div class="text"> - <p> - %fa:retweet% - <a href={ '/' + notification.post.user.username }>{ notification.post.user.name }</a> - </p> - <a class="post-ref" href={ '/' + notification.post.user.username + '/' + notification.post.id }> - %fa:quote-left%{ getPostSummary(notification.post.repost) }%fa:quote-right% - </a> - </div> - </virtual> - <virtual if={ notification.type == 'quote' }> - <a class="avatar-anchor" href={ '/' + notification.post.user.username }> - <img class="avatar" src={ notification.post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <div class="text"> - <p> - %fa:quote-left% - <a href={ '/' + notification.post.user.username }>{ notification.post.user.name }</a> - </p> - <a class="post-preview" href={ '/' + notification.post.user.username + '/' + notification.post.id }>{ getPostSummary(notification.post) }</a> - </div> - </virtual> - <virtual if={ notification.type == 'follow' }> - <a class="avatar-anchor" href={ '/' + notification.user.username }> - <img class="avatar" src={ notification.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <div class="text"> - <p> - %fa:user-plus% - <a href={ '/' + notification.user.username }>{ notification.user.name }</a> - </p> - </div> - </virtual> - <virtual if={ notification.type == 'reply' }> - <a class="avatar-anchor" href={ '/' + notification.post.user.username }> - <img class="avatar" src={ notification.post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <div class="text"> - <p> - %fa:reply% - <a href={ '/' + notification.post.user.username }>{ notification.post.user.name }</a> - </p> - <a class="post-preview" href={ '/' + notification.post.user.username + '/' + notification.post.id }>{ getPostSummary(notification.post) }</a> - </div> - </virtual> - <virtual if={ notification.type == 'mention' }> - <a class="avatar-anchor" href={ '/' + notification.post.user.username }> - <img class="avatar" src={ notification.post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <div class="text"> - <p> - %fa:at% - <a href={ '/' + notification.post.user.username }>{ notification.post.user.name }</a> - </p> - <a class="post-preview" href={ '/' + notification.post.user.username + '/' + notification.post.id }>{ getPostSummary(notification.post) }</a> - </div> - </virtual> - <virtual if={ notification.type == 'poll_vote' }> - <a class="avatar-anchor" href={ '/' + notification.user.username }> - <img class="avatar" src={ notification.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <div class="text"> - <p> - %fa:chart-pie% - <a href={ '/' + notification.user.username }>{ notification.user.name }</a> - </p> - <a class="post-ref" href={ '/' + notification.post.user.username + '/' + notification.post.id }> - %fa:quote-left%{ getPostSummary(notification.post) }%fa:quote-right% - </a> - </div> - </virtual> - <style> - :scope - display block - margin 0 - padding 16px - overflow-wrap break-word - - > mk-time - display inline - position absolute - top 16px - right 12px - vertical-align top - color rgba(0, 0, 0, 0.6) - font-size 12px - - &:after - content "" - display block - clear both - - .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, &.quote - .text p i - color #77B255 - - &.follow - .text p i - color #53c7ce - - &.reply, &.mention - .text p i - color #555 - - .post-preview - color rgba(0, 0, 0, 0.7) - - </style> - <script> - import getPostSummary from '../../../../common/get-post-summary.ts'; - this.getPostSummary = getPostSummary; - this.notification = this.opts.notification; - </script> -</mk-notification> diff --git a/src/web/app/mobile/tags/notifications.tag b/src/web/app/mobile/tags/notifications.tag deleted file mode 100644 index 742cc45145..0000000000 --- a/src/web/app/mobile/tags/notifications.tag +++ /dev/null @@ -1,164 +0,0 @@ -<mk-notifications> - <div class="notifications" if={ notifications.length != 0 }> - <virtual each={ notification, i in notifications }> - <mk-notification notification={ notification }/> - <p class="date" 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> - </virtual> - </div> - <button class="more" if={ moreNotifications } onclick={ fetchMoreNotifications } disabled={ fetchingMoreNotifications }> - <virtual if={ fetchingMoreNotifications }>%fa:spinner .pulse .fw%</virtual>{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:mobile.tags.mk-notifications.more%' } - </button> - <p class="empty" if={ notifications.length == 0 && !loading }>%i18n:mobile.tags.mk-notifications.empty%</p> - <p class="loading" if={ loading }>%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <style> - :scope - display block - 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 - - > .loading - margin 0 - padding 16px - text-align center - color #aaa - - > [data-fa] - margin-right 4px - - </style> - <script> - import getPostSummary from '../../../../common/get-post-summary.ts'; - this.getPostSummary = getPostSummary; - - this.mixin('api'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.notifications = []; - this.loading = true; - - this.on('mount', () => { - const max = 10; - - this.api('i/notifications', { - limit: max + 1 - }).then(notifications => { - if (notifications.length == max + 1) { - this.moreNotifications = true; - notifications.pop(); - } - - this.update({ - loading: false, - notifications: notifications - }); - - this.trigger('fetched'); - }); - - this.connection.on('notification', this.onNotification); - }); - - this.on('unmount', () => { - this.connection.off('notification', this.onNotification); - this.stream.dispose(this.connectionId); - }); - - this.on('update', () => { - this.notifications.forEach(notification => { - const date = new Date(notification.created_at).getDate(); - const month = new Date(notification.created_at).getMonth() + 1; - notification._date = date; - notification._datetext = `${month}月 ${date}日`; - }); - }); - - this.onNotification = notification => { - // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.connection.send({ - type: 'read_notification', - id: notification.id - }); - - this.notifications.unshift(notification); - this.update(); - }; - - this.fetchMoreNotifications = () => { - this.update({ - fetchingMoreNotifications: true - }); - - const max = 30; - - this.api('i/notifications', { - limit: max + 1, - until_id: this.notifications[this.notifications.length - 1].id - }).then(notifications => { - if (notifications.length == max + 1) { - this.moreNotifications = true; - notifications.pop(); - } else { - this.moreNotifications = false; - } - this.update({ - notifications: this.notifications.concat(notifications), - fetchingMoreNotifications: false - }); - }); - }; - </script> -</mk-notifications> diff --git a/src/web/app/mobile/tags/notify.tag b/src/web/app/mobile/tags/notify.tag deleted file mode 100644 index 2dfc2dddb8..0000000000 --- a/src/web/app/mobile/tags/notify.tag +++ /dev/null @@ -1,40 +0,0 @@ -<mk-notify> - <mk-notification-preview notification={ opts.notification }/> - <style> - :scope - display block - 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> - <script> - import anime from 'animejs'; - - this.on('mount', () => { - anime({ - targets: this.root, - bottom: '0px', - duration: 500, - easing: 'easeOutQuad' - }); - - setTimeout(() => { - anime({ - targets: this.root, - bottom: '-64px', - duration: 500, - easing: 'easeOutQuad', - complete: () => this.unmount() - }); - }, 6000); - }); - </script> -</mk-notify> diff --git a/src/web/app/mobile/tags/page/drive.tag b/src/web/app/mobile/tags/page/drive.tag deleted file mode 100644 index 0033ffe653..0000000000 --- a/src/web/app/mobile/tags/page/drive.tag +++ /dev/null @@ -1,73 +0,0 @@ -<mk-drive-page> - <mk-ui ref="ui"> - <mk-drive ref="browser" folder={ parent.opts.folder } file={ parent.opts.file } is-naked={ true } top={ 48 }/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../scripts/ui-event'; - import Progress from '../../../common/scripts/loading'; - - this.on('mount', () => { - document.title = 'Misskey Drive'; - ui.trigger('title', '%fa:cloud%%i18n:mobile.tags.mk-drive-page.drive%'); - - ui.trigger('func', () => { - this.refs.ui.refs.browser.openContextMenu(); - }, '%fa:ellipsis-h%'); - - this.refs.ui.refs.browser.on('begin-fetch', () => { - Progress.start(); - }); - - this.refs.ui.refs.browser.on('fetched-mid', () => { - Progress.set(0.5); - }); - - this.refs.ui.refs.browser.on('fetched', () => { - Progress.done(); - }); - - this.refs.ui.refs.browser.on('move-root', () => { - const title = 'Misskey Drive'; - - // Rewrite URL - history.pushState(null, title, '/i/drive'); - - document.title = title; - ui.trigger('title', '%fa:cloud%%i18n:mobile.tags.mk-drive-page.drive%'); - }); - - this.refs.ui.refs.browser.on('open-folder', (folder, silent) => { - const title = folder.name + ' | Misskey Drive'; - - if (!silent) { - // Rewrite URL - history.pushState(null, title, '/i/drive/folder/' + folder.id); - } - - document.title = title; - // TODO: escape html characters in folder.name - ui.trigger('title', '%fa:R folder-open%' + folder.name); - }); - - this.refs.ui.refs.browser.on('open-file', (file, silent) => { - const title = file.name + ' | Misskey Drive'; - - if (!silent) { - // Rewrite URL - history.pushState(null, title, '/i/drive/file/' + file.id); - } - - document.title = title; - // TODO: escape html characters in file.name - ui.trigger('title', '<mk-file-type-icon class="icon"></mk-file-type-icon>' + file.name); - riot.mount('mk-file-type-icon', { - type: file.type - }); - }); - }); - </script> -</mk-drive-page> diff --git a/src/web/app/mobile/tags/page/entrance.tag b/src/web/app/mobile/tags/page/entrance.tag deleted file mode 100644 index 191874caf9..0000000000 --- a/src/web/app/mobile/tags/page/entrance.tag +++ /dev/null @@ -1,66 +0,0 @@ -<mk-entrance> - <main><img src="/assets/title.svg" alt="Misskey"/> - <mk-entrance-signin if={ mode == 'signin' }/> - <mk-entrance-signup if={ mode == 'signup' }/> - <div class="introduction" if={ mode == 'introduction' }> - <mk-introduction/> - <button onclick={ signin }>%i18n:common.ok%</button> - </div> - </main> - <footer> - <p class="c">{ _COPYRIGHT_ }</p> - </footer> - <style> - :scope - display block - height 100% - - > main - display block - - > img - display block - width 130px - height 120px - margin 0 auto - - > .introduction - max-width 300px - margin 0 auto - color #666 - - > button - display block - margin 16px auto 0 auto - - > footer - > .c - margin 0 - text-align center - line-height 64px - font-size 10px - color rgba(#000, 0.5) - - </style> - <script> - this.mode = 'signin'; - - this.signup = () => { - this.update({ - mode: 'signup' - }); - }; - - this.signin = () => { - this.update({ - mode: 'signin' - }); - }; - - this.introduction = () => { - this.update({ - mode: 'introduction' - }); - }; - </script> -</mk-entrance> diff --git a/src/web/app/mobile/tags/page/entrance/signin.tag b/src/web/app/mobile/tags/page/entrance/signin.tag deleted file mode 100644 index 6f473feb9d..0000000000 --- a/src/web/app/mobile/tags/page/entrance/signin.tag +++ /dev/null @@ -1,52 +0,0 @@ -<mk-entrance-signin> - <mk-signin/> - <a href={ _API_URL_ + '/signin/twitter' }>Twitterでサインイン</a> - <div class="divider"><span>or</span></div> - <button class="signup" onclick={ parent.signup }>%i18n:mobile.tags.mk-entrance-signin.signup%</button><a class="introduction" onclick={ parent.introduction }>%i18n:mobile.tags.mk-entrance-signin.about%</a> - <style> - :scope - display block - margin 0 auto - padding 0 8px - max-width 350px - text-align center - - > .signup - padding 16px - width 100% - font-size 1em - color #fff - background $theme-color - border-radius 3px - - > .divider - padding 16px 0 - text-align center - - &:after - content "" - display block - position absolute - top 50% - width 100% - height 1px - border-top solid 1px rgba(0, 0, 0, 0.1) - - > * - z-index 1 - padding 0 8px - color rgba(0, 0, 0, 0.5) - background #fdfdfd - - > .introduction - display inline-block - margin-top 16px - font-size 12px - color #666 - - - - - - </style> -</mk-entrance-signin> diff --git a/src/web/app/mobile/tags/page/entrance/signup.tag b/src/web/app/mobile/tags/page/entrance/signup.tag deleted file mode 100644 index 7b11bcad4d..0000000000 --- a/src/web/app/mobile/tags/page/entrance/signup.tag +++ /dev/null @@ -1,38 +0,0 @@ -<mk-entrance-signup> - <mk-signup/> - <button class="cancel" type="button" onclick={ parent.signin } title="%i18n:mobile.tags.mk-entrance-signup.cancel%">%fa:times%</button> - <style> - :scope - display block - margin 0 auto - padding 0 8px - max-width 350px - - > .cancel - 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 - box-shadow none - background transparent - transition opacity 0.1s ease - - &:hover - color #555 - - &:active - color #222 - - > [data-fa] - padding 14px - - </style> -</mk-entrance-signup> diff --git a/src/web/app/mobile/tags/page/home.tag b/src/web/app/mobile/tags/page/home.tag deleted file mode 100644 index 99cc6b29bf..0000000000 --- a/src/web/app/mobile/tags/page/home.tag +++ /dev/null @@ -1,62 +0,0 @@ -<mk-home-page> - <mk-ui ref="ui"> - <mk-home ref="home"/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../scripts/ui-event'; - import Progress from '../../../common/scripts/loading'; - import getPostSummary from '../../../../../common/get-post-summary.ts'; - import openPostForm from '../../scripts/open-post-form'; - - this.mixin('i'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.unreadCount = 0; - - this.on('mount', () => { - document.title = 'Misskey' - ui.trigger('title', '%fa:home%%i18n:mobile.tags.mk-home.home%'); - document.documentElement.style.background = '#313a42'; - - ui.trigger('func', () => { - openPostForm(); - }, '%fa:pencil-alt%'); - - Progress.start(); - - this.connection.on('post', this.onStreamPost); - document.addEventListener('visibilitychange', this.onVisibilitychange, false); - - this.refs.ui.refs.home.on('loaded', () => { - Progress.done(); - }); - }); - - this.on('unmount', () => { - this.connection.off('post', this.onStreamPost); - this.stream.dispose(this.connectionId); - document.removeEventListener('visibilitychange', this.onVisibilitychange); - }); - - this.onStreamPost = post => { - if (document.hidden && post.user_id !== this.I.id) { - this.unreadCount++; - document.title = `(${this.unreadCount}) ${getPostSummary(post)}`; - } - }; - - this.onVisibilitychange = () => { - if (!document.hidden) { - this.unreadCount = 0; - document.title = 'Misskey'; - } - }; - </script> -</mk-home-page> diff --git a/src/web/app/mobile/tags/page/messaging-room.tag b/src/web/app/mobile/tags/page/messaging-room.tag deleted file mode 100644 index 00ee265120..0000000000 --- a/src/web/app/mobile/tags/page/messaging-room.tag +++ /dev/null @@ -1,31 +0,0 @@ -<mk-messaging-room-page> - <mk-ui ref="ui"> - <mk-messaging-room if={ !parent.fetching } user={ parent.user } is-naked={ true }/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../scripts/ui-event'; - - this.mixin('api'); - - this.fetching = true; - - this.on('mount', () => { - this.api('users/show', { - username: this.opts.username - }).then(user => { - this.update({ - fetching: false, - user: user - }); - - document.title = `%i18n:mobile.tags.mk-messaging-room-page.message%: ${user.name} | Misskey`; - // TODO: ユーザー名をエスケープ - ui.trigger('title', '%fa:R comments%' + user.name); - }); - }); - </script> -</mk-messaging-room-page> diff --git a/src/web/app/mobile/tags/page/messaging.tag b/src/web/app/mobile/tags/page/messaging.tag deleted file mode 100644 index 29e98ce092..0000000000 --- a/src/web/app/mobile/tags/page/messaging.tag +++ /dev/null @@ -1,23 +0,0 @@ -<mk-messaging-page> - <mk-ui ref="ui"> - <mk-messaging ref="index"/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../scripts/ui-event'; - - this.mixin('page'); - - this.on('mount', () => { - document.title = 'Misskey | %i18n:mobile.tags.mk-messaging-page.message%'; - ui.trigger('title', '%fa:R comments%%i18n:mobile.tags.mk-messaging-page.message%'); - - this.refs.ui.refs.index.on('navigate-user', user => { - this.page('/i/messaging/' + user.username); - }); - }); - </script> -</mk-messaging-page> diff --git a/src/web/app/mobile/tags/page/new-post.tag b/src/web/app/mobile/tags/page/new-post.tag deleted file mode 100644 index 7adde3b329..0000000000 --- a/src/web/app/mobile/tags/page/new-post.tag +++ /dev/null @@ -1,7 +0,0 @@ -<mk-new-post-page> - <mk-post-form ref="form"/> - <style> - :scope - display block - </style> -</mk-new-post-page> diff --git a/src/web/app/mobile/tags/page/notifications.tag b/src/web/app/mobile/tags/page/notifications.tag deleted file mode 100644 index 1db9c5d661..0000000000 --- a/src/web/app/mobile/tags/page/notifications.tag +++ /dev/null @@ -1,39 +0,0 @@ -<mk-notifications-page> - <mk-ui ref="ui"> - <mk-notifications ref="notifications"/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../scripts/ui-event'; - import Progress from '../../../common/scripts/loading'; - - this.mixin('api'); - - this.on('mount', () => { - document.title = 'Misskey | %i18n:mobile.tags.mk-notifications-page.notifications%'; - ui.trigger('title', '%fa:R bell%%i18n:mobile.tags.mk-notifications-page.notifications%'); - document.documentElement.style.background = '#313a42'; - - ui.trigger('func', () => { - this.readAll(); - }, '%fa:check%'); - - Progress.start(); - - this.refs.ui.refs.notifications.on('fetched', () => { - Progress.done(); - }); - }); - - this.readAll = () => { - const ok = window.confirm('%i18n:mobile.tags.mk-notifications-page.read-all%'); - - if (!ok) return; - - this.api('notifications/mark_as_read_all'); - }; - </script> -</mk-notifications-page> diff --git a/src/web/app/mobile/tags/page/post.tag b/src/web/app/mobile/tags/page/post.tag deleted file mode 100644 index 5303ca8d34..0000000000 --- a/src/web/app/mobile/tags/page/post.tag +++ /dev/null @@ -1,76 +0,0 @@ -<mk-post-page> - <mk-ui ref="ui"> - <main if={ !parent.fetching }> - <a if={ parent.post.next } href={ parent.post.next }>%fa:angle-up%%i18n:mobile.tags.mk-post-page.next%</a> - <div> - <mk-post-detail ref="post" post={ parent.post }/> - </div> - <a if={ parent.post.prev } href={ parent.post.prev }>%fa:angle-down%%i18n:mobile.tags.mk-post-page.prev%</a> - </main> - </mk-ui> - <style> - :scope - display block - - 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> - <script> - import ui from '../../scripts/ui-event'; - import Progress from '../../../common/scripts/loading'; - - this.mixin('api'); - - this.fetching = true; - this.post = null; - - this.on('mount', () => { - document.title = 'Misskey'; - ui.trigger('title', '%fa:R sticky-note%%i18n:mobile.tags.mk-post-page.title%'); - document.documentElement.style.background = '#313a42'; - - Progress.start(); - - this.api('posts/show', { - post_id: this.opts.post - }).then(post => { - - this.update({ - fetching: false, - post: post - }); - - Progress.done(); - }); - }); - </script> -</mk-post-page> diff --git a/src/web/app/mobile/tags/page/search.tag b/src/web/app/mobile/tags/page/search.tag deleted file mode 100644 index 5c39d97e51..0000000000 --- a/src/web/app/mobile/tags/page/search.tag +++ /dev/null @@ -1,26 +0,0 @@ -<mk-search-page> - <mk-ui ref="ui"> - <mk-search ref="search" query={ parent.opts.query }/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../scripts/ui-event'; - import Progress from '../../../common/scripts/loading'; - - this.on('mount', () => { - document.title = `%i18n:mobile.tags.mk-search-page.search%: ${this.opts.query} | Misskey` - // TODO: クエリをHTMLエスケープ - ui.trigger('title', '%fa:search%' + this.opts.query); - document.documentElement.style.background = '#313a42'; - - Progress.start(); - - this.refs.ui.refs.search.on('loaded', () => { - Progress.done(); - }); - }); - </script> -</mk-search-page> diff --git a/src/web/app/mobile/tags/page/selectdrive.tag b/src/web/app/mobile/tags/page/selectdrive.tag deleted file mode 100644 index 1a790d806c..0000000000 --- a/src/web/app/mobile/tags/page/selectdrive.tag +++ /dev/null @@ -1,87 +0,0 @@ -<mk-selectdrive-page> - <header> - <h1>%i18n:mobile.tags.mk-selectdrive-page.select-file%<span class="count" if={ files.length > 0 }>({ files.length })</span></h1> - <button class="upload" onclick={ upload }>%fa:upload%</button> - <button if={ multiple } class="ok" onclick={ ok }>%fa:check%</button> - </header> - <mk-drive ref="browser" select-file={ true } multiple={ multiple } is-naked={ true } top={ 42 }/> - - <style> - :scope - display block - 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> - <script> - const q = (new URL(location)).searchParams; - this.multiple = q.get('multiple') == 'true' ? true : false; - - this.on('mount', () => { - document.documentElement.style.background = '#fff'; - - this.refs.browser.on('selected', file => { - this.files = [file]; - this.ok(); - }); - - this.refs.browser.on('change-selection', files => { - this.update({ - files: files - }); - }); - }); - - this.upload = () => { - this.refs.browser.selectLocalFile(); - }; - - this.close = () => { - window.close(); - }; - - this.ok = () => { - window.opener.cb(this.multiple ? this.files : this.files[0]); - window.close(); - }; - </script> -</mk-selectdrive-page> diff --git a/src/web/app/mobile/tags/page/settings.tag b/src/web/app/mobile/tags/page/settings.tag deleted file mode 100644 index 9a73b0af3c..0000000000 --- a/src/web/app/mobile/tags/page/settings.tag +++ /dev/null @@ -1,100 +0,0 @@ -<mk-settings-page> - <mk-ui ref="ui"> - <mk-settings /> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../scripts/ui-event'; - - this.on('mount', () => { - document.title = 'Misskey | %i18n:mobile.tags.mk-settings-page.settings%'; - ui.trigger('title', '%fa:cog%%i18n:mobile.tags.mk-settings-page.settings%'); - document.documentElement.style.background = '#313a42'; - }); - </script> -</mk-settings-page> - -<mk-settings> - <p><mk-raw content={ '%i18n:mobile.tags.mk-settings.signed-in-as%'.replace('{}', '<b>' + I.name + '</b>') }/></p> - <ul> - <li><a href="./settings/profile">%fa:user%%i18n:mobile.tags.mk-settings-page.profile%%fa:angle-right%</a></li> - <li><a href="./settings/authorized-apps">%fa:puzzle-piece%%i18n:mobile.tags.mk-settings-page.applications%%fa:angle-right%</a></li> - <li><a href="./settings/twitter">%fa:B twitter%%i18n:mobile.tags.mk-settings-page.twitter-integration%%fa:angle-right%</a></li> - <li><a href="./settings/signin-history">%fa:sign-in-alt%%i18n:mobile.tags.mk-settings-page.signin-history%%fa:angle-right%</a></li> - </ul> - <ul> - <li><a onclick={ signout }>%fa:power-off%%i18n:mobile.tags.mk-settings-page.signout%</a></li> - </ul> - <p><small>ver { _VERSION_ } (葵 aoi)</small></p> - <style> - :scope - display block - - > 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> - <script> - import signout from '../../../common/scripts/signout'; - this.signout = signout; - - this.mixin('i'); - </script> -</mk-settings> diff --git a/src/web/app/mobile/tags/page/settings/authorized-apps.tag b/src/web/app/mobile/tags/page/settings/authorized-apps.tag deleted file mode 100644 index 8d538eba5d..0000000000 --- a/src/web/app/mobile/tags/page/settings/authorized-apps.tag +++ /dev/null @@ -1,17 +0,0 @@ -<mk-authorized-apps-page> - <mk-ui ref="ui"> - <mk-authorized-apps/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../../scripts/ui-event'; - - this.on('mount', () => { - document.title = 'Misskey | %i18n:mobile.tags.mk-authorized-apps-page.application%'; - ui.trigger('title', '%fa:puzzle-piece%%i18n:mobile.tags.mk-authorized-apps-page.application%'); - }); - </script> -</mk-authorized-apps-page> diff --git a/src/web/app/mobile/tags/page/settings/profile.tag b/src/web/app/mobile/tags/page/settings/profile.tag deleted file mode 100644 index 8881e95190..0000000000 --- a/src/web/app/mobile/tags/page/settings/profile.tag +++ /dev/null @@ -1,247 +0,0 @@ -<mk-profile-setting-page> - <mk-ui ref="ui"> - <mk-profile-setting/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../../scripts/ui-event'; - - this.on('mount', () => { - document.title = 'Misskey | %i18n:mobile.tags.mk-profile-setting-page.title%'; - ui.trigger('title', '%fa:user%%i18n:mobile.tags.mk-profile-setting-page.title%'); - document.documentElement.style.background = '#313a42'; - }); - </script> -</mk-profile-setting-page> - -<mk-profile-setting> - <div> - <p>%fa:info-circle%%i18n:mobile.tags.mk-profile-setting.will-be-published%</p> - <div class="form"> - <div style={ I.banner_url ? 'background-image: url(' + I.banner_url + '?thumbnail&size=1024)' : '' } onclick={ clickBanner }> - <img src={ I.avatar_url + '?thumbnail&size=200' } alt="avatar" onclick={ clickAvatar }/> - </div> - <label> - <p>%i18n:mobile.tags.mk-profile-setting.name%</p> - <input ref="name" type="text" value={ I.name }/> - </label> - <label> - <p>%i18n:mobile.tags.mk-profile-setting.location%</p> - <input ref="location" type="text" value={ I.profile.location }/> - </label> - <label> - <p>%i18n:mobile.tags.mk-profile-setting.description%</p> - <textarea ref="description">{ I.description }</textarea> - </label> - <label> - <p>%i18n:mobile.tags.mk-profile-setting.birthday%</p> - <input ref="birthday" type="date" value={ I.profile.birthday }/> - </label> - <label> - <p>%i18n:mobile.tags.mk-profile-setting.avatar%</p> - <button onclick={ setAvatar } disabled={ avatarSaving }>%i18n:mobile.tags.mk-profile-setting.set-avatar%</button> - </label> - <label> - <p>%i18n:mobile.tags.mk-profile-setting.banner%</p> - <button onclick={ setBanner } disabled={ bannerSaving }>%i18n:mobile.tags.mk-profile-setting.set-banner%</button> - </label> - </div> - <button class="save" onclick={ save } disabled={ saving }>%fa:check%%i18n:mobile.tags.mk-profile-setting.save%</button> - </div> - <style> - :scope - display block - - > div - 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> - <script> - this.mixin('i'); - this.mixin('api'); - - this.setAvatar = () => { - const i = riot.mount(document.body.appendChild(document.createElement('mk-drive-selector')), { - multiple: false - })[0]; - i.one('selected', file => { - this.update({ - avatarSaving: true - }); - - this.api('i/update', { - avatar_id: file.id - }).then(() => { - this.update({ - avatarSaving: false - }); - - alert('%i18n:mobile.tags.mk-profile-setting.avatar-saved%'); - }); - }); - }; - - this.setBanner = () => { - const i = riot.mount(document.body.appendChild(document.createElement('mk-drive-selector')), { - multiple: false - })[0]; - i.one('selected', file => { - this.update({ - bannerSaving: true - }); - - this.api('i/update', { - banner_id: file.id - }).then(() => { - this.update({ - bannerSaving: false - }); - - alert('%i18n:mobile.tags.mk-profile-setting.banner-saved%'); - }); - }); - }; - - this.clickAvatar = e => { - this.setAvatar(); - return false; - }; - - this.clickBanner = e => { - this.setBanner(); - return false; - }; - - this.save = () => { - this.update({ - saving: true - }); - - this.api('i/update', { - name: this.refs.name.value, - location: this.refs.location.value || null, - description: this.refs.description.value || null, - birthday: this.refs.birthday.value || null - }).then(() => { - this.update({ - saving: false - }); - - alert('%i18n:mobile.tags.mk-profile-setting.saved%'); - }); - }; - </script> -</mk-profile-setting> diff --git a/src/web/app/mobile/tags/page/settings/signin.tag b/src/web/app/mobile/tags/page/settings/signin.tag deleted file mode 100644 index 1a9e63886e..0000000000 --- a/src/web/app/mobile/tags/page/settings/signin.tag +++ /dev/null @@ -1,17 +0,0 @@ -<mk-signin-history-page> - <mk-ui ref="ui"> - <mk-signin-history/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../../scripts/ui-event'; - - this.on('mount', () => { - document.title = 'Misskey | %i18n:mobile.tags.mk-signin-history-page.signin-history%'; - ui.trigger('title', '%fa:sign-in-alt%%i18n:mobile.tags.mk-signin-history-page.signin-history%'); - }); - </script> -</mk-signin-history-page> diff --git a/src/web/app/mobile/tags/page/settings/twitter.tag b/src/web/app/mobile/tags/page/settings/twitter.tag deleted file mode 100644 index 02661d3b6b..0000000000 --- a/src/web/app/mobile/tags/page/settings/twitter.tag +++ /dev/null @@ -1,17 +0,0 @@ -<mk-twitter-setting-page> - <mk-ui ref="ui"> - <mk-twitter-setting/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../../scripts/ui-event'; - - this.on('mount', () => { - document.title = 'Misskey | %i18n:mobile.tags.mk-twitter-setting-page.twitter-integration%'; - ui.trigger('title', '%fa:B twitter%%i18n:mobile.tags.mk-twitter-setting-page.twitter-integration%'); - }); - </script> -</mk-twitter-setting-page> diff --git a/src/web/app/mobile/tags/page/user-followers.tag b/src/web/app/mobile/tags/page/user-followers.tag deleted file mode 100644 index cffb2b58c4..0000000000 --- a/src/web/app/mobile/tags/page/user-followers.tag +++ /dev/null @@ -1,40 +0,0 @@ -<mk-user-followers-page> - <mk-ui ref="ui"> - <mk-user-followers ref="list" if={ !parent.fetching } user={ parent.user }/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../scripts/ui-event'; - import Progress from '../../../common/scripts/loading'; - - this.mixin('api'); - - this.fetching = true; - this.user = null; - - this.on('mount', () => { - Progress.start(); - - this.api('users/show', { - username: this.opts.user - }).then(user => { - this.update({ - fetching: false, - user: user - }); - - document.title = '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name) + ' | Misskey'; - // TODO: ユーザー名をエスケープ - ui.trigger('title', '<img src="' + user.avatar_url + '?thumbnail&size=64">' + '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name)); - document.documentElement.style.background = '#313a42'; - - this.refs.ui.refs.list.on('loaded', () => { - Progress.done(); - }); - }); - }); - </script> -</mk-user-followers-page> diff --git a/src/web/app/mobile/tags/page/user-following.tag b/src/web/app/mobile/tags/page/user-following.tag deleted file mode 100644 index 369cb46422..0000000000 --- a/src/web/app/mobile/tags/page/user-following.tag +++ /dev/null @@ -1,40 +0,0 @@ -<mk-user-following-page> - <mk-ui ref="ui"> - <mk-user-following ref="list" if={ !parent.fetching } user={ parent.user }/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../scripts/ui-event'; - import Progress from '../../../common/scripts/loading'; - - this.mixin('api'); - - this.fetching = true; - this.user = null; - - this.on('mount', () => { - Progress.start(); - - this.api('users/show', { - username: this.opts.user - }).then(user => { - this.update({ - fetching: false, - user: user - }); - - document.title = '%i18n:mobile.tags.mk-user-following-page.following-of%'.replace('{}', user.name) + ' | Misskey'; - // TODO: ユーザー名をエスケープ - ui.trigger('title', '<img src="' + user.avatar_url + '?thumbnail&size=64">' + '%i18n:mobile.tags.mk-user-following-page.following-of%'.replace('{}', user.name)); - document.documentElement.style.background = '#313a42'; - - this.refs.ui.refs.list.on('loaded', () => { - Progress.done(); - }); - }); - }); - </script> -</mk-user-following-page> diff --git a/src/web/app/mobile/tags/page/user.tag b/src/web/app/mobile/tags/page/user.tag deleted file mode 100644 index 78ca534eb0..0000000000 --- a/src/web/app/mobile/tags/page/user.tag +++ /dev/null @@ -1,27 +0,0 @@ -<mk-user-page> - <mk-ui ref="ui"> - <mk-user ref="user" user={ parent.user } page={ parent.opts.page }/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../scripts/ui-event'; - import Progress from '../../../common/scripts/loading'; - - this.user = this.opts.user; - - this.on('mount', () => { - document.documentElement.style.background = '#313a42'; - Progress.start(); - - this.refs.ui.refs.user.on('loaded', user => { - Progress.done(); - document.title = user.name + ' | Misskey'; - // TODO: ユーザー名をエスケープ - ui.trigger('title', '%fa:user%' + user.name); - }); - }); - </script> -</mk-user-page> diff --git a/src/web/app/mobile/tags/post-detail.tag b/src/web/app/mobile/tags/post-detail.tag deleted file mode 100644 index 1816d1bf93..0000000000 --- a/src/web/app/mobile/tags/post-detail.tag +++ /dev/null @@ -1,448 +0,0 @@ -<mk-post-detail> - <button class="read-more" if={ p.reply && p.reply.reply_id && context == null } onclick={ loadContext } disabled={ loadingContext }> - <virtual if={ !contextFetching }>%fa:ellipsis-v%</virtual> - <virtual if={ contextFetching }>%fa:spinner .pulse%</virtual> - </button> - <div class="context"> - <virtual each={ post in context }> - <mk-post-detail-sub post={ post }/> - </virtual> - </div> - <div class="reply-to" if={ p.reply }> - <mk-post-detail-sub post={ p.reply }/> - </div> - <div class="repost" if={ isRepost }> - <p> - <a class="avatar-anchor" href={ '/' + post.user.username }> - <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=32' } alt="avatar"/></a> - %fa:retweet%<a class="name" href={ '/' + post.user.username }> - { post.user.name } - </a> - がRepost - </p> - </div> - <article> - <header> - <a class="avatar-anchor" href={ '/' + p.user.username }> - <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <div> - <a class="name" href={ '/' + p.user.username }>{ p.user.name }</a> - <span class="username">@{ p.user.username }</span> - </div> - </header> - <div class="body"> - <div class="text" ref="text"></div> - <div class="media" if={ p.media }> - <mk-images images={ p.media }/> - </div> - <mk-poll if={ p.poll } post={ p }/> - </div> - <a class="time" href={ '/' + p.user.username + '/' + p.id }> - <mk-time time={ p.created_at } mode="detail"/> - </a> - <footer> - <mk-reactions-viewer post={ p }/> - <button onclick={ reply } title="%i18n:mobile.tags.mk-post-detail.reply%"> - %fa:reply%<p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> - </button> - <button onclick={ repost } title="Repost"> - %fa:retweet%<p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> - </button> - <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:mobile.tags.mk-post-detail.reaction%"> - %fa:plus%<p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> - </button> - <button onclick={ menu } ref="menuButton"> - %fa:ellipsis-h% - </button> - </footer> - </article> - <div class="replies" if={ !compact }> - <virtual each={ post in replies }> - <mk-post-detail-sub post={ post }/> - </virtual> - </div> - <style> - :scope - display block - 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 - - > .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 - 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 - - > .text - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - font-size 16px - color #717171 - - @media (min-width 500px) - font-size 24px - - > mk-url-preview - margin-top 8px - - > .media - > img - display block - max-width 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> - <script> - import compile from '../../common/scripts/text-compiler'; - import getPostSummary from '../../../../common/get-post-summary.ts'; - import openPostForm from '../scripts/open-post-form'; - - this.mixin('api'); - - this.compact = this.opts.compact; - this.post = this.opts.post; - this.isRepost = this.post.repost != null; - this.p = this.isRepost ? this.post.repost : this.post; - this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0; - this.summary = getPostSummary(this.p); - - this.loadingContext = false; - this.context = null; - - this.on('mount', () => { - if (this.p.text) { - const tokens = this.p.ast; - - this.refs.text.innerHTML = compile(tokens); - - Array.from(this.refs.text.children).forEach(e => { - if (e.tagName == 'MK-URL') riot.mount(e); - }); - - // URLをプレビュー - tokens - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => { - riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), { - url: t.url - }); - }); - } - - // Get replies - if (!this.compact) { - this.api('posts/replies', { - post_id: this.p.id, - limit: 8 - }).then(replies => { - this.update({ - replies: replies - }); - }); - } - }); - - this.reply = () => { - openPostForm({ - reply: this.p - }); - }; - - this.repost = () => { - const text = window.prompt(`「${this.summary}」をRepost`); - if (text == null) return; - this.api('posts/create', { - repost_id: this.p.id, - text: text == '' ? undefined : text - }); - }; - - this.react = () => { - riot.mount(document.body.appendChild(document.createElement('mk-reaction-picker')), { - source: this.refs.reactButton, - post: this.p, - compact: true - }); - }; - - this.menu = () => { - riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), { - source: this.refs.menuButton, - post: this.p, - compact: true - }); - }; - - this.loadContext = () => { - this.contextFetching = true; - - // Fetch context - this.api('posts/context', { - post_id: this.p.reply_id - }).then(context => { - this.update({ - contextFetching: false, - context: context.reverse() - }); - }); - }; - </script> -</mk-post-detail> - -<mk-post-detail-sub> - <article> - <a class="avatar-anchor" href={ '/' + post.user.username }> - <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <div class="main"> - <header> - <a class="name" href={ '/' + post.user.username }>{ post.user.name }</a> - <span class="username">@{ post.user.username }</span> - <a class="time" href={ '/' + post.user.username + '/' + post.id }> - <mk-time time={ post.created_at }/> - </a> - </header> - <div class="body"> - <mk-sub-post-content class="text" post={ post }/> - </div> - </div> - </article> - <style> - :scope - display block - margin 0 - padding 8px - font-size 0.9em - background #fdfdfd - - @media (min-width 500px) - padding 12px - - > article - &: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> - <script>this.post = this.opts.post</script> -</mk-post-detail-sub> diff --git a/src/web/app/mobile/tags/post-form.tag b/src/web/app/mobile/tags/post-form.tag deleted file mode 100644 index 05466a6ec2..0000000000 --- a/src/web/app/mobile/tags/post-form.tag +++ /dev/null @@ -1,275 +0,0 @@ -<mk-post-form> - <header> - <button class="cancel" onclick={ cancel }>%fa:times%</button> - <div> - <span if={ refs.text } class="text-count { over: refs.text.value.length > 1000 }">{ 1000 - refs.text.value.length }</span> - <button class="submit" onclick={ post }>%i18n:mobile.tags.mk-post-form.submit%</button> - </div> - </header> - <div class="form"> - <mk-post-preview if={ opts.reply } post={ opts.reply }/> - <textarea ref="text" disabled={ wait } oninput={ update } onkeydown={ onkeydown } onpaste={ onpaste } placeholder={ opts.reply ? '%i18n:mobile.tags.mk-post-form.reply-placeholder%' : '%i18n:mobile.tags.mk-post-form.post-placeholder%' }></textarea> - <div class="attaches" show={ files.length != 0 }> - <ul class="files" ref="attaches"> - <li class="file" each={ files } data-id={ id }> - <div class="img" style="background-image: url({ url + '?thumbnail&size=128' })" onclick={ removeFile }></div> - </li> - </ul> - </div> - <mk-poll-editor if={ poll } ref="poll" ondestroy={ onPollDestroyed }/> - <mk-uploader ref="uploader"/> - <button ref="upload" onclick={ selectFile }>%fa:upload%</button> - <button ref="drive" onclick={ selectFileFromDrive }>%fa:cloud%</button> - <button class="kao" onclick={ kao }>%fa:R smile%</button> - <button class="poll" onclick={ addPoll }>%fa:chart-pie%</button> - <input ref="file" type="file" accept="image/*" multiple="multiple" onchange={ changeFile }/> - </div> - <style> - :scope - display block - 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 - width 50px - line-height 50px - font-size 24px - color #555 - - > div - position absolute - top 0 - right 0 - - > .text-count - line-height 50px - color #657786 - - > .submit - margin 8px - padding 0 16px - line-height 34px - 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 - - > [ref='file'] - display none - - > [ref='text'] - 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 - - > [ref='upload'] - > [ref='drive'] - .kao - .poll - 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> - <script> - import Sortable from 'sortablejs'; - import getKao from '../../common/scripts/get-kao'; - - this.mixin('api'); - - this.wait = false; - this.uploadings = []; - this.files = []; - this.poll = false; - - this.on('mount', () => { - this.refs.uploader.on('uploaded', file => { - this.addFile(file); - }); - - this.refs.uploader.on('change-uploads', uploads => { - this.trigger('change-uploading-files', uploads); - }); - - this.refs.text.focus(); - - new Sortable(this.refs.attaches, { - animation: 150 - }); - }); - - 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()); - } - }); - }; - - this.selectFile = () => { - this.refs.file.click(); - }; - - this.selectFileFromDrive = () => { - const i = riot.mount(document.body.appendChild(document.createElement('mk-drive-selector')), { - multiple: true - })[0]; - i.one('selected', files => { - files.forEach(this.addFile); - }); - }; - - this.changeFile = () => { - Array.from(this.refs.file.files).forEach(this.upload); - }; - - this.upload = file => { - this.refs.uploader.upload(file); - }; - - this.addFile = file => { - file._remove = () => { - this.files = this.files.filter(x => x.id != file.id); - this.trigger('change-files', this.files); - this.update(); - }; - - this.files.push(file); - this.trigger('change-files', this.files); - this.update(); - }; - - this.removeFile = e => { - const file = e.item; - this.files = this.files.filter(x => x.id != file.id); - this.trigger('change-files', this.files); - this.update(); - }; - - this.addPoll = () => { - this.poll = true; - }; - - this.onPollDestroyed = () => { - this.update({ - poll: false - }); - }; - - this.post = () => { - this.update({ - wait: true - }); - - const files = []; - - if (this.files.length > 0) { - Array.from(this.refs.attaches.children).forEach(el => { - const id = el.getAttribute('data-id'); - const file = this.files.find(f => f.id == id); - files.push(file); - }); - } - - this.api('posts/create', { - text: this.refs.text.value == '' ? undefined : this.refs.text.value, - media_ids: this.files.length > 0 ? files.map(f => f.id) : undefined, - reply_id: opts.reply ? opts.reply.id : undefined, - poll: this.poll ? this.refs.poll.get() : undefined - }).then(data => { - this.trigger('post'); - this.unmount(); - }).catch(err => { - this.update({ - wait: false - }); - }); - }; - - this.cancel = () => { - this.trigger('cancel'); - this.unmount(); - }; - - this.kao = () => { - this.refs.text.value += getKao(); - }; - </script> -</mk-post-form> diff --git a/src/web/app/mobile/tags/post-preview.tag b/src/web/app/mobile/tags/post-preview.tag deleted file mode 100644 index aaf8467039..0000000000 --- a/src/web/app/mobile/tags/post-preview.tag +++ /dev/null @@ -1,94 +0,0 @@ -<mk-post-preview> - <article> - <a class="avatar-anchor" href={ '/' + post.user.username }> - <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <div class="main"> - <header> - <a class="name" href={ '/' + post.user.username }>{ post.user.name }</a> - <span class="username">@{ post.user.username }</span> - <a class="time" href={ '/' + post.user.username + '/' + post.id }> - <mk-time time={ post.created_at }/> - </a> - </header> - <div class="body"> - <mk-sub-post-content class="text" post={ post }/> - </div> - </div> - </article> - <style> - :scope - display block - margin 0 - padding 0 - font-size 0.9em - background #fff - - > article - &: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> - <script>this.post = this.opts.post</script> -</mk-post-preview> diff --git a/src/web/app/mobile/tags/search-posts.tag b/src/web/app/mobile/tags/search-posts.tag deleted file mode 100644 index 3e3c034f21..0000000000 --- a/src/web/app/mobile/tags/search-posts.tag +++ /dev/null @@ -1,42 +0,0 @@ -<mk-search-posts> - <mk-timeline init={ init } more={ more } empty={ '%i18n:mobile.tags.mk-search-posts.empty%'.replace('{}', query) }/> - <style> - :scope - display block - 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> - <script> - import parse from '../../common/scripts/parse-search-query'; - - this.mixin('api'); - - this.limit = 30; - this.offset = 0; - - this.query = this.opts.query; - - this.init = new Promise((res, rej) => { - this.api('posts/search', parse(this.query)).then(posts => { - res(posts); - this.trigger('loaded'); - }); - }); - - this.more = () => { - this.offset += this.limit; - return this.api('posts/search', Object.assign({}, parse(this.query), { - limit: this.limit, - offset: this.offset - })); - }; - </script> -</mk-search-posts> diff --git a/src/web/app/mobile/tags/search.tag b/src/web/app/mobile/tags/search.tag deleted file mode 100644 index 2d299e0a77..0000000000 --- a/src/web/app/mobile/tags/search.tag +++ /dev/null @@ -1,16 +0,0 @@ -<mk-search> - <mk-search-posts ref="posts" query={ query }/> - <style> - :scope - display block - </style> - <script> - this.query = this.opts.query; - - this.on('mount', () => { - this.refs.posts.on('loaded', () => { - this.trigger('loaded'); - }); - }); - </script> -</mk-search> diff --git a/src/web/app/mobile/tags/sub-post-content.tag b/src/web/app/mobile/tags/sub-post-content.tag deleted file mode 100644 index adeb84dea0..0000000000 --- a/src/web/app/mobile/tags/sub-post-content.tag +++ /dev/null @@ -1,46 +0,0 @@ -<mk-sub-post-content> - <div class="body"><a class="reply" if={ post.reply_id }>%fa:reply%</a><span ref="text"></span><a class="quote" if={ post.repost_id } href={ '/post:' + post.repost_id }>RP: ...</a></div> - <details if={ post.media }> - <summary>({ post.media.length }個のメディア)</summary> - <mk-images images={ post.media }/> - </details> - <details if={ post.poll }> - <summary>%i18n:mobile.tags.mk-sub-post-content.poll%</summary> - <mk-poll post={ post }/> - </details> - <style> - :scope - display block - overflow-wrap break-word - - > .body - > .reply - margin-right 6px - color #717171 - - > .quote - margin-left 4px - font-style oblique - color #a0bf46 - - mk-poll - font-size 80% - - </style> - <script> - import compile from '../../common/scripts/text-compiler'; - - this.post = this.opts.post; - - this.on('mount', () => { - if (this.post.text) { - const tokens = this.post.ast; - this.refs.text.innerHTML = compile(tokens, false); - - Array.from(this.refs.text.children).forEach(e => { - if (e.tagName == 'MK-URL') riot.mount(e); - }); - } - }); - </script> -</mk-sub-post-content> diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag deleted file mode 100644 index 9e85f97da3..0000000000 --- a/src/web/app/mobile/tags/timeline.tag +++ /dev/null @@ -1,688 +0,0 @@ -<mk-timeline> - <div class="init" if={ init }> - %fa:spinner .pulse%%i18n:common.loading% - </div> - <div class="empty" if={ !init && posts.length == 0 }> - %fa:R comments%{ opts.empty || '%i18n:mobile.tags.mk-timeline.empty%' } - </div> - <virtual each={ post, i in posts }> - <mk-timeline-post post={ post }/> - <p class="date" 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> - </virtual> - <footer if={ !init }> - <button if={ canFetchMore } onclick={ more } disabled={ fetching }> - <span if={ !fetching }>%i18n:mobile.tags.mk-timeline.load-more%</span> - <span if={ fetching }>%i18n:common.loading%<mk-ellipsis/></span> - </button> - </footer> - <style> - :scope - display block - 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 - - > button - margin 0 - padding 16px - width 100% - color $theme-color - border-radius 0 0 8px 8px - - &:disabled - opacity 0.7 - - </style> - <script> - this.posts = []; - this.init = true; - this.fetching = false; - this.canFetchMore = true; - - this.on('mount', () => { - this.opts.init.then(posts => { - this.init = false; - this.setPosts(posts); - }); - }); - - this.on('update', () => { - this.posts.forEach(post => { - const date = new Date(post.created_at).getDate(); - const month = new Date(post.created_at).getMonth() + 1; - post._date = date; - post._datetext = `${month}月 ${date}日`; - }); - }); - - this.more = () => { - if (this.init || this.fetching || this.posts.length == 0) return; - this.update({ - fetching: true - }); - this.opts.more().then(posts => { - this.fetching = false; - this.prependPosts(posts); - }); - }; - - this.setPosts = posts => { - this.update({ - posts: posts - }); - }; - - this.prependPosts = posts => { - posts.forEach(post => { - this.posts.push(post); - this.update(); - }); - } - - this.addPost = post => { - this.posts.unshift(post); - this.update(); - }; - - this.tail = () => { - return this.posts[this.posts.length - 1]; - }; - </script> -</mk-timeline> - -<mk-timeline-post class={ repost: isRepost }> - <div class="reply-to" if={ p.reply }> - <mk-timeline-post-sub post={ p.reply }/> - </div> - <div class="repost" if={ isRepost }> - <p> - <a class="avatar-anchor" href={ '/' + post.user.username }> - <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - %fa:retweet%{'%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('{'))}<a class="name" href={ '/' + post.user.username }>{ post.user.name }</a>{'%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr('%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1)} - </p> - <mk-time time={ post.created_at }/> - </div> - <article> - <a class="avatar-anchor" href={ '/' + p.user.username }> - <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=96' } alt="avatar"/> - </a> - <div class="main"> - <header> - <a class="name" href={ '/' + p.user.username }>{ p.user.name }</a> - <span class="is-bot" if={ p.user.is_bot }>bot</span> - <span class="username">@{ p.user.username }</span> - <a class="created-at" href={ url }> - <mk-time time={ p.created_at }/> - </a> - </header> - <div class="body"> - <div class="text" ref="text"> - <p class="channel" if={ p.channel != null }><a href={ _CH_URL_ + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p> - <a class="reply" if={ p.reply }> - %fa:reply% - </a> - <p class="dummy"></p> - <a class="quote" if={ p.repost != null }>RP:</a> - </div> - <div class="media" if={ p.media }> - <mk-images images={ p.media }/> - </div> - <mk-poll if={ p.poll } post={ p } ref="pollViewer"/> - <span class="app" if={ p.app }>via <b>{ p.app.name }</b></span> - <div class="repost" if={ p.repost }>%fa:quote-right -flip-h% - <mk-post-preview class="repost" post={ p.repost }/> - </div> - </div> - <footer> - <mk-reactions-viewer post={ p } ref="reactionsViewer"/> - <button onclick={ reply }> - %fa:reply%<p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> - </button> - <button onclick={ repost } title="Repost"> - %fa:retweet%<p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> - </button> - <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton"> - %fa:plus%<p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> - </button> - <button class="menu" onclick={ menu } ref="menuButton"> - %fa:ellipsis-h% - </button> - </footer> - </div> - </article> - <style> - :scope - display block - margin 0 - padding 0 - 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 - white-space nowrap - - @media (min-width 500px) - margin-bottom 2px - - > .name - display block - margin 0 0.5em 0 0 - padding 0 - overflow hidden - color #777 - font-size 1em - font-weight 700 - text-align left - text-decoration none - text-overflow ellipsis - - &:hover - text-decoration underline - - > .is-bot - text-align left - margin 0 0.5em 0 0 - padding 1px 6px - font-size 12px - color #aaa - border solid 1px #ddd - border-radius 3px - - > .username - text-align left - margin 0 0.5em 0 0 - color #ccc - - > .created-at - margin-left auto - font-size 0.9em - color #c0c0c0 - - > .body - - > .text - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - font-size 1.1em - color #717171 - - > .dummy - display none - - mk-url-preview - margin-top 8px - - > .channel - margin 0 - - > .reply - margin-right 8px - color #717171 - - > .quote - margin-left 4px - font-style oblique - color #a0bf46 - - 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 - - > .media - > img - display block - max-width 100% - - > .app - font-size 12px - color #ccc - - > mk-poll - font-size 80% - - > .repost - margin 8px 0 - - > [data-fa]:first-child - position absolute - top -8px - left -8px - z-index 1 - color #c0dac6 - font-size 28px - background #fff - - > 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> - <script> - import compile from '../../common/scripts/text-compiler'; - import getPostSummary from '../../../../common/get-post-summary.ts'; - import openPostForm from '../scripts/open-post-form'; - - this.mixin('i'); - this.mixin('api'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.set = post => { - this.post = post; - this.isRepost = this.post.repost != null && this.post.text == null; - this.p = this.isRepost ? this.post.repost : this.post; - this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0; - this.summary = getPostSummary(this.p); - this.url = `/${this.p.user.username}/${this.p.id}`; - }; - - this.set(this.opts.post); - - this.refresh = post => { - this.set(post); - this.update(); - if (this.refs.reactionsViewer) this.refs.reactionsViewer.update({ - post - }); - if (this.refs.pollViewer) this.refs.pollViewer.init(post); - }; - - this.onStreamPostUpdated = data => { - const post = data.post; - if (post.id == this.post.id) { - this.refresh(post); - } - }; - - this.onStreamConnected = () => { - this.capture(); - }; - - this.capture = withHandler => { - if (this.SIGNIN) { - this.connection.send({ - type: 'capture', - id: this.post.id - }); - if (withHandler) this.connection.on('post-updated', this.onStreamPostUpdated); - } - }; - - this.decapture = withHandler => { - if (this.SIGNIN) { - this.connection.send({ - type: 'decapture', - id: this.post.id - }); - if (withHandler) this.connection.off('post-updated', this.onStreamPostUpdated); - } - }; - - this.on('mount', () => { - this.capture(true); - - if (this.SIGNIN) { - this.connection.on('_connected_', this.onStreamConnected); - } - - if (this.p.text) { - const tokens = this.p.ast; - - this.refs.text.innerHTML = this.refs.text.innerHTML.replace('<p class="dummy"></p>', compile(tokens)); - - Array.from(this.refs.text.children).forEach(e => { - if (e.tagName == 'MK-URL') riot.mount(e); - }); - - // URLをプレビュー - tokens - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => { - riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), { - url: t.url - }); - }); - } - }); - - this.on('unmount', () => { - this.decapture(true); - this.connection.off('_connected_', this.onStreamConnected); - this.stream.dispose(this.connectionId); - }); - - this.reply = () => { - openPostForm({ - reply: this.p - }); - }; - - this.repost = () => { - const text = window.prompt(`「${this.summary}」をRepost`); - if (text == null) return; - this.api('posts/create', { - repost_id: this.p.id, - text: text == '' ? undefined : text - }); - }; - - this.react = () => { - riot.mount(document.body.appendChild(document.createElement('mk-reaction-picker')), { - source: this.refs.reactButton, - post: this.p, - compact: true - }); - }; - - this.menu = () => { - riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), { - source: this.refs.menuButton, - post: this.p, - compact: true - }); - }; - </script> -</mk-timeline-post> - -<mk-timeline-post-sub> - <article><a class="avatar-anchor" href={ '/' + post.user.username }><img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=96' } alt="avatar"/></a> - <div class="main"> - <header><a class="name" href={ '/' + post.user.username }>{ post.user.name }</a><span class="username">@{ post.user.username }</span><a class="created-at" href={ '/' + post.user.username + '/' + post.id }> - <mk-time time={ post.created_at }/></a></header> - <div class="body"> - <mk-sub-post-content class="text" post={ post }/> - </div> - </div> - </article> - <style> - :scope - display block - margin 0 - padding 0 - font-size 0.9em - - > article - padding 16px - - &:after - content "" - display block - clear both - - &:hover - > .main > footer > button - color #888 - - > .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> - <script>this.post = this.opts.post</script> -</mk-timeline-post-sub> diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag deleted file mode 100644 index 77ad14530d..0000000000 --- a/src/web/app/mobile/tags/ui.tag +++ /dev/null @@ -1,419 +0,0 @@ -<mk-ui> - <mk-ui-header/> - <mk-ui-nav ref="nav"/> - <div class="content"> - <yield /> - </div> - <mk-stream-indicator if={ SIGNIN }/> - <style> - :scope - display block - padding-top 48px - </style> - <script> - this.mixin('i'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.isDrawerOpening = false; - - this.on('mount', () => { - this.connection.on('notification', this.onStreamNotification); - }); - - this.on('unmount', () => { - this.connection.off('notification', this.onStreamNotification); - this.stream.dispose(this.connectionId); - }); - - this.toggleDrawer = () => { - this.isDrawerOpening = !this.isDrawerOpening; - this.refs.nav.root.style.display = this.isDrawerOpening ? 'block' : 'none'; - }; - - this.onStreamNotification = notification => { - // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.connection.send({ - type: 'read_notification', - id: notification.id - }); - - riot.mount(document.body.appendChild(document.createElement('mk-notify')), { - notification: notification - }); - }; - </script> -</mk-ui> - -<mk-ui-header> - <mk-special-message/> - <div class="main"> - <div class="backdrop"></div> - <div class="content"> - <button class="nav" onclick={ parent.toggleDrawer }>%fa:bars%</button> - <virtual if={ hasUnreadNotifications || hasUnreadMessagingMessages }>%fa:circle%</virtual> - <h1 ref="title">Misskey</h1> - <button if={ func } onclick={ func }><mk-raw content={ funcIcon }/></button> - </div> - </div> - <style> - :scope - $height = 48px - - display block - 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 1023 - width 100% - height $height - -webkit-backdrop-filter blur(12px) - backdrop-filter blur(12px) - background-color rgba(#1b2023, 0.75) - - > .content - z-index 1024 - - > 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] - margin-right 8px - - > 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 - 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 - width $height - text-align center - font-size 1.4em - color inherit - line-height $height - border-left solid 1px rgba(#000, 0.1) - - </style> - <script> - import ui from '../scripts/ui-event'; - - this.mixin('api'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.func = null; - this.funcIcon = null; - - this.on('mount', () => { - this.connection.on('read_all_notifications', this.onReadAllNotifications); - this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.connection.on('unread_messaging_message', this.onUnreadMessagingMessage); - - // Fetch count of unread notifications - this.api('notifications/get_unread_count').then(res => { - if (res.count > 0) { - this.update({ - hasUnreadNotifications: true - }); - } - }); - - // Fetch count of unread messaging messages - this.api('messaging/unread').then(res => { - if (res.count > 0) { - this.update({ - hasUnreadMessagingMessages: true - }); - } - }); - }); - - this.on('unmount', () => { - this.connection.off('read_all_notifications', this.onReadAllNotifications); - this.connection.off('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.connection.off('unread_messaging_message', this.onUnreadMessagingMessage); - this.stream.dispose(this.connectionId); - - ui.off('title', this.setTitle); - ui.off('func', this.setFunc); - }); - - this.onReadAllNotifications = () => { - this.update({ - hasUnreadNotifications: false - }); - }; - - this.onReadAllMessagingMessages = () => { - this.update({ - hasUnreadMessagingMessages: false - }); - }; - - this.onUnreadMessagingMessage = () => { - this.update({ - hasUnreadMessagingMessages: true - }); - }; - - this.setTitle = title => { - this.refs.title.innerHTML = title; - }; - - this.setFunc = (fn, icon) => { - this.update({ - func: fn, - funcIcon: icon - }); - }; - - ui.on('title', this.setTitle); - ui.on('func', this.setFunc); - </script> -</mk-ui-header> - -<mk-ui-nav> - <div class="backdrop" onclick={ parent.toggleDrawer }></div> - <div class="body"> - <a class="me" if={ SIGNIN } href={ '/' + I.username }> - <img class="avatar" src={ I.avatar_url + '?thumbnail&size=128' } alt="avatar"/> - <p class="name">{ I.name }</p> - </a> - <div class="links"> - <ul> - <li><a href="/">%fa:home%%i18n:mobile.tags.mk-ui-nav.home%%fa:angle-right%</a></li> - <li><a href="/i/notifications">%fa:R bell%%i18n:mobile.tags.mk-ui-nav.notifications%<virtual if={ hasUnreadNotifications }>%fa:circle%</virtual>%fa:angle-right%</a></li> - <li><a href="/i/messaging">%fa:R comments%%i18n:mobile.tags.mk-ui-nav.messaging%<virtual if={ hasUnreadMessagingMessages }>%fa:circle%</virtual>%fa:angle-right%</a></li> - </ul> - <ul> - <li><a href={ _CH_URL_ } target="_blank">%fa:tv%%i18n:mobile.tags.mk-ui-nav.ch%%fa:angle-right%</a></li> - <li><a href="/i/drive">%fa:cloud%%i18n:mobile.tags.mk-ui-nav.drive%%fa:angle-right%</a></li> - </ul> - <ul> - <li><a onclick={ search }>%fa:search%%i18n:mobile.tags.mk-ui-nav.search%%fa:angle-right%</a></li> - </ul> - <ul> - <li><a href="/i/settings">%fa:cog%%i18n:mobile.tags.mk-ui-nav.settings%%fa:angle-right%</a></li> - </ul> - </div> - <a href={ aboutUrl }><p class="about">%i18n:mobile.tags.mk-ui-nav.about%</p></a> - </div> - <style> - :scope - display none - - .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 - - </style> - <script> - this.mixin('i'); - this.mixin('page'); - this.mixin('api'); - - this.mixin('stream'); - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.aboutUrl = `${_DOCS_URL_}/${_LANG_}/about`; - - this.on('mount', () => { - this.connection.on('read_all_notifications', this.onReadAllNotifications); - this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.connection.on('unread_messaging_message', this.onUnreadMessagingMessage); - - // Fetch count of unread notifications - this.api('notifications/get_unread_count').then(res => { - if (res.count > 0) { - this.update({ - hasUnreadNotifications: true - }); - } - }); - - // Fetch count of unread messaging messages - this.api('messaging/unread').then(res => { - if (res.count > 0) { - this.update({ - hasUnreadMessagingMessages: true - }); - } - }); - }); - - this.on('unmount', () => { - this.connection.off('read_all_notifications', this.onReadAllNotifications); - this.connection.off('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.connection.off('unread_messaging_message', this.onUnreadMessagingMessage); - this.stream.dispose(this.connectionId); - }); - - this.onReadAllNotifications = () => { - this.update({ - hasUnreadNotifications: false - }); - }; - - this.onReadAllMessagingMessages = () => { - this.update({ - hasUnreadMessagingMessages: false - }); - }; - - this.onUnreadMessagingMessage = () => { - this.update({ - hasUnreadMessagingMessages: true - }); - }; - - this.search = () => { - const query = window.prompt('%i18n:mobile.tags.mk-ui-nav.search%'); - if (query == null || query == '') return; - this.page('/search?q=' + encodeURIComponent(query)); - }; - </script> -</mk-ui-nav> diff --git a/src/web/app/mobile/tags/user-card.tag b/src/web/app/mobile/tags/user-card.tag deleted file mode 100644 index d0c79698c5..0000000000 --- a/src/web/app/mobile/tags/user-card.tag +++ /dev/null @@ -1,55 +0,0 @@ -<mk-user-card> - <header style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' }> - <a href={ '/' + user.username }> - <img src={ user.avatar_url + '?thumbnail&size=200' } alt="avatar"/> - </a> - </header> - <a class="name" href={ '/' + user.username } target="_blank">{ user.name }</a> - <p class="username">@{ user.username }</p> - <mk-follow-button user={ user }/> - <style> - :scope - 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> - <script> - this.user = this.opts.user; - </script> -</mk-user-card> diff --git a/src/web/app/mobile/tags/user-followers.tag b/src/web/app/mobile/tags/user-followers.tag deleted file mode 100644 index b710e376c6..0000000000 --- a/src/web/app/mobile/tags/user-followers.tag +++ /dev/null @@ -1,28 +0,0 @@ -<mk-user-followers> - <mk-users-list ref="list" fetch={ fetch } count={ user.followers_count } you-know-count={ user.followers_you_know_count } no-users={ '%i18n:mobile.tags.mk-user-followers.no-users%' }/> - <style> - :scope - display block - - </style> - <script> - this.mixin('api'); - - this.user = this.opts.user; - - this.fetch = (iknow, limit, cursor, cb) => { - this.api('users/followers', { - user_id: this.user.id, - iknow: iknow, - limit: limit, - cursor: cursor ? cursor : undefined - }).then(cb); - }; - - this.on('mount', () => { - this.refs.list.on('loaded', () => { - this.trigger('loaded'); - }); - }); - </script> -</mk-user-followers> diff --git a/src/web/app/mobile/tags/user-following.tag b/src/web/app/mobile/tags/user-following.tag deleted file mode 100644 index 62ca091812..0000000000 --- a/src/web/app/mobile/tags/user-following.tag +++ /dev/null @@ -1,28 +0,0 @@ -<mk-user-following> - <mk-users-list ref="list" fetch={ fetch } count={ user.following_count } you-know-count={ user.following_you_know_count } no-users={ '%i18n:mobile.tags.mk-user-following.no-users%' }/> - <style> - :scope - display block - - </style> - <script> - this.mixin('api'); - - this.user = this.opts.user; - - this.fetch = (iknow, limit, cursor, cb) => { - this.api('users/following', { - user_id: this.user.id, - iknow: iknow, - limit: limit, - cursor: cursor ? cursor : undefined - }).then(cb); - }; - - this.on('mount', () => { - this.refs.list.on('loaded', () => { - this.trigger('loaded'); - }); - }); - </script> -</mk-user-following> diff --git a/src/web/app/mobile/tags/user-preview.tag b/src/web/app/mobile/tags/user-preview.tag deleted file mode 100644 index 48bf88a892..0000000000 --- a/src/web/app/mobile/tags/user-preview.tag +++ /dev/null @@ -1,95 +0,0 @@ -<mk-user-preview> - <a class="avatar-anchor" href={ '/' + user.username }> - <img class="avatar" src={ user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <div class="main"> - <header> - <a class="name" href={ '/' + user.username }>{ user.name }</a> - <span class="username">@{ user.username }</span> - </header> - <div class="body"> - <div class="description">{ user.description }</div> - </div> - </div> - <style> - :scope - display block - 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> - <script>this.user = this.opts.user</script> -</mk-user-preview> diff --git a/src/web/app/mobile/tags/user-timeline.tag b/src/web/app/mobile/tags/user-timeline.tag deleted file mode 100644 index 86ead5971f..0000000000 --- a/src/web/app/mobile/tags/user-timeline.tag +++ /dev/null @@ -1,33 +0,0 @@ -<mk-user-timeline> - <mk-timeline ref="timeline" init={ init } more={ more } empty={ withMedia ? '%i18n:mobile.tags.mk-user-timeline.no-posts-with-media%' : '%i18n:mobile.tags.mk-user-timeline.no-posts%' }/> - <style> - :scope - display block - max-width 600px - margin 0 auto - </style> - <script> - this.mixin('api'); - - this.user = this.opts.user; - this.withMedia = this.opts.withMedia; - - this.init = new Promise((res, rej) => { - this.api('users/posts', { - user_id: this.user.id, - with_media: this.withMedia - }).then(posts => { - res(posts); - this.trigger('loaded'); - }); - }); - - this.more = () => { - return this.api('users/posts', { - user_id: this.user.id, - with_media: this.withMedia, - until_id: this.refs.timeline.tail().id - }); - }; - </script> -</mk-user-timeline> diff --git a/src/web/app/mobile/tags/user.tag b/src/web/app/mobile/tags/user.tag deleted file mode 100644 index b3a2f1a147..0000000000 --- a/src/web/app/mobile/tags/user.tag +++ /dev/null @@ -1,735 +0,0 @@ -<mk-user> - <div class="user" if={ !fetching }> - <header> - <div class="banner" style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' }></div> - <div class="body"> - <div class="top"> - <a class="avatar"> - <img src={ user.avatar_url + '?thumbnail&size=200' } alt="avatar"/> - </a> - <mk-follow-button if={ SIGNIN && I.id != user.id } user={ user }/> - </div> - <div class="title"> - <h1>{ user.name }</h1> - <span class="username">@{ user.username }</span> - <span class="followed" if={ user.is_followed }>%i18n:mobile.tags.mk-user.follows-you%</span> - </div> - <div class="description">{ user.description }</div> - <div class="info"> - <p class="location" if={ user.profile.location }> - %fa:map-marker%{ user.profile.location } - </p> - <p class="birthday" if={ user.profile.birthday }> - %fa:birthday-cake%{ user.profile.birthday.replace('-', '年').replace('-', '月') + '日' } ({ age(user.profile.birthday) }歳) - </p> - </div> - <div class="status"> - <a> - <b>{ user.posts_count }</b> - <i>%i18n:mobile.tags.mk-user.posts%</i> - </a> - <a href="{ user.username }/following"> - <b>{ user.following_count }</b> - <i>%i18n:mobile.tags.mk-user.following%</i> - </a> - <a href="{ user.username }/followers"> - <b>{ user.followers_count }</b> - <i>%i18n:mobile.tags.mk-user.followers%</i> - </a> - </div> - </div> - <nav> - <a data-is-active={ page == 'overview' } onclick={ go.bind(null, 'overview') }>%i18n:mobile.tags.mk-user.overview%</a> - <a data-is-active={ page == 'posts' } onclick={ go.bind(null, 'posts') }>%i18n:mobile.tags.mk-user.timeline%</a> - <a data-is-active={ page == 'media' } onclick={ go.bind(null, 'media') }>%i18n:mobile.tags.mk-user.media%</a> - </nav> - </header> - <div class="body"> - <mk-user-overview if={ page == 'overview' } user={ user }/> - <mk-user-timeline if={ page == 'posts' } user={ user }/> - <mk-user-timeline if={ page == 'media' } user={ user } with-media={ true }/> - </div> - </div> - <style> - :scope - display block - - > .user - > header - box-shadow 0 4px 4px rgba(0, 0, 0, 0.3) - - > .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 2px 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 - - > mk-activity-table - margin 12px 0 0 0 - - > nav - 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> - <script> - this.age = require('s-age'); - - this.mixin('i'); - this.mixin('api'); - - this.username = this.opts.user; - this.page = this.opts.page ? this.opts.page : 'overview'; - this.fetching = true; - - this.on('mount', () => { - this.api('users/show', { - username: this.username - }).then(user => { - this.fetching = false; - this.user = user; - this.trigger('loaded', user); - this.update(); - }); - }); - - this.go = page => { - this.update({ - page: page - }); - }; - </script> -</mk-user> - -<mk-user-overview> - <mk-post-detail if={ user.pinned_post } post={ user.pinned_post } compact={ true }/> - <section class="recent-posts"> - <h2>%fa:R comments%%i18n:mobile.tags.mk-user-overview.recent-posts%</h2> - <div> - <mk-user-overview-posts user={ user }/> - </div> - </section> - <section class="images"> - <h2>%fa:image%%i18n:mobile.tags.mk-user-overview.images%</h2> - <div> - <mk-user-overview-photos user={ user }/> - </div> - </section> - <section class="activity"> - <h2>%fa:chart-bar%%i18n:mobile.tags.mk-user-overview.activity%</h2> - <div> - <mk-user-overview-activity-chart user={ user }/> - </div> - </section> - <section class="keywords"> - <h2>%fa:R comment%%i18n:mobile.tags.mk-user-overview.keywords%</h2> - <div> - <mk-user-overview-keywords user={ user }/> - </div> - </section> - <section class="domains"> - <h2>%fa:globe%%i18n:mobile.tags.mk-user-overview.domains%</h2> - <div> - <mk-user-overview-domains user={ user }/> - </div> - </section> - <section class="frequently-replied-users"> - <h2>%fa:users%%i18n:mobile.tags.mk-user-overview.frequently-replied-users%</h2> - <div> - <mk-user-overview-frequently-replied-users user={ user }/> - </div> - </section> - <section class="followers-you-know" if={ SIGNIN && I.id !== user.id }> - <h2>%fa:users%%i18n:mobile.tags.mk-user-overview.followers-you-know%</h2> - <div> - <mk-user-overview-followers-you-know user={ user }/> - </div> - </section> - <p>%i18n:mobile.tags.mk-user-overview.last-used-at%: <b><mk-time time={ user.last_used_at }/></b></p> - <style> - :scope - display block - 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> - <script> - this.mixin('i'); - - this.user = this.opts.user; - </script> -</mk-user-overview> - -<mk-user-overview-posts> - <p class="initializing" if={ initializing }>%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-posts.loading%<mk-ellipsis/></p> - <div if={ !initializing && posts.length > 0 }> - <virtual each={ posts }> - <mk-user-overview-posts-post-card post={ this }/> - </virtual> - </div> - <p class="empty" if={ !initializing && posts.length == 0 }>%i18n:mobile.tags.mk-user-overview-posts.no-posts%</p> - <style> - :scope - display block - - > div - overflow-x scroll - -webkit-overflow-scrolling touch - white-space nowrap - padding 8px - - > * - vertical-align top - - &:not(:last-child) - margin-right 8px - - > .initializing - > .empty - margin 0 - padding 16px - text-align center - color #aaa - - > i - margin-right 4px - - </style> - <script> - this.mixin('api'); - - this.user = this.opts.user; - this.initializing = true; - - this.on('mount', () => { - this.api('users/posts', { - user_id: this.user.id - }).then(posts => { - this.update({ - posts: posts, - initializing: false - }); - }); - }); - </script> -</mk-user-overview-posts> - -<mk-user-overview-posts-post-card> - <a href={ '/' + post.user.username + '/' + post.id }> - <header> - <img src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/><h3>{ post.user.name }</h3> - </header> - <div> - { text } - </div> - <mk-time time={ post.created_at }/> - </a> - <style> - :scope - 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> - <script> - import summary from '../../../../common/get-post-summary.ts'; - - this.post = this.opts.post; - this.text = summary(this.post); - </script> -</mk-user-overview-posts-post-card> - -<mk-user-overview-photos> - <p class="initializing" if={ initializing }>%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-photos.loading%<mk-ellipsis/></p> - <div class="stream" if={ !initializing && images.length > 0 }> - <virtual each={ image in images }> - <a class="img" style={ 'background-image: url(' + image.media.url + '?thumbnail&size=256)' } href={ '/' + image.post.user.username + '/' + image.post.id }></a> - </virtual> - </div> - <p class="empty" if={ !initializing && images.length == 0 }>%i18n:mobile.tags.mk-user-overview-photos.no-photos%</p> - <style> - :scope - display block - - > .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> - <script> - this.mixin('api'); - - this.images = []; - this.initializing = true; - this.user = this.opts.user; - - this.on('mount', () => { - this.api('users/posts', { - user_id: this.user.id, - with_media: true, - limit: 6 - }).then(posts => { - this.initializing = false; - posts.forEach(post => { - post.media.forEach(media => { - if (this.images.length < 9) this.images.push({ - post, - media - }); - }); - }); - this.update(); - }); - }); - </script> -</mk-user-overview-photos> - -<mk-user-overview-activity-chart> - <svg if={ data } ref="canvas" viewBox="0 0 30 1" preserveAspectRatio="none"> - <g each={ d, i in data.reverse() }> - <rect width="0.8" riot-height={ d.postsH } - riot-x={ i + 0.1 } riot-y={ 1 - d.postsH - d.repliesH - d.repostsH } - fill="#41ddde"/> - <rect width="0.8" riot-height={ d.repliesH } - riot-x={ i + 0.1 } riot-y={ 1 - d.repliesH - d.repostsH } - fill="#f7796c"/> - <rect width="0.8" riot-height={ d.repostsH } - riot-x={ i + 0.1 } riot-y={ 1 - d.repostsH } - fill="#a1de41"/> - </g> - </svg> - <style> - :scope - display block - max-width 600px - margin 0 auto - - > svg - display block - width 100% - height 80px - - > rect - transform-origin center - - </style> - <script> - this.mixin('api'); - - this.user = this.opts.user; - - this.on('mount', () => { - this.api('aggregation/users/activity', { - user_id: this.user.id, - limit: 30 - }).then(data => { - data.forEach(d => d.total = d.posts + d.replies + d.reposts); - this.peak = Math.max.apply(null, data.map(d => d.total)); - data.forEach(d => { - d.postsH = d.posts / this.peak; - d.repliesH = d.replies / this.peak; - d.repostsH = d.reposts / this.peak; - }); - this.update({ data }); - }); - }); - </script> -</mk-user-overview-activity-chart> - -<mk-user-overview-keywords> - <div if={ user.keywords != null && user.keywords.length > 1 }> - <virtual each={ keyword in user.keywords }> - <a>{ keyword }</a> - </virtual> - </div> - <p class="empty" if={ user.keywords == null || user.keywords.length == 0 }>%i18n:mobile.tags.mk-user-overview-keywords.no-keywords%</p> - <style> - :scope - display block - - > div - padding 4px - - > a - display inline-block - margin 4px - color #555 - - > .empty - margin 0 - padding 16px - text-align center - color #aaa - - > i - margin-right 4px - - </style> - <script> - this.user = this.opts.user; - </script> -</mk-user-overview-keywords> - -<mk-user-overview-domains> - <div if={ user.domains != null && user.domains.length > 1 }> - <virtual each={ domain in user.domains }> - <a style="opacity: { 0.5 + (domain.weight / 2) }">{ domain.domain }</a> - </virtual> - </div> - <p class="empty" if={ user.domains == null || user.domains.length == 0 }>%i18n:mobile.tags.mk-user-overview-domains.no-domains%</p> - <style> - :scope - display block - - > div - padding 4px - - > a - display inline-block - margin 4px - color #555 - - > .empty - margin 0 - padding 16px - text-align center - color #aaa - - > i - margin-right 4px - - </style> - <script> - this.user = this.opts.user; - </script> -</mk-user-overview-domains> - -<mk-user-overview-frequently-replied-users> - <p class="initializing" if={ initializing }>%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-frequently-replied-users.loading%<mk-ellipsis/></p> - <div if={ !initializing && users.length > 0 }> - <virtual each={ users }> - <mk-user-card user={ this.user }/> - </virtual> - </div> - <p class="empty" if={ !initializing && users.length == 0 }>%i18n:mobile.tags.mk-user-overview-frequently-replied-users.no-users%</p> - <style> - :scope - display block - - > div - overflow-x scroll - -webkit-overflow-scrolling touch - white-space nowrap - padding 8px - - > mk-user-card - &:not(:last-child) - margin-right 8px - - > .initializing - > .empty - margin 0 - padding 16px - text-align center - color #aaa - - > i - margin-right 4px - - </style> - <script> - this.mixin('api'); - - this.user = this.opts.user; - this.initializing = true; - - this.on('mount', () => { - this.api('users/get_frequently_replied_users', { - user_id: this.user.id - }).then(x => { - this.update({ - users: x, - initializing: false - }); - }); - }); - </script> -</mk-user-overview-frequently-replied-users> - -<mk-user-overview-followers-you-know> - <p class="initializing" if={ initializing }>%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-followers-you-know.loading%<mk-ellipsis/></p> - <div if={ !initializing && users.length > 0 }> - <virtual each={ user in users }> - <a href={ '/' + user.username }><img src={ user.avatar_url + '?thumbnail&size=64' } alt={ user.name }/></a> - </virtual> - </div> - <p class="empty" if={ !initializing && users.length == 0 }>%i18n:mobile.tags.mk-user-overview-followers-you-know.no-users%</p> - <style> - :scope - display block - - > 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> - <script> - this.mixin('api'); - - this.user = this.opts.user; - this.initializing = true; - - this.on('mount', () => { - this.api('users/followers', { - user_id: this.user.id, - iknow: true, - limit: 30 - }).then(x => { - this.update({ - users: x.users, - initializing: false - }); - }); - }); - </script> -</mk-user-overview-followers-you-know> diff --git a/src/web/app/mobile/tags/users-list.tag b/src/web/app/mobile/tags/users-list.tag deleted file mode 100644 index 1dec33dddc..0000000000 --- a/src/web/app/mobile/tags/users-list.tag +++ /dev/null @@ -1,127 +0,0 @@ -<mk-users-list> - <nav> - <span data-is-active={ mode == 'all' } onclick={ setMode.bind(this, 'all') }>%i18n:mobile.tags.mk-users-list.all%<span>{ opts.count }</span></span> - <span if={ SIGNIN && opts.youKnowCount } data-is-active={ mode == 'iknow' } onclick={ setMode.bind(this, 'iknow') }>%i18n:mobile.tags.mk-users-list.known%<span>{ opts.youKnowCount }</span></span> - </nav> - <div class="users" if={ !fetching && users.length != 0 }> - <mk-user-preview each={ users } user={ this }/> - </div> - <button class="more" if={ !fetching && next != null } onclick={ more } disabled={ moreFetching }> - <span if={ !moreFetching }>%i18n:mobile.tags.mk-users-list.load-more%</span> - <span if={ moreFetching }>%i18n:common.loading%<mk-ellipsis/></span></button> - <p class="no" if={ !fetching && users.length == 0 }>{ opts.noUsers }</p> - <p class="fetching" if={ fetching }>%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <style> - :scope - display block - - > 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> - <script> - this.mixin('i'); - - this.limit = 30; - this.mode = 'all'; - - this.fetching = true; - this.moreFetching = false; - - this.on('mount', () => { - this.fetch(() => this.trigger('loaded')); - }); - - this.fetch = cb => { - this.update({ - fetching: true - }); - this.opts.fetch(this.mode == 'iknow', this.limit, null, obj => { - this.update({ - fetching: false, - users: obj.users, - next: obj.next - }); - if (cb) cb(); - }); - }; - - this.more = () => { - this.update({ - moreFetching: true - }); - this.opts.fetch(this.mode == 'iknow', this.limit, this.next, obj => { - this.update({ - moreFetching: false, - users: this.users.concat(obj.users), - next: obj.next - }); - }); - }; - - this.setMode = mode => { - this.update({ - mode: mode - }); - this.fetch(); - }; - </script> -</mk-users-list> diff --git a/src/web/app/mobile/views/components/drive-file-chooser.vue b/src/web/app/mobile/views/components/drive-file-chooser.vue new file mode 100644 index 0000000000..6806af0f1e --- /dev/null +++ b/src/web/app/mobile/views/components/drive-file-chooser.vue @@ -0,0 +1,98 @@ +<template> +<div class="mk-drive-file-chooser"> + <div class="body"> + <header> + <h1>%i18n:mobile.tags.mk-drive-selector.select-file%<span class="count" v-if="files.length > 0">({{ files.length }})</span></h1> + <button class="close" @click="cancel">%fa:times%</button> + <button v-if="multiple" class="ok" @click="ok">%fa:check%</button> + </header> + <mk-drive ref="browser" + :select-file="true" + :multiple="multiple" + @change-selection="onChangeSelection" + @selected="onSelected" + /> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['multiple'], + data() { + return { + files: [] + }; + }, + methods: { + onChangeSelection(files) { + this.files = files; + }, + onSelected(file) { + this.$emit('selected', file); + this.$destroy(); + }, + cancel() { + this.$emit('canceled'); + this.$destroy(); + }, + ok() { + this.$emit('selected', this.files); + this.$destroy(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-drive-file-chooser + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + padding 8px + background rgba(0, 0, 0, 0.2) + + > .body + width 100% + height 100% + background #fff + + > header + border-bottom solid 1px #eee + + > h1 + margin 0 + padding 0 + text-align center + line-height 42px + font-size 1em + font-weight normal + + > .count + margin-left 4px + opacity 0.5 + + > .close + position absolute + top 0 + left 0 + line-height 42px + width 42px + + > .ok + position absolute + top 0 + right 0 + line-height 42px + width 42px + + > .mk-drive + height calc(100% - 42px) + overflow scroll + -webkit-overflow-scrolling touch + +</style> diff --git a/src/web/app/mobile/views/components/drive-folder-chooser.vue b/src/web/app/mobile/views/components/drive-folder-chooser.vue new file mode 100644 index 0000000000..853078664f --- /dev/null +++ b/src/web/app/mobile/views/components/drive-folder-chooser.vue @@ -0,0 +1,78 @@ +<template> +<div class="mk-drive-folder-chooser"> + <div class="body"> + <header> + <h1>%i18n:mobile.tags.mk-drive-folder-selector.select-folder%</h1> + <button class="close" @click="cancel">%fa:times%</button> + <button class="ok" @click="ok">%fa:check%</button> + </header> + <mk-drive ref="browser" + select-folder + /> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + methods: { + cancel() { + this.$emit('canceled'); + this.$destroy(); + }, + ok() { + this.$emit('selected', (this.$refs.browser as any).folder); + this.$destroy(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-drive-folder-chooser + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + padding 8px + background rgba(0, 0, 0, 0.2) + + > .body + width 100% + height 100% + background #fff + + > header + border-bottom solid 1px #eee + + > h1 + margin 0 + padding 0 + text-align center + line-height 42px + font-size 1em + font-weight normal + + > .close + position absolute + top 0 + left 0 + line-height 42px + width 42px + + > .ok + position absolute + top 0 + right 0 + line-height 42px + width 42px + + > .mk-drive + height calc(100% - 42px) + overflow scroll + -webkit-overflow-scrolling touch + +</style> diff --git a/src/web/app/mobile/views/components/drive.file-detail.vue b/src/web/app/mobile/views/components/drive.file-detail.vue new file mode 100644 index 0000000000..9a47eeb12c --- /dev/null +++ b/src/web/app/mobile/views/components/drive.file-detail.vue @@ -0,0 +1,290 @@ +<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="`background-color:rgb(${ file.properties.average_color.join(',') })`"> + <template v-if="kind != 'image'">%fa:file%</template> + <footer v-if="kind == 'image' && file.properties && file.properties.width && file.properties.height"> + <span class="size"> + <span class="width">{{ file.properties.width }}</span> + <span class="time">×</span> + <span class="height">{{ file.properties.height }}</span> + <span class="px">px</span> + </span> + <span class="separator"></span> + <span class="aspect-ratio"> + <span class="width">{{ file.properties.width / gcd(file.properties.width, file.properties.height) }}</span> + <span class="colon">:</span> + <span class="height">{{ file.properties.height / gcd(file.properties.width, file.properties.height) }}</span> + </span> + </footer> + </div> + <div class="info"> + <div> + <span class="type"><mk-file-type-icon :type="file.type"/>{{ file.type }}</span> + <span class="separator"></span> + <span class="data-size">{{ file.datasize | bytes }}</span> + <span class="separator"></span> + <span class="created-at" @click="showCreatedAt">%fa:R clock%<mk-time :time="file.created_at"/></span> + </div> + </div> + <div class="menu"> + <div> + <a :href="`${file.url}?download`" :download="file.name"> + %fa:download%%i18n:mobile.tags.mk-drive-file-viewer.download% + </a> + <button @click="rename"> + %fa:pencil-alt%%i18n:mobile.tags.mk-drive-file-viewer.rename% + </button> + <button @click="move"> + %fa:R folder-open%%i18n:mobile.tags.mk-drive-file-viewer.move% + </button> + </div> + </div> + <div class="exif" v-show="exif"> + <div> + <p> + %fa:camera%%i18n:mobile.tags.mk-drive-file-viewer.exif% + </p> + <pre ref="exif" class="json">{{ exif ? JSON.stringify(exif, null, 2) : '' }}</pre> + </div> + </div> + <div class="hash"> + <div> + <p> + %fa:hashtag%%i18n:mobile.tags.mk-drive-file-viewer.hash% + </p> + <code>{{ file.md5 }}</code> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as EXIF from 'exif-js'; +import * as hljs from 'highlight.js'; +import gcd from '../../../common/scripts/gcd'; + +export default Vue.extend({ + props: ['file'], + data() { + return { + gcd, + exif: null + }; + }, + computed: { + browser(): any { + return this.$parent; + }, + kind(): string { + return this.file.type.split('/')[0]; + } + }, + methods: { + rename() { + const name = window.prompt('名前を変更', this.file.name); + if (name == null || name == '' || name == this.file.name) return; + (this as any).api('drive/files/update', { + file_id: this.file.id, + name: name + }).then(() => { + this.browser.cf(this.file, true); + }); + }, + move() { + (this as any).apis.chooseDriveFolder().then(folder => { + (this as any).api('drive/files/update', { + file_id: this.file.id, + folder_id: folder == null ? null : folder.id + }).then(() => { + this.browser.cf(this.file, true); + }); + }); + }, + showCreatedAt() { + alert(new Date(this.file.created_at).toLocaleString()); + }, + onImageLoaded() { + const self = this; + EXIF.getData(this.$refs.img, function(this: any) { + const allMetaData = EXIF.getAllTags(this); + self.exif = allMetaData; + hljs.highlightBlock(self.$refs.exif); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.file-detail + + > .preview + padding 8px + background #f0f0f0 + + > img + display block + max-width 100% + max-height 300px + margin 0 auto + box-shadow 1px 1px 4px rgba(0, 0, 0, 0.2) + + > footer + padding 8px 8px 0 8px + font-size 0.8em + color #888 + text-align center + + > .separator + display inline + padding 0 4px + + > .size + display inline + + .time + margin 0 2px + + .px + margin-left 4px + + > .aspect-ratio + display inline + opacity 0.7 + + &:before + content "(" + + &:after + content ")" + + > .info + padding 14px + font-size 0.8em + border-top solid 1px #dfdfdf + + > div + max-width 500px + margin 0 auto + + > .separator + padding 0 4px + color #cdcdcd + + > .type + > .data-size + color #9d9d9d + + > mk-file-type-icon + margin-right 4px + + > .created-at + color #bdbdbd + + > [data-fa] + margin-right 2px + + > .menu + padding 14px + border-top solid 1px #dfdfdf + + > div + max-width 500px + margin 0 auto + + > * + display block + width 100% + padding 10px 16px + margin 0 0 12px 0 + color #333 + font-size 0.9em + text-align center + text-decoration none + text-shadow 0 1px 0 rgba(255, 255, 255, 0.9) + background-image linear-gradient(#fafafa, #eaeaea) + border 1px solid #ddd + border-bottom-color #cecece + border-radius 3px + + &:last-child + margin-bottom 0 + + &:active + background-color #767676 + background-image none + border-color #444 + box-shadow 0 1px 3px rgba(0, 0, 0, 0.075), inset 0 0 5px rgba(0, 0, 0, 0.2) + + > [data-fa] + margin-right 4px + + > .hash + padding 14px + border-top solid 1px #dfdfdf + + > div + max-width 500px + margin 0 auto + + > p + display block + margin 0 + padding 0 + color #555 + font-size 0.9em + + > [data-fa] + margin-right 4px + + > code + display block + width 100% + margin 6px 0 0 0 + padding 8px + white-space nowrap + overflow auto + font-size 0.8em + color #222 + border solid 1px #dfdfdf + border-radius 2px + background #f5f5f5 + + > .exif + padding 14px + border-top solid 1px #dfdfdf + + > div + max-width 500px + margin 0 auto + + > p + display block + margin 0 + padding 0 + color #555 + font-size 0.9em + + > [data-fa] + margin-right 4px + + > pre + display block + width 100% + margin 6px 0 0 0 + padding 8px + height 128px + overflow auto + font-size 0.9em + border solid 1px #dfdfdf + border-radius 2px + background #f5f5f5 + +</style> diff --git a/src/web/app/mobile/views/components/drive.file.vue b/src/web/app/mobile/views/components/drive.file.vue new file mode 100644 index 0000000000..dfc69e249f --- /dev/null +++ b/src/web/app/mobile/views/components/drive.file.vue @@ -0,0 +1,169 @@ +<template> +<a class="file" @click.prevent="onClick" :href="`/i/drive/file/${ file.id }`" :data-is-selected="isSelected"> + <div class="container"> + <div class="thumbnail" :style="thumbnail"></div> + <div class="body"> + <p class="name"> + <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> + <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> + </p> + <!-- + if file.tags.length > 0 + ul.tags + each tag in file.tags + li.tag(style={background: tag.color, color: contrast(tag.color)})= tag.name + --> + <footer> + <p class="type"><mk-file-type-icon :type="file.type"/>{{ file.type }}</p> + <p class="separator"></p> + <p class="data-size">{{ file.datasize | bytes }}</p> + <p class="separator"></p> + <p class="created-at"> + %fa:R clock%<mk-time :time="file.created_at"/> + </p> + </footer> + </div> + </div> +</a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['file'], + data() { + return { + isSelected: false + }; + }, + computed: { + browser(): any { + return this.$parent; + }, + thumbnail(): any { + return { + 'background-color': this.file.properties.average_color ? `rgb(${this.file.properties.average_color.join(',')})` : 'transparent', + 'background-image': `url(${this.file.url}?thumbnail&size=128)` + }; + } + }, + created() { + this.isSelected = this.browser.selectedFiles.some(f => f.id == this.file.id) + + this.browser.$on('change-selection', this.onBrowserChangeSelection); + }, + beforeDestroy() { + this.browser.$off('change-selection', this.onBrowserChangeSelection); + }, + methods: { + onBrowserChangeSelection(selections) { + this.isSelected = selections.some(f => f.id == this.file.id); + }, + onClick() { + this.browser.chooseFile(this.file); + } + } +}); +</script> + +<style lang="stylus" scoped> +.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/web/app/mobile/views/components/drive.folder.vue b/src/web/app/mobile/views/components/drive.folder.vue new file mode 100644 index 0000000000..22ff38fecb --- /dev/null +++ b/src/web/app/mobile/views/components/drive.folder.vue @@ -0,0 +1,58 @@ +<template> +<a class="root folder" @click.prevent="onClick" :href="`/i/drive/folder/${ folder.id }`"> + <div class="container"> + <p class="name">%fa:folder%{{ folder.name }}</p>%fa:angle-right% + </div> +</a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['folder'], + computed: { + browser(): any { + return this.$parent; + } + }, + methods: { + onClick() { + this.browser.cd(this.folder); + } + } +}); +</script> + +<style lang="stylus" scoped> +.root.folder + display block + color #777 + text-decoration none !important + + * + user-select none + pointer-events none + + > .container + max-width 500px + margin 0 auto + padding 16px + + > .name + display block + margin 0 + padding 0 + + > [data-fa] + margin-right 6px + + > [data-fa] + position absolute + top 0 + bottom 0 + right 20px + + > * + height 100% + +</style> diff --git a/src/web/app/mobile/views/components/drive.vue b/src/web/app/mobile/views/components/drive.vue new file mode 100644 index 0000000000..696c63e2a4 --- /dev/null +++ b/src/web/app/mobile/views/components/drive.vue @@ -0,0 +1,581 @@ +<template> +<div class="mk-drive"> + <nav ref="nav"> + <a @click.prevent="goRoot()" href="/i/drive">%fa:cloud%%i18n:mobile.tags.mk-drive.drive%</a> + <template v-for="folder in hierarchyFolders"> + <span :key="folder.id + '>'">%fa:angle-right%</span> + <a :key="folder.id" @click.prevent="cd(folder)" :href="`/i/drive/folder/${folder.id}`">{{ folder.name }}</a> + </template> + <template v-if="folder != null"> + <span>%fa:angle-right%</span> + <p>{{ folder.name }}</p> + </template> + <template v-if="file != null"> + <span>%fa:angle-right%</span> + <p>{{ file.name }}</p> + </template> + </nav> + <mk-uploader ref="uploader"/> + <div class="browser" :class="{ fetching }" v-if="file == null"> + <div class="info" v-if="info"> + <p v-if="folder == null">{{ (info.usage / info.capacity * 100).toFixed(1) }}% %i18n:mobile.tags.mk-drive.used%</p> + <p v-if="folder != null && (folder.folders_count > 0 || folder.files_count > 0)"> + <template v-if="folder.folders_count > 0">{{ folder.folders_count }} %i18n:mobile.tags.mk-drive.folder-count%</template> + <template v-if="folder.folders_count > 0 && folder.files_count > 0">%i18n:mobile.tags.mk-drive.count-separator%</template> + <template v-if="folder.files_count > 0">{{ folder.files_count }} %i18n:mobile.tags.mk-drive.file-count%</template> + </p> + </div> + <div class="folders" v-if="folders.length > 0"> + <x-folder v-for="folder in folders" :key="folder.id" :folder="folder"/> + <p v-if="moreFolders">%i18n:mobile.tags.mk-drive.load-more%</p> + </div> + <div class="files" v-if="files.length > 0"> + <x-file v-for="file in files" :key="file.id" :file="file"/> + <button class="more" v-if="moreFiles" @click="fetchMoreFiles"> + {{ fetchingMoreFiles ? '%i18n:common.loading%' : '%i18n:mobile.tags.mk-drive.load-more%' }} + </button> + </div> + <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching"> + <p v-if="folder == null">%i18n:mobile.tags.mk-drive.nothing-in-drive%</p> + <p v-if="folder != null">%i18n:mobile.tags.mk-drive.folder-is-empty%</p> + </div> + </div> + <div class="fetching" v-if="fetching && file == null && files.length == 0 && folders.length == 0"> + <div class="spinner"> + <div class="dot1"></div> + <div class="dot2"></div> + </div> + </div> + <input ref="file" class="file" type="file" multiple="multiple" @change="onChangeLocalFile"/> + <x-file-detail v-if="file != null" :file="file"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XFolder from './drive.folder.vue'; +import XFile from './drive.file.vue'; +import XFileDetail from './drive.file-detail.vue'; + +export default Vue.extend({ + components: { + XFolder, + XFile, + XFileDetail + }, + props: ['initFolder', 'initFile', 'selectFile', 'multiple', 'isNaked', 'top'], + data() { + return { + /** + * 現在の階層(フォルダ) + * * null でルートを表す + */ + folder: null, + + file: null, + + files: [], + folders: [], + moreFiles: false, + moreFolders: false, + hierarchyFolders: [], + selectedFiles: [], + info: null, + connection: null, + connectionId: null, + + fetching: true, + fetchingMoreFiles: false, + fetchingMoreFolders: false + }; + }, + computed: { + isFileSelectMode(): boolean { + return this.selectFile; + } + }, + mounted() { + this.connection = (this as any).os.streams.driveStream.getConnection(); + this.connectionId = (this as any).os.streams.driveStream.use(); + + this.connection.on('file_created', this.onStreamDriveFileCreated); + this.connection.on('file_updated', this.onStreamDriveFileUpdated); + this.connection.on('folder_created', this.onStreamDriveFolderCreated); + this.connection.on('folder_updated', this.onStreamDriveFolderUpdated); + + if (this.initFolder) { + this.cd(this.initFolder, true); + } else if (this.initFile) { + this.cf(this.initFile, true); + } else { + this.fetch(); + } + + if (this.isNaked) { + (this.$refs.nav as any).style.top = `${this.top}px`; + } + }, + beforeDestroy() { + this.connection.off('file_created', this.onStreamDriveFileCreated); + this.connection.off('file_updated', this.onStreamDriveFileUpdated); + this.connection.off('folder_created', this.onStreamDriveFolderCreated); + this.connection.off('folder_updated', this.onStreamDriveFolderUpdated); + (this as any).os.streams.driveStream.dispose(this.connectionId); + }, + methods: { + onStreamDriveFileCreated(file) { + this.addFile(file, true); + }, + + onStreamDriveFileUpdated(file) { + const current = this.folder ? this.folder.id : null; + if (current != file.folder_id) { + this.removeFile(file); + } else { + this.addFile(file, true); + } + }, + + onStreamDriveFolderCreated(folder) { + this.addFolder(folder, true); + }, + + onStreamDriveFolderUpdated(folder) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parent_id) { + this.removeFolder(folder); + } else { + this.addFolder(folder, true); + } + }, + + dive(folder) { + this.hierarchyFolders.unshift(folder); + if (folder.parent) this.dive(folder.parent); + }, + + cd(target, silent = false) { + this.file = null; + + if (target == null) { + this.goRoot(silent); + return; + } else if (typeof target == 'object') { + target = target.id; + } + + this.fetching = true; + + (this as any).api('drive/folders/show', { + folder_id: target + }).then(folder => { + this.folder = folder; + this.hierarchyFolders = []; + + if (folder.parent) this.dive(folder.parent); + + this.$emit('open-folder', this.folder, silent); + this.fetch(); + }); + }, + + addFolder(folder, unshift = false) { + const current = this.folder ? this.folder.id : null; + // 追加しようとしているフォルダが、今居る階層とは違う階層のものだったら中断 + if (current != folder.parent_id) return; + + // 追加しようとしているフォルダを既に所有してたら中断 + if (this.folders.some(f => f.id == folder.id)) return; + + if (unshift) { + this.folders.unshift(folder); + } else { + this.folders.push(folder); + } + }, + + addFile(file, unshift = false) { + const current = this.folder ? this.folder.id : null; + // 追加しようとしているファイルが、今居る階層とは違う階層のものだったら中断 + if (current != file.folder_id) return; + + if (this.files.some(f => f.id == file.id)) { + const exist = this.files.map(f => f.id).indexOf(file.id); + Vue.set(this.files, exist, file); + return; + } + + if (unshift) { + this.files.unshift(file); + } else { + this.files.push(file); + } + }, + + removeFolder(folder) { + if (typeof folder == 'object') folder = folder.id; + this.folders = this.folders.filter(f => f.id != folder); + }, + + removeFile(file) { + if (typeof file == 'object') file = file.id; + this.files = this.files.filter(f => f.id != file); + }, + + appendFile(file) { + this.addFile(file); + }, + appendFolder(folder) { + this.addFolder(folder); + }, + prependFile(file) { + this.addFile(file, true); + }, + prependFolder(folder) { + this.addFolder(folder, true); + }, + + goRoot(silent = false) { + if (this.folder || this.file) { + this.file = null; + this.folder = null; + this.hierarchyFolders = []; + this.$emit('move-root', silent); + this.fetch(); + } + }, + + fetch() { + this.folders = []; + this.files = []; + this.moreFolders = false; + this.moreFiles = false; + this.fetching = true; + + this.$emit('begin-fetch'); + + let fetchedFolders = null; + let fetchedFiles = null; + + const foldersMax = 20; + const filesMax = 20; + + // フォルダ一覧取得 + (this as any).api('drive/folders', { + folder_id: this.folder ? this.folder.id : null, + limit: foldersMax + 1 + }).then(folders => { + if (folders.length == foldersMax + 1) { + this.moreFolders = true; + folders.pop(); + } + fetchedFolders = folders; + complete(); + }); + + // ファイル一覧取得 + (this as any).api('drive/files', { + folder_id: this.folder ? this.folder.id : null, + limit: filesMax + 1 + }).then(files => { + if (files.length == filesMax + 1) { + this.moreFiles = true; + files.pop(); + } + fetchedFiles = files; + complete(); + }); + + let flag = false; + const complete = () => { + if (flag) { + fetchedFolders.forEach(this.appendFolder); + fetchedFiles.forEach(this.appendFile); + this.fetching = false; + + // 一連の読み込みが完了したイベントを発行 + this.$emit('fetched'); + } else { + flag = true; + // 一連の読み込みが半分完了したイベントを発行 + this.$emit('fetch-mid'); + } + }; + + if (this.folder == null) { + // Fetch addtional drive info + (this as any).api('drive').then(info => { + this.info = info; + }); + } + }, + + fetchMoreFiles() { + this.fetching = true; + this.fetchingMoreFiles = true; + + const max = 30; + + // ファイル一覧取得 + (this as any).api('drive/files', { + folder_id: this.folder ? this.folder.id : null, + limit: max + 1, + until_id: this.files[this.files.length - 1].id + }).then(files => { + if (files.length == max + 1) { + this.moreFiles = true; + files.pop(); + } else { + this.moreFiles = false; + } + files.forEach(this.appendFile); + this.fetching = false; + this.fetchingMoreFiles = false; + }); + }, + + chooseFile(file) { + if (this.isFileSelectMode) { + if (this.multiple) { + if (this.selectedFiles.some(f => f.id == file.id)) { + this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id); + } else { + this.selectedFiles.push(file); + } + this.$emit('change-selection', this.selectedFiles); + } else { + this.$emit('selected', file); + } + } else { + this.cf(file); + } + }, + + cf(file, silent = false) { + if (typeof file == 'object') file = file.id; + + this.fetching = true; + + (this as any).api('drive/files/show', { + file_id: file + }).then(file => { + this.file = file; + this.folder = null; + this.hierarchyFolders = []; + + if (file.folder) this.dive(file.folder); + + this.fetching = false; + + this.$emit('open-file', this.file, silent); + }); + }, + + openContextMenu() { + const fn = window.prompt('何をしますか?(数字を入力してください): <1 → ファイルをアップロード | 2 → ファイルをURLでアップロード | 3 → フォルダ作成 | 4 → このフォルダ名を変更 | 5 → このフォルダを移動 | 6 → このフォルダを削除>'); + if (fn == null || fn == '') return; + switch (fn) { + case '1': + this.selectLocalFile(); + break; + case '2': + this.urlUpload(); + break; + case '3': + this.createFolder(); + break; + case '4': + this.renameFolder(); + break; + case '5': + this.moveFolder(); + break; + case '6': + alert('ごめんなさい!フォルダの削除は未実装です...。'); + break; + } + }, + + selectLocalFile() { + (this.$refs.file as any).click(); + }, + + createFolder() { + const name = window.prompt('フォルダー名'); + if (name == null || name == '') return; + (this as any).api('drive/folders/create', { + name: name, + parent_id: this.folder ? this.folder.id : undefined + }).then(folder => { + this.addFolder(folder, true); + }); + }, + + renameFolder() { + if (this.folder == null) { + alert('現在いる場所はルートで、フォルダではないため名前の変更はできません。名前を変更したいフォルダに移動してからやってください。'); + return; + } + const name = window.prompt('フォルダー名', this.folder.name); + if (name == null || name == '') return; + (this as any).api('drive/folders/update', { + name: name, + folder_id: this.folder.id + }).then(folder => { + this.cd(folder); + }); + }, + + moveFolder() { + if (this.folder == null) { + alert('現在いる場所はルートで、フォルダではないため移動はできません。移動したいフォルダに移動してからやってください。'); + return; + } + (this as any).apis.chooseDriveFolder().then(folder => { + (this as any).api('drive/folders/update', { + parent_id: folder ? folder.id : null, + folder_id: this.folder.id + }).then(folder => { + this.cd(folder); + }); + }); + }, + + urlUpload() { + const url = window.prompt('アップロードしたいファイルのURL'); + if (url == null || url == '') return; + (this as any).api('drive/files/upload_from_url', { + url: url, + folder_id: this.folder ? this.folder.id : undefined + }); + alert('アップロードをリクエストしました。アップロードが完了するまで時間がかかる場合があります。'); + }, + + onChangeLocalFile() { + Array.from((this.$refs.file as any).files) + .forEach(f => (this.$refs.uploader as any).upload(f, this.folder)); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-drive + background #fff + + > nav + display block + position sticky + position -webkit-sticky + top 0 + z-index 1 + width 100% + padding 10px 12px + overflow auto + white-space nowrap + font-size 0.9em + color rgba(0, 0, 0, 0.67) + -webkit-backdrop-filter blur(12px) + backdrop-filter blur(12px) + background-color rgba(#fff, 0.75) + border-bottom solid 1px rgba(0, 0, 0, 0.13) + + > p + > a + display inline + margin 0 + padding 0 + text-decoration none !important + color inherit + + &:last-child + font-weight bold + + > [data-fa] + margin-right 4px + + > span + margin 0 8px + opacity 0.5 + + > .browser + &.fetching + opacity 0.5 + + > .info + border-bottom solid 1px #eee + + &:empty + display none + + > p + display block + max-width 500px + margin 0 auto + padding 4px 16px + font-size 10px + color #777 + + > .folders + > .folder + border-bottom solid 1px #eee + + > .files + > .file + border-bottom solid 1px #eee + + > .more + display block + width 100% + padding 16px + font-size 16px + color #555 + + > .empty + padding 16px + text-align center + color #999 + pointer-events none + + > p + margin 0 + + > .fetching + .spinner + margin 100px auto + width 40px + height 40px + text-align center + + animation sk-rotate 2.0s infinite linear + + .dot1, .dot2 + width 60% + height 60% + display inline-block + position absolute + top 0 + background rgba(0, 0, 0, 0.2) + border-radius 100% + + animation sk-bounce 2.0s infinite ease-in-out + + .dot2 + top auto + bottom 0 + animation-delay -1.0s + + @keyframes sk-rotate { 100% { transform: rotate(360deg); }} + + @keyframes sk-bounce { + 0%, 100% { + transform: scale(0.0); + } 50% { + transform: scale(1.0); + } + } + + > .file + display none + +</style> diff --git a/src/web/app/mobile/views/components/follow-button.vue b/src/web/app/mobile/views/components/follow-button.vue new file mode 100644 index 0000000000..2d45ea215d --- /dev/null +++ b/src/web/app/mobile/views/components/follow-button.vue @@ -0,0 +1,121 @@ +<template> +<button class="mk-follow-button" + :class="{ wait: wait, follow: !user.is_following, unfollow: user.is_following }" + @click="onClick" + :disabled="wait" +> + <template v-if="!wait && user.is_following">%fa:minus%</template> + <template v-if="!wait && !user.is_following">%fa:plus%</template> + <template v-if="wait">%fa:spinner .pulse .fw%</template> + {{ user.is_following ? '%i18n:mobile.tags.mk-follow-button.unfollow%' : '%i18n:mobile.tags.mk-follow-button.follow%' }} +</button> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + user: { + type: Object, + required: true + } + }, + data() { + return { + wait: false, + connection: null, + connectionId: null + }; + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('follow', this.onFollow); + this.connection.on('unfollow', this.onUnfollow); + }, + beforeDestroy() { + this.connection.off('follow', this.onFollow); + this.connection.off('unfollow', this.onUnfollow); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + + onFollow(user) { + if (user.id == this.user.id) { + this.user.is_following = user.is_following; + } + }, + + onUnfollow(user) { + if (user.id == this.user.id) { + this.user.is_following = user.is_following; + } + }, + + onClick() { + this.wait = true; + if (this.user.is_following) { + (this as any).api('following/delete', { + user_id: this.user.id + }).then(() => { + this.user.is_following = false; + }).catch(err => { + console.error(err); + }).then(() => { + this.wait = false; + }); + } else { + (this as any).api('following/create', { + user_id: this.user.id + }).then(() => { + this.user.is_following = true; + }).catch(err => { + console.error(err); + }).then(() => { + this.wait = false; + }); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.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/web/app/mobile/views/components/friends-maker.vue b/src/web/app/mobile/views/components/friends-maker.vue new file mode 100644 index 0000000000..961a5f568a --- /dev/null +++ b/src/web/app/mobile/views/components/friends-maker.vue @@ -0,0 +1,127 @@ +<template> +<div class="mk-friends-maker"> + <p class="title">気になるユーザーをフォロー:</p> + <div class="users" v-if="!fetching && users.length > 0"> + <mk-user-card v-for="user in users" :key="user.id" :user="user"/> + </div> + <p class="empty" v-if="!fetching && users.length == 0">おすすめのユーザーは見つかりませんでした。</p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%読み込んでいます<mk-ellipsis/></p> + <a class="refresh" @click="refresh">もっと見る</a> + <button class="close" @click="close" title="閉じる">%fa:times%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + users: [], + fetching: true, + limit: 6, + page: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + fetch() { + this.fetching = true; + this.users = []; + + (this as any).api('users/recommendation', { + limit: this.limit, + offset: this.limit * this.page + }).then(users => { + this.users = users; + this.fetching = false; + }); + }, + refresh() { + if (this.users.length < this.limit) { + this.page = 0; + } else { + this.page++; + } + this.fetch(); + }, + close() { + this.$destroy(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-friends-maker + background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + > .title + margin 0 + padding 8px 16px + font-size 1em + font-weight bold + color #888 + + > .users + overflow-x scroll + -webkit-overflow-scrolling touch + white-space nowrap + padding 16px + background #eee + + > .mk-user-card + &:not(:last-child) + margin-right 16px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + + > .refresh + display block + margin 0 + padding 8px 16px + text-align right + font-size 0.9em + color #999 + + > .close + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + background transparent + + &:hover + color #555 + + &:active + color #222 + + > [data-fa] + padding 10px + +</style> diff --git a/src/web/app/mobile/views/components/home.vue b/src/web/app/mobile/views/components/home.vue new file mode 100644 index 0000000000..3feab581d2 --- /dev/null +++ b/src/web/app/mobile/views/components/home.vue @@ -0,0 +1,29 @@ +<template> +<div class="mk-home"> + <mk-timeline @loaded="onTlLoaded"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + methods: { + onTlLoaded() { + this.$emit('loaded'); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-home + + > .mk-timeline + max-width 600px + margin 0 auto + padding 8px + + @media (min-width 500px) + padding 16px + +</style> diff --git a/src/web/app/mobile/views/components/images-image.vue b/src/web/app/mobile/views/components/images-image.vue new file mode 100644 index 0000000000..6bc1dc0aee --- /dev/null +++ b/src/web/app/mobile/views/components/images-image.vue @@ -0,0 +1,31 @@ +<template> +<a class="mk-images-image" :href="image.url" target="_blank" :style="style" :title="image.name"></a> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['image'], + computed: { + style(): any { + return { + 'background-color': this.image.properties.average_color ? `rgb(${this.image.properties.average_color.join(',')})` : 'transparent', + 'background-image': `url(${this.image.url}?thumbnail&size=512)` + }; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-images-image + display block + overflow hidden + width 100% + height 100% + background-position center + background-size cover + border-radius 4px + +</style> diff --git a/src/web/app/mobile/views/components/index.ts b/src/web/app/mobile/views/components/index.ts new file mode 100644 index 0000000000..73cc1f9f3a --- /dev/null +++ b/src/web/app/mobile/views/components/index.ts @@ -0,0 +1,39 @@ +import Vue from 'vue'; + +import ui from './ui.vue'; +import home from './home.vue'; +import timeline from './timeline.vue'; +import posts from './posts.vue'; +import imagesImage from './images-image.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'; + +Vue.component('mk-ui', ui); +Vue.component('mk-home', home); +Vue.component('mk-timeline', timeline); +Vue.component('mk-posts', posts); +Vue.component('mk-images-image', imagesImage); +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); diff --git a/src/web/app/mobile/views/components/notification-preview.vue b/src/web/app/mobile/views/components/notification-preview.vue new file mode 100644 index 0000000000..47df626fa8 --- /dev/null +++ b/src/web/app/mobile/views/components/notification-preview.vue @@ -0,0 +1,128 @@ +<template> +<div class="mk-notification-preview" :class="notification.type"> + <template v-if="notification.type == 'reaction'"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p><mk-reaction-icon :reaction="notification.reaction"/>{{ notification.user.name }}</p> + <p class="post-ref">%fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right%</p> + </div> + </template> + + <template v-if="notification.type == 'repost'"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p>%fa:retweet%{{ notification.post.user.name }}</p> + <p class="post-ref">%fa:quote-left%{{ getPostSummary(notification.post.repost) }}%fa:quote-right%</p> + </div> + </template> + + <template v-if="notification.type == 'quote'"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p>%fa:quote-left%{{ notification.post.user.name }}</p> + <p class="post-preview">{{ getPostSummary(notification.post) }}</p> + </div> + </template> + + <template v-if="notification.type == 'follow'"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p>%fa:user-plus%{{ notification.user.name }}</p> + </div> + </template> + + <template v-if="notification.type == 'reply'"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p>%fa:reply%{{ notification.post.user.name }}</p> + <p class="post-preview">{{ getPostSummary(notification.post) }}</p> + </div> + </template> + + <template v-if="notification.type == 'mention'"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p>%fa:at%{{ notification.post.user.name }}</p> + <p class="post-preview">{{ getPostSummary(notification.post) }}</p> + </div> + </template> + + <template v-if="notification.type == 'poll_vote'"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p>%fa:chart-pie%{{ notification.user.name }}</p> + <p class="post-ref">%fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right%</p> + </div> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getPostSummary from '../../../../../common/get-post-summary'; + +export default Vue.extend({ + props: ['notification'], + data() { + return { + getPostSummary + }; + } +}); +</script> + +<style lang="stylus" scoped> +.mk-notification-preview + margin 0 + padding 8px + color #fff + overflow-wrap break-word + + &:after + content "" + display block + clear both + + img + display block + float left + min-width 36px + min-height 36px + max-width 36px + max-height 36px + border-radius 6px + + .text + float right + width calc(100% - 36px) + padding-left 8px + + p + margin 0 + + i, mk-reaction-icon + margin-right 4px + + .post-ref + + [data-fa] + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &.repost, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #fff + +</style> + diff --git a/src/web/app/mobile/views/components/notification.vue b/src/web/app/mobile/views/components/notification.vue new file mode 100644 index 0000000000..dce373b452 --- /dev/null +++ b/src/web/app/mobile/views/components/notification.vue @@ -0,0 +1,189 @@ +<template> +<div class="mk-notification" :class="notification.type"> + <mk-time :time="notification.created_at"/> + + <template v-if="notification.type == 'reaction'"> + <a class="avatar-anchor" :href="`/${notification.user.username}`"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </a> + <div class="text"> + <p> + <mk-reaction-icon :reaction="notification.reaction"/> + <a :href="`/${notification.user.username}`">{{ notification.user.name }}</a> + </p> + <a class="post-ref" :href="`/${notification.post.user.username}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post) }} + %fa:quote-right% + </a> + </div> + </template> + + <template v-if="notification.type == 'repost'"> + <a class="avatar-anchor" :href="`/${notification.post.user.username}`"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </a> + <div class="text"> + <p> + %fa:retweet% + <a :href="`/${notification.post.user.username}`">{{ notification.post.user.name }}</a> + </p> + <a class="post-ref" :href="`/${notification.post.user.username}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post.repost) }}%fa:quote-right% + </a> + </div> + </template> + + <template v-if="notification.type == 'quote'"> + <a class="avatar-anchor" :href="`/${notification.post.user.username}`"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </a> + <div class="text"> + <p> + %fa:quote-left% + <a :href="`/${notification.post.user.username}`">{{ notification.post.user.name }}</a> + </p> + <a class="post-preview" :href="`/${notification.post.user.username}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</a> + </div> + </template> + + <template v-if="notification.type == 'follow'"> + <a class="avatar-anchor" :href="`/${notification.user.username}`"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </a> + <div class="text"> + <p> + %fa:user-plus% + <a :href="`/${notification.user.username}`">{{ notification.user.name }}</a> + </p> + </div> + </template> + + <template v-if="notification.type == 'reply'"> + <a class="avatar-anchor" :href="`/${notification.post.user.username}`"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </a> + <div class="text"> + <p> + %fa:reply% + <a :href="`/${notification.post.user.username}`">{{ notification.post.user.name }}</a> + </p> + <a class="post-preview" :href="`/${notification.post.user.username}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</a> + </div> + </template> + + <template v-if="notification.type == 'mention'"> + <a class="avatar-anchor" :href="`/${notification.post.user.username}`"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </a> + <div class="text"> + <p> + %fa:at% + <a :href="`/${notification.post.user.username}`">{{ notification.post.user.name }}</a> + </p> + <a class="post-preview" :href="`/${notification.post.user.username}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</a> + </div> + </template> + + <template v-if="notification.type == 'poll_vote'"> + <a class="avatar-anchor" :href="`/${notification.user.username}`"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </a> + <div class="text"> + <p> + %fa:chart-pie% + <a :href="`/${notification.user.username}`">{{ notification.user.name }}</a> + </p> + <a class="post-ref" :href="`/${notification.post.user.username}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right% + </a> + </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 + margin 0 + padding 16px + overflow-wrap break-word + + > .mk-time + display inline + position absolute + top 16px + right 12px + vertical-align top + color rgba(0, 0, 0, 0.6) + font-size 12px + + &:after + content "" + display block + clear both + + .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, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #555 + + .post-preview + color rgba(0, 0, 0, 0.7) + +</style> + diff --git a/src/web/app/mobile/views/components/notifications.vue b/src/web/app/mobile/views/components/notifications.vue new file mode 100644 index 0000000000..1cd6e2bc13 --- /dev/null +++ b/src/web/app/mobile/views/components/notifications.vue @@ -0,0 +1,168 @@ +<template> +<div class="mk-notifications"> + <div class="notifications" v-if="notifications.length != 0"> + <template v-for="(notification, i) in _notifications"> + <mk-notification :notification="notification" :key="notification.id"/> + <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date"> + <span>%fa:angle-up%{{ notification._datetext }}</span> + <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> + </p> + </template> + </div> + <button class="more" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications"> + <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template> + {{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:mobile.tags.mk-notifications.more%' }} + </button> + <p class="empty" v-if="notifications.length == 0 && !fetching">%i18n:mobile.tags.mk-notifications.empty%</p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + fetchingMoreNotifications: false, + notifications: [], + moreNotifications: false, + connection: null, + connectionId: null + }; + }, + computed: { + _notifications(): any[] { + return (this.notifications as any).map(notification => { + const date = new Date(notification.created_at).getDate(); + const month = new Date(notification.created_at).getMonth() + 1; + notification._date = date; + notification._datetext = `${month}月 ${date}日`; + return notification; + }); + } + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('notification', this.onNotification); + + const max = 10; + + (this as any).api('i/notifications', { + limit: max + 1 + }).then(notifications => { + if (notifications.length == max + 1) { + this.moreNotifications = true; + notifications.pop(); + } + + this.notifications = notifications; + this.fetching = false; + this.$emit('fetched'); + }); + }, + beforeDestroy() { + this.connection.off('notification', this.onNotification); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + fetchMoreNotifications() { + this.fetchingMoreNotifications = true; + + const max = 30; + + (this as any).api('i/notifications', { + limit: max + 1, + until_id: this.notifications[this.notifications.length - 1].id + }).then(notifications => { + if (notifications.length == max + 1) { + this.moreNotifications = true; + notifications.pop(); + } else { + this.moreNotifications = false; + } + this.notifications = this.notifications.concat(notifications); + this.fetchingMoreNotifications = false; + }); + }, + onNotification(notification) { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.connection.send({ + type: 'read_notification', + id: notification.id + }); + + this.notifications.unshift(notification); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-notifications + margin 8px auto + padding 0 + max-width 500px + width calc(100% - 16px) + background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) + + > .notifications + + > .mk-notification + margin 0 auto + max-width 500px + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + &:last-child + border-bottom none + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.8em + color #aaa + background #fdfdfd + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + span + margin 0 16px + + i + margin-right 8px + + > .more + display block + width 100% + padding 16px + color #555 + border-top solid 1px rgba(0, 0, 0, 0.05) + + > [data-fa] + margin-right 4px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/web/app/mobile/views/components/notify.vue b/src/web/app/mobile/views/components/notify.vue new file mode 100644 index 0000000000..6d4a481dbe --- /dev/null +++ b/src/web/app/mobile/views/components/notify.vue @@ -0,0 +1,49 @@ +<template> +<div class="mk-notify"> + <mk-notification-preview :notification="notification"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['notification'], + mounted() { + this.$nextTick(() => { + anime({ + targets: this.$el, + bottom: '0px', + duration: 500, + easing: 'easeOutQuad' + }); + + setTimeout(() => { + anime({ + targets: this.$el, + bottom: '-64px', + duration: 500, + easing: 'easeOutQuad', + complete: () => this.$destroy() + }); + }, 6000); + }); + } +}); +</script> + +<style lang="stylus" scoped> +.mk-notify + position fixed + z-index 1024 + bottom -64px + left 0 + width 100% + height 64px + pointer-events none + -webkit-backdrop-filter blur(2px) + backdrop-filter blur(2px) + background-color rgba(#000, 0.5) + +</style> diff --git a/src/web/app/mobile/views/components/post-card.vue b/src/web/app/mobile/views/components/post-card.vue new file mode 100644 index 0000000000..08a2bebfce --- /dev/null +++ b/src/web/app/mobile/views/components/post-card.vue @@ -0,0 +1,85 @@ +<template> +<div class="mk-post-card"> + <a :href="`/${post.user.username}/${post.id}`"> + <header> + <img :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/><h3>{{ post.user.name }}</h3> + </header> + <div> + {{ text }} + </div> + <mk-time :time="post.created_at"/> + </a> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import summary from '../../../../../common/get-post-summary'; + +export default Vue.extend({ + props: ['post'], + computed: { + 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/web/app/mobile/views/components/post-detail.sub.vue b/src/web/app/mobile/views/components/post-detail.sub.vue new file mode 100644 index 0000000000..dff0cef51f --- /dev/null +++ b/src/web/app/mobile/views/components/post-detail.sub.vue @@ -0,0 +1,102 @@ +<template> +<div class="root sub"> + <router-link class="avatar-anchor" :to="`/${post.user.username}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/${post.user.username}`">{{ post.user.name }}</router-link> + <span class="username">@{{ post.user.username }}</span> + <router-link class="time" :to="`/${post.user.username}/${post.id}`"> + <mk-time :time="post.created_at"/> + </router-link> + </header> + <div class="body"> + <mk-sub-post-content class="text" :post="post"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['post'] +}); +</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/web/app/mobile/views/components/post-detail.vue b/src/web/app/mobile/views/components/post-detail.vue new file mode 100644 index 0000000000..e7c08df7e9 --- /dev/null +++ b/src/web/app/mobile/views/components/post-detail.vue @@ -0,0 +1,362 @@ +<template> +<div class="mk-post-detail"> + <button + class="more" + v-if="p.reply && p.reply.reply_id && context == null" + @click="fetchContext" + :disabled="fetchingContext" + > + <template v-if="!contextFetching">%fa:ellipsis-v%</template> + <template v-if="contextFetching">%fa:spinner .pulse%</template> + </button> + <div class="context"> + <x-sub v-for="post in context" :key="post.id" :post="post"/> + </div> + <div class="reply-to" v-if="p.reply"> + <x-sub :post="p.reply"/> + </div> + <div class="repost" v-if="isRepost"> + <p> + <router-link class="avatar-anchor" :to="`/${post.user.username}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=32`" alt="avatar"/> + </router-link> + %fa:retweet% + <router-link class="name" :to="`/${post.user.username}`"> + {{ post.user.name }} + </router-link> + がRepost + </p> + </div> + <article> + <header> + <router-link class="avatar-anchor" :to="`/${p.user.username}`"> + <img class="avatar" :src="`${p.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div> + <router-link class="name" :to="`/${p.user.username}`">{{ p.user.name }}</router-link> + <span class="username">@{{ p.user.username }}</span> + </div> + </header> + <div class="body"> + <mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <div class="media" v-if="p.media"> + <mk-images :images="p.media"/> + </div> + <mk-poll v-if="p.poll" :post="p"/> + </div> + <router-link class="time" :to="`/${p.user.username}/${p.id}`"> + <mk-time :time="p.created_at" mode="detail"/> + </router-link> + <footer> + <mk-reactions-viewer :post="p"/> + <button @click="reply" title="%i18n:mobile.tags.mk-post-detail.reply%"> + %fa:reply%<p class="count" v-if="p.replies_count > 0">{{ p.replies_count }}</p> + </button> + <button @click="repost" title="Repost"> + %fa:retweet%<p class="count" v-if="p.repost_count > 0">{{ p.repost_count }}</p> + </button> + <button :class="{ reacted: p.my_reaction != null }" @click="react" ref="reactButton" title="%i18n:mobile.tags.mk-post-detail.reaction%"> + %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> + </button> + <button @click="menu" ref="menuButton"> + %fa:ellipsis-h% + </button> + </footer> + </article> + <div class="replies" v-if="!compact"> + <x-sub v-for="post in replies" :key="post.id" :post="post"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import 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: { + isRepost(): boolean { + return this.post.repost != null; + }, + p(): any { + return this.isRepost ? this.post.repost : this.post; + }, + reactionsCount(): number { + return this.p.reaction_counts + ? Object.keys(this.p.reaction_counts) + .map(key => this.p.reaction_counts[key]) + .reduce((a, b) => a + b) + : 0; + }, + urls(): string[] { + if (this.p.ast) { + return this.p.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + }, + mounted() { + // Get replies + if (!this.compact) { + (this as any).api('posts/replies', { + post_id: this.p.id, + limit: 8 + }).then(replies => { + this.replies = replies; + }); + } + }, + methods: { + fetchContext() { + this.contextFetching = true; + + // Fetch context + (this as any).api('posts/context', { + post_id: this.p.reply_id + }).then(context => { + this.contextFetching = false; + this.context = context.reverse(); + }); + }, + reply() { + (this as any).apis.post({ + reply: this.p + }); + }, + repost() { + (this as any).apis.post({ + repost: this.p + }); + }, + react() { + (this as any).os.new(MkReactionPicker, { + source: this.$refs.reactButton, + post: this.p, + compact: true + }); + }, + menu() { + (this as any).os.new(MkPostMenu, { + source: this.$refs.menuButton, + post: this.p, + compact: true + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.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 + + > .text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 16px + color #717171 + + @media (min-width 500px) + font-size 24px + + > .mk-url-preview + margin-top 8px + + > .media + > img + display block + max-width 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> diff --git a/src/web/app/mobile/views/components/post-form.vue b/src/web/app/mobile/views/components/post-form.vue new file mode 100644 index 0000000000..3e8206c92c --- /dev/null +++ b/src/web/app/mobile/views/components/post-form.vue @@ -0,0 +1,233 @@ +<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> + <button class="submit" :disabled="posting" @click="post">%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"/> + <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> + <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 + }; + }, + mounted() { + this.$nextTick(() => { + (this.$refs.text as any).focus(); + }); + }, + methods: { + 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); + }, + clear() { + this.text = ''; + this.files = []; + this.poll = false; + this.$emit('change-attached-media'); + }, + post() { + this.posting = true; + (this as any).api('posts/create', { + text: this.text == '' ? undefined : this.text, + media_ids: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + reply_id: this.reply ? this.reply.id : undefined, + poll: this.poll ? (this.$refs.poll as any).get() : undefined + }).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> +.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 + width 50px + line-height 50px + font-size 24px + color #555 + + > div + position absolute + top 0 + right 0 + + > .text-count + line-height 50px + color #657786 + + > .submit + margin 8px + padding 0 16px + line-height 34px + 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 + 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/web/app/mobile/views/components/post-preview.vue b/src/web/app/mobile/views/components/post-preview.vue new file mode 100644 index 0000000000..ccb8b5f336 --- /dev/null +++ b/src/web/app/mobile/views/components/post-preview.vue @@ -0,0 +1,99 @@ +<template> +<div class="mk-post-preview"> + <a class="avatar-anchor" :href="`/${post.user.username}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </a> + <div class="main"> + <header> + <a class="name" :href="`/${post.user.username}`">{{ post.user.name }}</a> + <span class="username">@{{ post.user.username }}</span> + <a class="time" :href="`/${post.user.username}/${post.id}`"> + <mk-time :time="post.created_at"/> + </a> + </header> + <div class="body"> + <mk-sub-post-content class="text" :post="post"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['post'] +}); +</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/web/app/mobile/views/components/posts.post.sub.vue b/src/web/app/mobile/views/components/posts.post.sub.vue new file mode 100644 index 0000000000..f1c858675e --- /dev/null +++ b/src/web/app/mobile/views/components/posts.post.sub.vue @@ -0,0 +1,108 @@ +<template> +<div class="sub"> + <router-link class="avatar-anchor" :to="`/${post.user.username}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=96`" alt="avatar"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/${post.user.username}`">{{ post.user.name }}</router-link> + <span class="username">@{{ post.user.username }}</span> + <router-link class="created-at" :to="`/${post.user.username}/${post.id}`"> + <mk-time :time="post.created_at"/> + </router-link> + </header> + <div class="body"> + <mk-sub-post-content class="text" :post="post"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['post'] +}); +</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/web/app/mobile/views/components/posts.post.vue b/src/web/app/mobile/views/components/posts.post.vue new file mode 100644 index 0000000000..43d8d4a89b --- /dev/null +++ b/src/web/app/mobile/views/components/posts.post.vue @@ -0,0 +1,445 @@ +<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="`/${post.user.username}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + %fa:retweet% + {{ '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('{')) }} + <router-link class="name" :to="`/${post.user.username}`">{{ post.user.name }}</router-link> + {{ '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr('%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1) }} + </p> + <mk-time :time="post.created_at"/> + </div> + <article> + <router-link class="avatar-anchor" :to="`/${p.user.username}`"> + <img class="avatar" :src="`${p.user.avatar_url}?thumbnail&size=96`" alt="avatar"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/${p.user.username}`">{{ p.user.name }}</router-link> + <span class="is-bot" v-if="p.user.is_bot">bot</span> + <span class="username">@{{ p.user.username }}</span> + <router-link class="created-at" :to="url"> + <mk-time :time="p.created_at"/> + </router-link> + </header> + <div class="body"> + <div class="text" ref="text"> + <p class="channel" v-if="p.channel != null"><a target="_blank">{{ p.channel.title }}</a>:</p> + <a class="reply" v-if="p.reply"> + %fa:reply% + </a> + <mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <a class="quote" v-if="p.repost != null">RP:</a> + </div> + <div class="media" v-if="p.media"> + <mk-images :images="p.media"/> + </div> + <mk-poll v-if="p.poll" :post="p" ref="pollViewer"/> + <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span> + <div class="repost" v-if="p.repost">%fa:quote-right -flip-h% + <mk-post-preview class="repost" :post="p.repost"/> + </div> + </div> + <footer> + <mk-reactions-viewer :post="p" ref="reactionsViewer"/> + <button @click="reply"> + %fa:reply%<p class="count" v-if="p.replies_count > 0">{{ p.replies_count }}</p> + </button> + <button @click="repost" title="Repost"> + %fa:retweet%<p class="count" v-if="p.repost_count > 0">{{ p.repost_count }}</p> + </button> + <button :class="{ reacted: p.my_reaction != null }" @click="react" ref="reactButton"> + %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> + </button> + <button class="menu" @click="menu" ref="menuButton"> + %fa:ellipsis-h% + </button> + </footer> + </div> + </article> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkPostMenu from '../../../common/views/components/post-menu.vue'; +import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; +import XSub from './posts.post.sub.vue'; + +export default Vue.extend({ + components: { + XSub + }, + props: ['post'], + data() { + return { + connection: null, + connectionId: null + }; + }, + computed: { + isRepost(): boolean { + return (this.post.repost && + this.post.text == null && + this.post.media_ids == null && + this.post.poll == null); + }, + p(): any { + return this.isRepost ? this.post.repost : this.post; + }, + reactionsCount(): number { + return this.p.reaction_counts + ? Object.keys(this.p.reaction_counts) + .map(key => this.p.reaction_counts[key]) + .reduce((a, b) => a + b) + : 0; + }, + url(): string { + return `/${this.p.user.username}/${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() { + 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); + } + }, + beforeDestroy() { + this.decapture(true); + 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.post.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.post.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); + } + }, + 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> +.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 + white-space nowrap + + @media (min-width 500px) + margin-bottom 2px + + > .name + display block + margin 0 0.5em 0 0 + padding 0 + overflow hidden + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .is-bot + text-align left + margin 0 0.5em 0 0 + padding 1px 6px + font-size 12px + color #aaa + border solid 1px #ddd + border-radius 3px + + > .username + text-align left + margin 0 0.5em 0 0 + color #ccc + + > .created-at + margin-left auto + font-size 0.9em + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.1em + color #717171 + + > .dummy + display none + + mk-url-preview + margin-top 8px + + > .channel + margin 0 + + > .reply + margin-right 8px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + + 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 + + > .media + > img + display block + max-width 100% + + > .app + font-size 12px + color #ccc + + > .mk-poll + font-size 80% + + > .repost + margin 8px 0 + + > [data-fa]:first-child + position absolute + top -8px + left -8px + z-index 1 + color #c0dac6 + font-size 28px + background #fff + + > .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> + diff --git a/src/web/app/mobile/views/components/posts.vue b/src/web/app/mobile/views/components/posts.vue new file mode 100644 index 0000000000..34fb0749a2 --- /dev/null +++ b/src/web/app/mobile/views/components/posts.vue @@ -0,0 +1,113 @@ +<template> +<div class="mk-posts"> + <slot name="head"></slot> + <slot></slot> + <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="tail"></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.created_at).getDate(); + const month = new Date(post.created_at).getMonth() + 1; + post._date = date; + post._datetext = `${month}月 ${date}日`; + return post; + }); + } + }, + methods: { + onPostUpdated(i, post) { + Vue.set((this as any).posts, i, post); + } + } +}); +</script> + +<style lang="stylus" scoped> +.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/web/app/mobile/views/components/sub-post-content.vue b/src/web/app/mobile/views/components/sub-post-content.vue new file mode 100644 index 0000000000..429e760050 --- /dev/null +++ b/src/web/app/mobile/views/components/sub-post-content.vue @@ -0,0 +1,43 @@ +<template> +<div class="mk-sub-post-content"> + <div class="body"> + <a class="reply" v-if="post.reply_id">%fa:reply%</a> + <mk-post-html v-if="post.ast" :ast="post.ast" :i="os.i"/> + <a class="quote" v-if="post.repost_id">RP: ...</a> + </div> + <details v-if="post.media"> + <summary>({{ post.media.length }}個のメディア)</summary> + <mk-images :images="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 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + + mk-poll + font-size 80% + +</style> diff --git a/src/web/app/mobile/views/components/timeline.vue b/src/web/app/mobile/views/components/timeline.vue new file mode 100644 index 0000000000..e7a9f2df1a --- /dev/null +++ b/src/web/app/mobile/views/components/timeline.vue @@ -0,0 +1,95 @@ +<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 && posts.length != 0" @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> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + date: { + type: Date, + required: false + } + }, + data() { + return { + fetching: true, + moreFetching: false, + posts: [], + connection: null, + connectionId: null + }; + }, + computed: { + alone(): boolean { + return (this as any).os.i.following_count == 0; + } + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('post', this.onPost); + this.connection.on('follow', this.onChangeFollowing); + this.connection.on('unfollow', this.onChangeFollowing); + + this.fetch(); + }, + beforeDestroy() { + this.connection.off('post', this.onPost); + this.connection.off('follow', this.onChangeFollowing); + this.connection.off('unfollow', this.onChangeFollowing); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + fetch(cb?) { + this.fetching = true; + + (this as any).api('posts/timeline', { + until_date: this.date ? (this.date as any).getTime() : undefined + }).then(posts => { + this.posts = posts; + this.fetching = false; + this.$emit('loaded'); + if (cb) cb(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0) return; + this.moreFetching = true; + (this as any).api('posts/timeline', { + until_id: this.posts[this.posts.length - 1].id + }).then(posts => { + 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/web/app/mobile/views/components/ui.header.vue b/src/web/app/mobile/views/components/ui.header.vue new file mode 100644 index 0000000000..2df5ea162e --- /dev/null +++ b/src/web/app/mobile/views/components/ui.header.vue @@ -0,0 +1,168 @@ +<template> +<div class="header"> + <mk-special-message/> + <div class="main"> + <div class="backdrop"></div> + <div class="content"> + <button class="nav" @click="$parent.isDrawerOpening = true">%fa:bars%</button> + <template v-if="hasUnreadNotifications || hasUnreadMessagingMessages">%fa:circle%</template> + <h1> + <slot>Misskey</slot> + </h1> + <button v-if="func" @click="func"> + <slot name="funcIcon"></slot> + </button> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['func'], + data() { + return { + hasUnreadNotifications: false, + hasUnreadMessagingMessages: 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); + + // 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 as any).os.stream.dispose(this.connectionId); + } + }, + methods: { + onReadAllNotifications() { + this.hasUnreadNotifications = false; + }, + onUnreadNotification() { + this.hasUnreadNotifications = true; + }, + onReadAllMessagingMessages() { + this.hasUnreadMessagingMessages = false; + }, + onUnreadMessagingMessage() { + this.hasUnreadMessagingMessages = true; + } + } +}); +</script> + +<style lang="stylus" scoped> +.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 1023 + width 100% + height $height + -webkit-backdrop-filter blur(12px) + backdrop-filter blur(12px) + background-color rgba(#1b2023, 0.75) + + > .content + z-index 1024 + + > 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] + margin-right 8px + + > 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 + 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 + 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/web/app/mobile/views/components/ui.nav.vue b/src/web/app/mobile/views/components/ui.nav.vue new file mode 100644 index 0000000000..5ca7e2e94d --- /dev/null +++ b/src/web/app/mobile/views/components/ui.nav.vue @@ -0,0 +1,200 @@ +<template> +<div class="nav" :style="{ display: isOpen ? 'block' : 'none' }"> + <div class="backdrop" @click="$parent.isDrawerOpening = false"></div> + <div class="body"> + <router-link class="me" v-if="os.isSignedIn" :to="`/${os.i.username}`"> + <img class="avatar" :src="`${os.i.avatar_url}?thumbnail&size=128`" alt="avatar"/> + <p class="name">{{ os.i.name }}</p> + </router-link> + <div class="links"> + <ul> + <li><router-link to="/">%fa:home%%i18n:mobile.tags.mk-ui-nav.home%%fa:angle-right%</router-link></li> + <li><router-link to="/i/notifications">%fa:R bell%%i18n:mobile.tags.mk-ui-nav.notifications%<template v-if="hasUnreadNotifications">%fa:circle%</template>%fa:angle-right%</router-link></li> + <li><router-link to="/i/messaging">%fa:R comments%%i18n:mobile.tags.mk-ui-nav.messaging%<template v-if="hasUnreadMessagingMessages">%fa:circle%</template>%fa:angle-right%</router-link></li> + </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="docsUrl"><p class="about">%i18n:mobile.tags.mk-ui-nav.about%</p></a> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { docsUrl, chUrl } from '../../../config'; + +export default Vue.extend({ + props: ['isOpen'], + data() { + return { + hasUnreadNotifications: false, + hasUnreadMessagingMessages: false, + connection: null, + connectionId: null, + docsUrl, + 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); + + // 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 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; + } + } +}); +</script> + +<style lang="stylus" scoped> +.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 + +</style> diff --git a/src/web/app/mobile/views/components/ui.vue b/src/web/app/mobile/views/components/ui.vue new file mode 100644 index 0000000000..54b8a2d0d3 --- /dev/null +++ b/src/web/app/mobile/views/components/ui.vue @@ -0,0 +1,67 @@ +<template> +<div class="mk-ui"> + <x-header :func="func"> + <template slot="funcIcon"><slot name="funcIcon"></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', 'func'], + 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 + padding-top 48px +</style> diff --git a/src/web/app/mobile/views/components/user-card.vue b/src/web/app/mobile/views/components/user-card.vue new file mode 100644 index 0000000000..729421616e --- /dev/null +++ b/src/web/app/mobile/views/components/user-card.vue @@ -0,0 +1,62 @@ +<template> +<div class="mk-user-card"> + <header :style="user.banner_url ? `background-image: url(${user.banner_url}?thumbnail&size=1024)` : ''"> + <a :href="`/${user.username}`"> + <img :src="`${user.avatar_url}?thumbnail&size=200`" alt="avatar"/> + </a> + </header> + <a class="name" :href="`/${user.username}`" target="_blank">{{ user.name }}</a> + <p class="username">@{{ user.username }}</p> + <mk-follow-button :user="user"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['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/web/app/mobile/views/components/user-preview.vue b/src/web/app/mobile/views/components/user-preview.vue new file mode 100644 index 0000000000..3cbc200337 --- /dev/null +++ b/src/web/app/mobile/views/components/user-preview.vue @@ -0,0 +1,103 @@ +<template> +<div class="mk-user-preview"> + <router-link class="avatar-anchor" :to="`/${user.username}`"> + <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/${user.username}`">{{ user.name }}</router-link> + <span class="username">@{{ user.username }}</span> + </header> + <div class="body"> + <div class="description">{{ user.description }}</div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['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/web/app/mobile/views/components/user-timeline.vue b/src/web/app/mobile/views/components/user-timeline.vue new file mode 100644 index 0000000000..ffd6288381 --- /dev/null +++ b/src/web/app/mobile/views/components/user-timeline.vue @@ -0,0 +1,46 @@ +<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="canFetchMore" @click="more" :disabled="fetching" slot="tail"> + <span v-if="!fetching">%i18n:mobile.tags.mk-user-timeline.load-more%</span> + <span v-if="fetching">%i18n:common.loading%<mk-ellipsis/></span> + </button> + </mk-posts> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user', 'withMedia'], + data() { + return { + fetching: true, + posts: [] + }; + }, + mounted() { + (this as any).api('users/posts', { + user_id: this.user.id, + with_media: this.withMedia + }).then(posts => { + this.posts = posts; + this.fetching = false; + this.$emit('loaded'); + }); + } +}); +</script> + +<style lang="stylus" scoped> +.mk-user-timeline + max-width 600px + margin 0 auto +</style> diff --git a/src/web/app/mobile/views/components/users-list.vue b/src/web/app/mobile/views/components/users-list.vue new file mode 100644 index 0000000000..d6c6261354 --- /dev/null +++ b/src/web/app/mobile/views/components/users-list.vue @@ -0,0 +1,131 @@ +<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> +.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/web/app/mobile/views/directives/index.ts b/src/web/app/mobile/views/directives/index.ts new file mode 100644 index 0000000000..324e07596d --- /dev/null +++ b/src/web/app/mobile/views/directives/index.ts @@ -0,0 +1,6 @@ +import Vue from 'vue'; + +import userPreview from './user-preview'; + +Vue.directive('userPreview', userPreview); +Vue.directive('user-preview', userPreview); diff --git a/src/web/app/mobile/views/directives/user-preview.ts b/src/web/app/mobile/views/directives/user-preview.ts new file mode 100644 index 0000000000..1a54abc20d --- /dev/null +++ b/src/web/app/mobile/views/directives/user-preview.ts @@ -0,0 +1,2 @@ +// nope +export default {}; diff --git a/src/web/app/mobile/views/pages/drive.vue b/src/web/app/mobile/views/pages/drive.vue new file mode 100644 index 0000000000..689be04d81 --- /dev/null +++ b/src/web/app/mobile/views/pages/drive.vue @@ -0,0 +1,106 @@ +<template> +<mk-ui :func="fn"> + <span slot="header"> + <template v-if="folder">%fa:R folder-open%{{ folder.name }}</template> + <template v-if="file"><mk-file-type-icon class="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="funcIcon">%fa:ellipsis-h%</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'; + }, + 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/web/app/mobile/views/pages/followers.vue b/src/web/app/mobile/views/pages/followers.vue new file mode 100644 index 0000000000..c2b6b90e29 --- /dev/null +++ b/src/web/app/mobile/views/pages/followers.vue @@ -0,0 +1,66 @@ +<template> +<mk-ui> + <template slot="header" v-if="!fetching"> + <img :src="`${user.avatar_url}?thumbnail&size=64`" alt=""> + {{ '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name) }} + </template> + <mk-users-list + v-if="!fetching" + :fetch="fetchUsers" + :count="user.followers_count" + :you-know-count="user.followers_you_know_count" + @loaded="onLoaded" + > + %i18n:mobile.tags.mk-user-followers.no-users% + </mk-users-list> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +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', { + username: this.$route.params.user + }).then(user => { + this.user = user; + this.fetching = false; + + document.title = '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name) + ' | Misskey'; + }); + }, + onLoaded() { + Progress.done(); + }, + fetchUsers(iknow, limit, cursor, cb) { + (this as any).api('users/followers', { + user_id: this.user.id, + iknow: iknow, + limit: limit, + cursor: cursor ? cursor : undefined + }).then(cb); + } + } +}); +</script> diff --git a/src/web/app/mobile/views/pages/following.vue b/src/web/app/mobile/views/pages/following.vue new file mode 100644 index 0000000000..6365d3b370 --- /dev/null +++ b/src/web/app/mobile/views/pages/following.vue @@ -0,0 +1,66 @@ +<template> +<mk-ui> + <template slot="header" v-if="!fetching"> + <img :src="`${user.avatar_url}?thumbnail&size=64`" alt=""> + {{ '%i18n:mobile.tags.mk-user-following-page.following-of%'.replace('{}', user.name) }} + </template> + <mk-users-list + v-if="!fetching" + :fetch="fetchUsers" + :count="user.following_count" + :you-know-count="user.following_you_know_count" + @loaded="onLoaded" + > + %i18n:mobile.tags.mk-user-following.no-users% + </mk-users-list> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +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', { + username: this.$route.params.user + }).then(user => { + this.user = user; + this.fetching = false; + + document.title = '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name) + ' | Misskey'; + }); + }, + onLoaded() { + Progress.done(); + }, + fetchUsers(iknow, limit, cursor, cb) { + (this as any).api('users/following', { + user_id: this.user.id, + iknow: iknow, + limit: limit, + cursor: cursor ? cursor : undefined + }).then(cb); + } + } +}); +</script> diff --git a/src/web/app/mobile/views/pages/home.vue b/src/web/app/mobile/views/pages/home.vue new file mode 100644 index 0000000000..c81cbcadb3 --- /dev/null +++ b/src/web/app/mobile/views/pages/home.vue @@ -0,0 +1,60 @@ +<template> +<mk-ui :func="fn"> + <span slot="header">%fa:home%%i18n:mobile.tags.mk-home.home%</span> + <template slot="funcIcon">%fa:pencil-alt%</template> + <mk-home @loaded="onHomeLoaded"/> +</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({ + data() { + return { + connection: null, + connectionId: null, + unreadCount: 0 + }; + }, + 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); + 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: { + fn() { + (this as any).apis.post(); + }, + onHomeLoaded() { + Progress.done(); + }, + onStreamPost(post) { + if (document.hidden && post.user_id !== (this as any).os.i.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${getPostSummary(post)}`; + } + }, + onVisibilitychange() { + if (!document.hidden) { + this.unreadCount = 0; + document.title = 'Misskey'; + } + } + } +}); +</script> diff --git a/src/web/app/mobile/views/pages/index.vue b/src/web/app/mobile/views/pages/index.vue new file mode 100644 index 0000000000..0ea47d913b --- /dev/null +++ b/src/web/app/mobile/views/pages/index.vue @@ -0,0 +1,16 @@ +<template> +<component :is="os.isSignedIn ? 'home' : 'welcome'"></component> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Home from './home.vue'; +import Welcome from './welcome.vue'; + +export default Vue.extend({ + components: { + Home, + Welcome + } +}); +</script> diff --git a/src/web/app/mobile/views/pages/messaging-room.vue b/src/web/app/mobile/views/pages/messaging-room.vue new file mode 100644 index 0000000000..a653145c10 --- /dev/null +++ b/src/web/app/mobile/views/pages/messaging-room.vue @@ -0,0 +1,42 @@ +<template> +<mk-ui> + <span slot="header"> + <template v-if="user">%fa:R comments%{{ user.name }}</template> + <template v-else><mk-ellipsis/></template> + </span> + <mk-messaging-room v-if="!fetching" :user="user" is-naked/> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +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', { + username: (this as any).$route.params.username + }).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/web/app/mobile/views/pages/messaging.vue b/src/web/app/mobile/views/pages/messaging.vue new file mode 100644 index 0000000000..f36ad4a4fe --- /dev/null +++ b/src/web/app/mobile/views/pages/messaging.vue @@ -0,0 +1,21 @@ +<template> +<mk-ui> + <span slot="header">%fa:R comments%%i18n:mobile.tags.mk-messaging-page.message%</span> + <mk-messaging @navigate="navigate"/> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +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/${user.username}`); + } + } +}); +</script> diff --git a/src/web/app/mobile/views/pages/notifications.vue b/src/web/app/mobile/views/pages/notifications.vue new file mode 100644 index 0000000000..b1243dbc74 --- /dev/null +++ b/src/web/app/mobile/views/pages/notifications.vue @@ -0,0 +1,32 @@ +<template> +<mk-ui :func="fn"> + <span slot="header">%fa:R bell%%i18n:mobile.tags.mk-notifications-page.notifications%</span> + <span slot="funcIcon">%fa:check%</span> + <mk-notifications @fetched="onFetched"/> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +export default Vue.extend({ + mounted() { + document.title = 'Misskey | %i18n:mobile.tags.mk-notifications-page.notifications%'; + document.documentElement.style.background = '#313a42'; + + Progress.start(); + }, + methods: { + fn() { + const ok = window.confirm('%i18n:mobile.tags.mk-notifications-page.read-all%'); + if (!ok) return; + + (this as any).api('notifications/mark_as_read_all'); + }, + onFetched() { + Progress.done(); + } + } +}); +</script> diff --git a/src/web/app/mobile/views/pages/post.vue b/src/web/app/mobile/views/pages/post.vue new file mode 100644 index 0000000000..2ed2ebfcfd --- /dev/null +++ b/src/web/app/mobile/views/pages/post.vue @@ -0,0 +1,85 @@ +<template> +<mk-ui> + <span slot="header">%fa:R sticky-note%%i18n:mobile.tags.mk-post-page.title%</span> + <main v-if="!fetching"> + <a v-if="post.next" :href="post.next">%fa:angle-up%%i18n:mobile.tags.mk-post-page.next%</a> + <div> + <mk-post-detail :post="post"/> + </div> + <a v-if="post.prev" :href="post.prev">%fa:angle-down%%i18n:mobile.tags.mk-post-page.prev%</a> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +export default Vue.extend({ + data() { + return { + fetching: true, + post: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + mounted() { + document.title = 'Misskey'; + document.documentElement.style.background = '#313a42'; + }, + methods: { + fetch() { + Progress.start(); + this.fetching = true; + + (this as any).api('posts/show', { + post_id: this.$route.params.post + }).then(post => { + this.post = post; + this.fetching = false; + + Progress.done(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +main + text-align center + + > div + margin 8px auto + padding 0 + max-width 500px + width calc(100% - 16px) + + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) + + > a + display inline-block + + &:first-child + margin-top 8px + + @media (min-width 500px) + margin-top 16px + + &:last-child + margin-bottom 8px + + @media (min-width 500px) + margin-bottom 16px + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/web/app/mobile/views/pages/profile-setting.vue b/src/web/app/mobile/views/pages/profile-setting.vue new file mode 100644 index 0000000000..432a850e46 --- /dev/null +++ b/src/web/app/mobile/views/pages/profile-setting.vue @@ -0,0 +1,224 @@ +<template> +<mk-ui> + <span slot="header">%fa:user%%i18n:mobile.tags.mk-profile-setting-page.title%</span> + <div :class="$style.content"> + <p>%fa:info-circle%%i18n:mobile.tags.mk-profile-setting.will-be-published%</p> + <div :class="$style.form"> + <div :style="os.i.banner_url ? `background-image: url(${os.i.banner_url}?thumbnail&size=1024)` : ''" @click="setBanner"> + <img :src="`${os.i.avatar_url}?thumbnail&size=200`" alt="avatar" @click="setAvatar"/> + </div> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.name%</p> + <input v-model="name" type="text"/> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.location%</p> + <input v-model="location" type="text"/> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.description%</p> + <textarea v-model="description"></textarea> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.birthday%</p> + <input v-model="birthday" type="date"/> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.avatar%</p> + <button @click="setAvatar" :disabled="avatarSaving">%i18n:mobile.tags.mk-profile-setting.set-avatar%</button> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.banner%</p> + <button @click="setBanner" :disabled="bannerSaving">%i18n:mobile.tags.mk-profile-setting.set-banner%</button> + </label> + </div> + <button :class="$style.save" @click="save" :disabled="saving">%fa:check%%i18n:mobile.tags.mk-profile-setting.save%</button> + </div> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + name: null, + location: null, + description: null, + birthday: null, + avatarSaving: false, + bannerSaving: false, + saving: false + }; + }, + created() { + this.name = (this as any).os.i.name; + this.location = (this as any).os.i.profile.location; + this.description = (this as any).os.i.description; + this.birthday = (this as any).os.i.profile.birthday; + }, + mounted() { + document.title = 'Misskey | %i18n:mobile.tags.mk-profile-setting-page.title%'; + document.documentElement.style.background = '#313a42'; + }, + methods: { + setAvatar() { + (this as any).apis.chooseDriveFile({ + multiple: false + }).then(file => { + this.avatarSaving = true; + + (this as any).api('i/update', { + avatar_id: file.id + }).then(() => { + this.avatarSaving = false; + alert('%i18n:mobile.tags.mk-profile-setting.avatar-saved%'); + }); + }); + }, + setBanner() { + (this as any).apis.chooseDriveFile({ + multiple: false + }).then(file => { + this.bannerSaving = true; + + (this as any).api('i/update', { + banner_id: file.id + }).then(() => { + this.bannerSaving = false; + alert('%i18n:mobile.tags.mk-profile-setting.banner-saved%'); + }); + }); + }, + save() { + this.saving = true; + + (this as any).api('i/update', { + name: this.name, + location: this.location || null, + description: this.description || null, + birthday: this.birthday || null + }).then(() => { + this.saving = false; + alert('%i18n:mobile.tags.mk-profile-setting.saved%'); + }); + } + } +}); +</script> + +<style lang="stylus" module> +.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/web/app/mobile/views/pages/search.vue b/src/web/app/mobile/views/pages/search.vue new file mode 100644 index 0000000000..b6e114a82b --- /dev/null +++ b/src/web/app/mobile/views/pages/search.vue @@ -0,0 +1,70 @@ +<template> +<mk-ui> + <span slot="header">%fa:search% {{ query }}</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('{}', query) }}</span> + <button v-if="canFetchMore" @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 = 30; + +export default Vue.extend({ + props: ['query'], + data() { + return { + fetching: true, + posts: [], + offset: 0 + }; + }, + mounted() { + document.title = `%i18n:mobile.tags.mk-search-page.search%: ${this.query} | Misskey`; + document.documentElement.style.background = '#313a42'; + + Progress.start(); + + (this as any).api('posts/search', Object.assign({}, parse(this.query), { + limit: limit + })).then(posts => { + this.posts = posts; + this.fetching = false; + Progress.done(); + }); + }, + methods: { + more() { + this.offset += limit; + return (this as any).api('posts/search', Object.assign({}, parse(this.query), { + limit: limit, + offset: this.offset + })); + } + } +}); +</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/web/app/mobile/views/pages/selectdrive.vue b/src/web/app/mobile/views/pages/selectdrive.vue new file mode 100644 index 0000000000..3480a0d103 --- /dev/null +++ b/src/web/app/mobile/views/pages/selectdrive.vue @@ -0,0 +1,96 @@ +<template> +<div class="mk-selectdrive"> + <header> + <h1>%i18n:mobile.tags.mk-selectdrive-page.select-file%<span class="count" v-if="files.length > 0">({{ files.length }})</span></h1> + <button class="upload" @click="upload">%fa:upload%</button> + <button v-if="multiple" class="ok" @click="ok">%fa:check%</button> + </header> + <mk-drive ref="browser" select-file :multiple="multiple" is-naked :top="42"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + files: [] + }; + }, + computed: { + multiple(): boolean { + const q = (new URL(location.toString())).searchParams; + return q.get('multiple') == 'true'; + } + }, + mounted() { + document.title = '%i18n:desktop.tags.mk-selectdrive-page.title%'; + }, + methods: { + onSelected(file) { + this.files = [file]; + this.ok(); + }, + onChangeSelection(files) { + this.files = files; + }, + upload() { + (this.$refs.browser as any).selectLocalFile(); + }, + close() { + window.close(); + }, + ok() { + window.opener.cb(this.multiple ? this.files : this.files[0]); + this.close(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-selectdrive + width 100% + height 100% + background #fff + + > header + position fixed + top 0 + left 0 + width 100% + z-index 1000 + background #fff + box-shadow 0 1px rgba(0, 0, 0, 0.1) + + > h1 + margin 0 + padding 0 + text-align center + line-height 42px + font-size 1em + font-weight normal + + > .count + margin-left 4px + opacity 0.5 + + > .upload + position absolute + top 0 + left 0 + line-height 42px + width 42px + + > .ok + position absolute + top 0 + right 0 + line-height 42px + width 42px + + > .mk-drive + top 42px + +</style> diff --git a/src/web/app/mobile/views/pages/settings.vue b/src/web/app/mobile/views/pages/settings.vue new file mode 100644 index 0000000000..3250999e12 --- /dev/null +++ b/src/web/app/mobile/views/pages/settings.vue @@ -0,0 +1,102 @@ +<template> +<mk-ui> + <span slot="header">%fa:cog%%i18n:mobile.tags.mk-settings-page.settings%</span> + <div :class="$style.content"> + <p v-html="'%i18n:mobile.tags.mk-settings.signed-in-as%'.replace('{}', '<b>' + os.i.name + '</b>')"></p> + <ul> + <li><router-link to="./settings/profile">%fa:user%%i18n:mobile.tags.mk-settings-page.profile%%fa:angle-right%</router-link></li> + <li><router-link to="./settings/authorized-apps">%fa:puzzle-piece%%i18n:mobile.tags.mk-settings-page.applications%%fa:angle-right%</router-link></li> + <li><router-link to="./settings/twitter">%fa:B twitter%%i18n:mobile.tags.mk-settings-page.twitter-integration%%fa:angle-right%</router-link></li> + <li><router-link to="./settings/signin-history">%fa:sign-in-alt%%i18n:mobile.tags.mk-settings-page.signin-history%%fa:angle-right%</router-link></li> + </ul> + <ul> + <li><a @click="signout">%fa:power-off%%i18n:mobile.tags.mk-settings-page.signout%</a></li> + </ul> + <p><small>ver {{ v }} (葵 aoi)</small></p> + </div> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { version } from '../../../config'; + +export default Vue.extend({ + data() { + return { + v: version + }; + }, + mounted() { + document.title = 'Misskey | %i18n:mobile.tags.mk-settings-page.settings%'; + document.documentElement.style.background = '#313a42'; + }, + methods: { + signout() { + (this as any).os.signout(); + } + } +}); +</script> + +<style lang="stylus" module> +.content + + > p + display block + margin 24px + text-align center + color #cad2da + + > ul + $radius = 8px + + display block + margin 16px auto + padding 0 + max-width 500px + width calc(100% - 32px) + list-style none + background #fff + border solid 1px rgba(0, 0, 0, 0.2) + border-radius $radius + + > li + display block + border-bottom solid 1px #ddd + + &:hover + background rgba(0, 0, 0, 0.1) + + &:first-child + border-top-left-radius $radius + border-top-right-radius $radius + + &:last-child + border-bottom-left-radius $radius + border-bottom-right-radius $radius + border-bottom none + + > a + $height = 48px + + display block + position relative + padding 0 16px + line-height $height + color #4d635e + + > [data-fa]:nth-of-type(1) + margin-right 4px + + > [data-fa]:nth-of-type(2) + display block + position absolute + top 0 + right 8px + z-index 1 + padding 0 20px + font-size 1.2em + line-height $height + +</style> diff --git a/src/web/app/mobile/views/pages/signup.vue b/src/web/app/mobile/views/pages/signup.vue new file mode 100644 index 0000000000..9dc07a4b86 --- /dev/null +++ b/src/web/app/mobile/views/pages/signup.vue @@ -0,0 +1,57 @@ +<template> +<div class="signup"> + <h1>Misskeyをはじめる</h1> + <p>いつでも、どこからでもMisskeyを利用できます。もちろん、無料です。</p> + <div class="form"> + <p>新規登録</p> + <div> + <mk-signup/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + mounted() { + document.documentElement.style.background = '#293946'; + } +}); +</script> + +<style lang="stylus" scoped> +.signup + padding 16px + margin 0 auto + max-width 500px + + h1 + margin 0 + padding 8px + font-size 1.5em + font-weight normal + color #c3c6ca + + & + p + margin 0 0 16px 0 + padding 0 8px 0 8px + color #949fa9 + + .form + background #fff + border solid 1px rgba(0, 0, 0, 0.2) + border-radius 8px + overflow hidden + + > p + margin 0 + padding 12px 20px + color #555 + background #f5f5f5 + border-bottom solid 1px #ddd + + > div + padding 16px + +</style> diff --git a/src/web/app/mobile/views/pages/user.vue b/src/web/app/mobile/views/pages/user.vue new file mode 100644 index 0000000000..c9c1c6bfbd --- /dev/null +++ b/src/web/app/mobile/views/pages/user.vue @@ -0,0 +1,240 @@ +<template> +<mk-ui> + <span slot="header" v-if="!fetching">%fa:user% {{ user.name }}</span> + <template slot="funcIcon">%fa:pencil-alt%</template> + <main v-if="!fetching"> + <header> + <div class="banner" :style="user.banner_url ? `background-image: url(${user.banner_url}?thumbnail&size=1024)` : ''"></div> + <div class="body"> + <div class="top"> + <a class="avatar"> + <img :src="`${user.avatar_url}?thumbnail&size=200`" alt="avatar"/> + </a> + <mk-follow-button v-if="os.isSignedIn && os.i.id != user.id" :user="user"/> + </div> + <div class="title"> + <h1>{{ user.name }}</h1> + <span class="username">@{{ user.username }}</span> + <span class="followed" v-if="user.is_followed">%i18n:mobile.tags.mk-user.follows-you%</span> + </div> + <div class="description">{{ user.description }}</div> + <div class="info"> + <p class="location" v-if="user.profile.location"> + %fa:map-marker%{{ user.profile.location }} + </p> + <p class="birthday" v-if="user.profile.birthday"> + %fa:birthday-cake%{{ user.profile.birthday.replace('-', '年').replace('-', '月') + '日' }} ({{ age }}歳) + </p> + </div> + <div class="status"> + <a> + <b>{{ user.posts_count }}</b> + <i>%i18n:mobile.tags.mk-user.posts%</i> + </a> + <a :href="`${user.username}/following`"> + <b>{{ user.following_count }}</b> + <i>%i18n:mobile.tags.mk-user.following%</i> + </a> + <a :href="`${user.username}/followers`"> + <b>{{ user.followers_count }}</b> + <i>%i18n:mobile.tags.mk-user.followers%</i> + </a> + </div> + </div> + <nav> + <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> + </nav> + </header> + <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 Progress from '../../../common/scripts/loading'; +import XHome from './user/home.vue'; + +export default Vue.extend({ + components: { + XHome + }, + props: { + page: { + default: 'home' + } + }, + data() { + return { + fetching: true, + user: null + }; + }, + computed: { + age(): number { + return age(this.user.profile.birthday); + } + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + mounted() { + document.documentElement.style.background = '#313a42'; + }, + methods: { + fetch() { + Progress.start(); + + (this as any).api('users/show', { + username: this.$route.params.user + }).then(user => { + this.user = user; + this.fetching = false; + + Progress.done(); + document.title = user.name + ' | Misskey'; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +main + > header + box-shadow 0 4px 4px rgba(0, 0, 0, 0.3) + + > .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 + 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/web/app/mobile/views/pages/user/home.activity.vue b/src/web/app/mobile/views/pages/user/home.activity.vue new file mode 100644 index 0000000000..87970795b2 --- /dev/null +++ b/src/web/app/mobile/views/pages/user/home.activity.vue @@ -0,0 +1,62 @@ +<template> +<div class="root activity"> + <svg v-if="data" ref="canvas" viewBox="0 0 30 1" preserveAspectRatio="none"> + <g v-for="(d, i) in data"> + <rect width="0.8" :height="d.postsH" + :x="i + 0.1" :y="1 - d.postsH - d.repliesH - d.repostsH" + fill="#41ddde"/> + <rect width="0.8" :height="d.repliesH" + :x="i + 0.1" :y="1 - d.repliesH - d.repostsH" + fill="#f7796c"/> + <rect width="0.8" :height="d.repostsH" + :x="i + 0.1" :y="1 - d.repostsH" + fill="#a1de41"/> + </g> + </svg> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + fetching: true, + data: [], + peak: null + }; + }, + mounted() { + (this as any).api('aggregation/users/activity', { + user_id: this.user.id, + limit: 30 + }).then(data => { + data.forEach(d => d.total = d.posts + d.replies + d.reposts); + this.peak = Math.max.apply(null, data.map(d => d.total)); + data.forEach(d => { + d.postsH = d.posts / this.peak; + d.repliesH = d.replies / this.peak; + d.repostsH = d.reposts / this.peak; + }); + data.reverse(); + this.data = data; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root.activity + max-width 600px + margin 0 auto + + > svg + display block + width 100% + height 80px + + > rect + transform-origin center + +</style> diff --git a/src/web/app/mobile/views/pages/user/home.followers-you-know.vue b/src/web/app/mobile/views/pages/user/home.followers-you-know.vue new file mode 100644 index 0000000000..acefcaa106 --- /dev/null +++ b/src/web/app/mobile/views/pages/user/home.followers-you-know.vue @@ -0,0 +1,62 @@ +<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="`/${user.username}`"> + <img :src="`${user.avatar_url}?thumbnail&size=64`" :alt="user.name"/> + </a> + </div> + <p class="empty" v-if="!fetching && users.length == 0">%i18n:mobile.tags.mk-user-overview-followers-you-know.no-users%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + fetching: true, + users: [] + }; + }, + mounted() { + (this as any).api('users/followers', { + user_id: this.user.id, + iknow: true, + limit: 30 + }).then(res => { + this.fetching = false; + this.users = res.users; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root.followers-you-know + + > div + padding 4px + + > a + display inline-block + margin 4px + + > img + width 48px + height 48px + vertical-align bottom + border-radius 100% + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +</style> diff --git a/src/web/app/mobile/views/pages/user/home.friends.vue b/src/web/app/mobile/views/pages/user/home.friends.vue new file mode 100644 index 0000000000..b37f1a2fe8 --- /dev/null +++ b/src/web/app/mobile/views/pages/user/home.friends.vue @@ -0,0 +1,54 @@ +<template> +<div class="root friends"> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-frequently-replied-users.loading%<mk-ellipsis/></p> + <div v-if="!fetching && users.length > 0"> + <mk-user-card v-for="user in users" :key="user.id" :user="user"/> + </div> + <p class="empty" v-if="!fetching && users.length == 0">%i18n:mobile.tags.mk-user-overview-frequently-replied-users.no-users%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + fetching: true, + users: [] + }; + }, + mounted() { + (this as any).api('users/get_frequently_replied_users', { + user_id: this.user.id + }).then(res => { + this.users = res.map(x => x.user); + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root.friends + > div + overflow-x scroll + -webkit-overflow-scrolling touch + white-space nowrap + padding 8px + + > .mk-user-card + &:not(:last-child) + margin-right 8px + + > .fetching + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +</style> diff --git a/src/web/app/mobile/views/pages/user/home.photos.vue b/src/web/app/mobile/views/pages/user/home.photos.vue new file mode 100644 index 0000000000..2a6343189a --- /dev/null +++ b/src/web/app/mobile/views/pages/user/home.photos.vue @@ -0,0 +1,78 @@ +<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" :key="image.id" + class="img" + :style="`background-image: url(${image.media.url}?thumbnail&size=256)`" + :href="`/${image.post.user.username}/${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'; +export default Vue.extend({ + props: ['user'], + data() { + return { + fetching: true, + images: [] + }; + }, + mounted() { + (this as any).api('users/posts', { + user_id: this.user.id, + with_media: true, + limit: 6 + }).then(posts => { + posts.forEach(post => { + post.media.forEach(media => { + if (this.images.length < 9) this.images.push({ + post, + media + }); + }); + }); + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root.photos + + > .stream + display -webkit-flex + display -moz-flex + display -ms-flex + display flex + justify-content center + flex-wrap wrap + padding 8px + + > .img + flex 1 1 33% + width 33% + height 80px + background-position center center + background-size cover + background-clip content-box + border solid 2px transparent + border-radius 4px + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +</style> + diff --git a/src/web/app/mobile/views/pages/user/home.posts.vue b/src/web/app/mobile/views/pages/user/home.posts.vue new file mode 100644 index 0000000000..70b20ce943 --- /dev/null +++ b/src/web/app/mobile/views/pages/user/home.posts.vue @@ -0,0 +1,57 @@ +<template> +<div class="root posts"> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-posts.loading%<mk-ellipsis/></p> + <div v-if="!fetching && posts.length > 0"> + <mk-post-card v-for="post in posts" :key="post.id" :post="post"/> + </div> + <p class="empty" v-if="!fetching && posts.length == 0">%i18n:mobile.tags.mk-user-overview-posts.no-posts%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + fetching: true, + posts: [] + }; + }, + mounted() { + (this as any).api('users/posts', { + user_id: this.user.id + }).then(posts => { + this.posts = posts; + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root.posts + + > div + overflow-x scroll + -webkit-overflow-scrolling touch + white-space nowrap + padding 8px + + > * + vertical-align top + + &:not(:last-child) + margin-right 8px + + > .fetching + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +</style> diff --git a/src/web/app/mobile/views/pages/user/home.vue b/src/web/app/mobile/views/pages/user/home.vue new file mode 100644 index 0000000000..040b916ca2 --- /dev/null +++ b/src/web/app/mobile/views/pages/user/home.vue @@ -0,0 +1,96 @@ +<template> +<div class="root home"> + <mk-post-detail v-if="user.pinned_post" :post="user.pinned_post" compact/> + <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> + <x-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>%i18n:mobile.tags.mk-user-overview.last-used-at%: <b><mk-time :time="user.last_used_at"/></b></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPosts from './home.posts.vue'; +import XPhotos from './home.photos.vue'; +import XFriends from './home.friends.vue'; +import XFollowersYouKnow from './home.followers-you-know.vue'; +import XActivity from './home.activity.vue'; + +export default Vue.extend({ + components: { + XPosts, + XPhotos, + XFriends, + XFollowersYouKnow, + XActivity + }, + 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/web/app/mobile/views/pages/welcome.vue b/src/web/app/mobile/views/pages/welcome.vue new file mode 100644 index 0000000000..84e5ae5507 --- /dev/null +++ b/src/web/app/mobile/views/pages/welcome.vue @@ -0,0 +1,146 @@ +<template> +<div class="welcome"> + <h1><b>Misskey</b>へようこそ</h1> + <p>Twitter風ミニブログSNS、Misskeyへようこそ。思ったことを投稿したり、タイムラインでみんなの投稿を読むこともできます。</p> + <div class="form"> + <p>ログイン</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.two_factor_enabled" v-model="token" type="number" placeholder="トークン" required/> + <button type="submit" :disabled="signing">{{ signing ? 'ログインしています' : 'ログイン' }}</button> + </form> + <div> + <a :href="`${apiUrl}/signin/twitter`">Twitterでログイン</a> + </div> + </div> + </div> + <a href="/signup">アカウントを作成する</a> +</div> +</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 + }; + }, + mounted() { + document.documentElement.style.background = '#293946'; + }, + 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.two_factor_enabled ? this.token : undefined + }).then(() => { + location.reload(); + }).catch(() => { + alert('something happened'); + this.signing = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.welcome + padding 16px + margin 0 auto + max-width 500px + + h1 + margin 0 + padding 8px + font-size 1.5em + font-weight normal + color #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 + + & + a + display block + margin-top 16px + text-align center + + > 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 + +</style> diff --git a/src/web/app/stats/tags/index.tag b/src/web/app/stats/tags/index.tag index 4b5415b2fd..4b167ccbc8 100644 --- a/src/web/app/stats/tags/index.tag +++ b/src/web/app/stats/tags/index.tag @@ -1,11 +1,11 @@ <mk-index> <h1>Misskey<i>Statistics</i></h1> - <main if={ !initializing }> + <main v-if="!initializing"> <mk-users stats={ stats }/> <mk-posts stats={ stats }/> </main> <footer><a href={ _URL_ }>{ _HOST_ }</a></footer> - <style> + <style lang="stylus" scoped> :scope display block margin 0 auto @@ -40,13 +40,13 @@ > a color #546567 </style> - <script> + <script lang="typescript"> this.mixin('api'); this.initializing = true; this.on('mount', () => { - this.api('stats').then(stats => { + this.$root.$data.os.api('stats').then(stats => { this.update({ initializing: false, stats @@ -58,19 +58,19 @@ <mk-posts> <h2>%i18n:stats.posts-count% <b>{ stats.posts_count }</b></h2> - <mk-posts-chart if={ !initializing } data={ data }/> - <style> + <mk-posts-chart v-if="!initializing" data={ data }/> + <style lang="stylus" scoped> :scope display block </style> - <script> + <script lang="typescript"> this.mixin('api'); this.initializing = true; this.stats = this.opts.stats; this.on('mount', () => { - this.api('aggregation/posts', { + this.$root.$data.os.api('aggregation/posts', { limit: 365 }).then(data => { this.update({ @@ -84,19 +84,19 @@ <mk-users> <h2>%i18n:stats.users-count% <b>{ stats.users_count }</b></h2> - <mk-users-chart if={ !initializing } data={ data }/> - <style> + <mk-users-chart v-if="!initializing" data={ data }/> + <style lang="stylus" scoped> :scope display block </style> - <script> + <script lang="typescript"> this.mixin('api'); this.initializing = true; this.stats = this.opts.stats; this.on('mount', () => { - this.api('aggregation/users', { + this.$root.$data.os.api('aggregation/users', { limit: 365 }).then(data => { this.update({ @@ -133,7 +133,7 @@ stroke="#555" stroke-dasharray="2 2"/> </svg> - <style> + <style lang="stylus" scoped> :scope display block @@ -142,7 +142,7 @@ padding 1px width 100% </style> - <script> + <script lang="typescript"> this.viewBoxX = 365; this.viewBoxY = 80; @@ -178,7 +178,7 @@ stroke-width="1" stroke="#555"/> </svg> - <style> + <style lang="stylus" scoped> :scope display block @@ -187,7 +187,7 @@ padding 1px width 100% </style> - <script> + <script lang="typescript"> this.viewBoxX = 365; this.viewBoxY = 80; diff --git a/src/web/app/status/tags/index.tag b/src/web/app/status/tags/index.tag index dcadc66172..899467097a 100644 --- a/src/web/app/status/tags/index.tag +++ b/src/web/app/status/tags/index.tag @@ -6,7 +6,7 @@ <mk-mem-usage connection={ connection }/> </main> <footer><a href={ _URL_ }>{ _HOST_ }</a></footer> - <style> + <style lang="stylus" scoped> :scope display block margin 0 auto @@ -50,7 +50,7 @@ > a color #546567 </style> - <script> + <script lang="typescript"> import Connection from '../../common/scripts/streaming/server-stream'; this.mixin('api'); @@ -59,7 +59,7 @@ this.connection = new Connection(); this.on('mount', () => { - this.api('meta').then(meta => { + this.$root.$data.os.api('meta').then(meta => { this.update({ initializing: false, meta @@ -77,11 +77,11 @@ <mk-cpu-usage> <h2>CPU <b>{ percentage }%</b></h2> <mk-line-chart ref="chart"/> - <style> + <style lang="stylus" scoped> :scope display block </style> - <script> + <script lang="typescript"> this.connection = this.opts.connection; this.on('mount', () => { @@ -93,7 +93,7 @@ }); this.onStats = stats => { - this.refs.chart.addData(1 - stats.cpu_usage); + this.$refs.chart.addData(1 - stats.cpu_usage); const percentage = (stats.cpu_usage * 100).toFixed(0); @@ -107,11 +107,11 @@ <mk-mem-usage> <h2>MEM <b>{ percentage }%</b></h2> <mk-line-chart ref="chart"/> - <style> + <style lang="stylus" scoped> :scope display block </style> - <script> + <script lang="typescript"> this.connection = this.opts.connection; this.on('mount', () => { @@ -124,7 +124,7 @@ this.onStats = stats => { stats.mem.used = stats.mem.total - stats.mem.free; - this.refs.chart.addData(1 - (stats.mem.used / stats.mem.total)); + this.$refs.chart.addData(1 - (stats.mem.used / stats.mem.total)); const percentage = (stats.mem.used / stats.mem.total * 100).toFixed(0); @@ -164,7 +164,7 @@ stroke="#f43b16" stroke-width="0.5"/> </svg> - <style> + <style lang="stylus" scoped> :scope display block padding 16px @@ -176,7 +176,7 @@ padding 1px width 100% </style> - <script> + <script lang="typescript"> import uuid from 'uuid'; this.viewBoxX = 100; diff --git a/src/tsconfig.json b/src/web/app/tsconfig.json similarity index 86% rename from src/tsconfig.json rename to src/web/app/tsconfig.json index 36600eed2b..e31b52dab1 100644 --- a/src/tsconfig.json +++ b/src/web/app/tsconfig.json @@ -12,13 +12,12 @@ "target": "es2017", "module": "commonjs", "removeComments": false, - "noLib": false + "noLib": false, + "strict": true, + "strictNullChecks": false }, "compileOnSave": false, "include": [ "./**/*.ts" - ], - "exclude": [ - "./web/app/**/*.ts" ] } diff --git a/src/web/app/v.d.ts b/src/web/app/v.d.ts new file mode 100644 index 0000000000..8f3a240d80 --- /dev/null +++ b/src/web/app/v.d.ts @@ -0,0 +1,4 @@ +declare module "*.vue" { + import Vue from 'vue'; + export default Vue; +} diff --git a/tsconfig.json b/tsconfig.json index a38ff220b2..9d26429c51 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,10 +12,15 @@ "target": "es2017", "module": "commonjs", "removeComments": false, - "noLib": false + "noLib": false, + "strict": true, + "strictNullChecks": false }, "compileOnSave": false, "include": [ - "./gulpfile.ts" + "./src/**/*.ts" + ], + "exclude": [ + "./src/web/app/**/*.ts" ] } diff --git a/webpack/loaders/replace.js b/webpack/loaders/replace.js new file mode 100644 index 0000000000..03cf1fcd78 --- /dev/null +++ b/webpack/loaders/replace.js @@ -0,0 +1,18 @@ +const loaderUtils = require('loader-utils'); + +function trim(text, g) { + return text.substring(1, text.length - (g ? 2 : 0)); +} + +module.exports = function(src) { + this.cacheable(); + const options = loaderUtils.getOptions(this); + const search = options.search; + const g = search[search.length - 1] == 'g'; + const replace = global[options.replace]; + if (typeof search != 'string' || search.length == 0) console.error('invalid search'); + if (typeof replace != 'function') console.error('invalid replacer:', replace, this.request); + src = src.replace(new RegExp(trim(search, g), g ? 'g' : ''), replace); + this.callback(null, src); + return src; +}; diff --git a/webpack/module/index.ts b/webpack/module/index.ts deleted file mode 100644 index 088aca7238..0000000000 --- a/webpack/module/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import rules from './rules'; - -export default lang => ({ - rules: rules(lang) -}); diff --git a/webpack/module/rules/base64.ts b/webpack/module/rules/base64.ts deleted file mode 100644 index 529816bd20..0000000000 --- a/webpack/module/rules/base64.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Replace base64 symbols - */ - -import * as fs from 'fs'; -const StringReplacePlugin = require('string-replace-webpack-plugin'); - -export default () => ({ - enforce: 'pre', - test: /\.(tag|js)$/, - exclude: /node_modules/, - loader: StringReplacePlugin.replace({ - replacements: [{ - pattern: /%base64:(.+?)%/g, replacement: (_, key) => { - return fs.readFileSync(__dirname + '/../../../src/web/' + key, 'base64'); - } - }] - }) -}); diff --git a/webpack/module/rules/collapse-spaces.ts b/webpack/module/rules/collapse-spaces.ts new file mode 100644 index 0000000000..734c735926 --- /dev/null +++ b/webpack/module/rules/collapse-spaces.ts @@ -0,0 +1,19 @@ +import * as fs from 'fs'; +const minify = require('html-minifier').minify; + +export default () => ({ + enforce: 'pre', + test: /\.vue$/, + exclude: /node_modules/, + loader: 'string-replace-loader', + query: { + search: /^<template>([\s\S]+?)\r?\n<\/template>/, + replace: html => { + return minify(html, { + collapseWhitespace: true, + collapseInlineTagWhitespace: true, + keepClosingSlash: true + }); + } + } +}); diff --git a/webpack/module/rules/fa.ts b/webpack/module/rules/fa.ts deleted file mode 100644 index 891b78ece2..0000000000 --- a/webpack/module/rules/fa.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Replace fontawesome symbols - */ - -const StringReplacePlugin = require('string-replace-webpack-plugin'); -import { pattern, replacement } from '../../../src/common/build/fa'; - -export default () => ({ - enforce: 'pre', - test: /\.(tag|js|ts)$/, - exclude: /node_modules/, - loader: StringReplacePlugin.replace({ - replacements: [{ - pattern, replacement - }] - }) -}); diff --git a/webpack/module/rules/i18n.ts b/webpack/module/rules/i18n.ts deleted file mode 100644 index 7261548be5..0000000000 --- a/webpack/module/rules/i18n.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Replace i18n texts - */ - -const StringReplacePlugin = require('string-replace-webpack-plugin'); -import Replacer from '../../../src/common/build/i18n'; - -export default lang => { - const replacer = new Replacer(lang); - - return { - enforce: 'pre', - test: /\.(tag|js|ts)$/, - exclude: /node_modules/, - loader: StringReplacePlugin.replace({ - replacements: [{ - pattern: replacer.pattern, replacement: replacer.replacement - }] - }) - }; -}; diff --git a/webpack/module/rules/index.ts b/webpack/module/rules/index.ts deleted file mode 100644 index b02bdef723..0000000000 --- a/webpack/module/rules/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import i18n from './i18n'; -import license from './license'; -import fa from './fa'; -import base64 from './base64'; -import themeColor from './theme-color'; -import tag from './tag'; -import stylus from './stylus'; -import typescript from './typescript'; - -export default lang => [ - i18n(lang), - license(), - fa(), - base64(), - themeColor(), - tag(), - stylus(), - typescript() -]; diff --git a/webpack/module/rules/license.ts b/webpack/module/rules/license.ts deleted file mode 100644 index de8b7d79fb..0000000000 --- a/webpack/module/rules/license.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Inject license - */ - -const StringReplacePlugin = require('string-replace-webpack-plugin'); -import { licenseHtml } from '../../../src/common/build/license'; - -export default () => ({ - enforce: 'pre', - test: /\.(tag|js)$/, - exclude: /node_modules/, - loader: StringReplacePlugin.replace({ - replacements: [{ - pattern: '%license%', replacement: () => licenseHtml - }] - }) -}); diff --git a/webpack/module/rules/stylus.ts b/webpack/module/rules/stylus.ts deleted file mode 100644 index dd1e4c3218..0000000000 --- a/webpack/module/rules/stylus.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Stylus support - */ - -export default () => ({ - test: /\.styl$/, - exclude: /node_modules/, - use: [ - { loader: 'style-loader' }, - { loader: 'css-loader' }, - { loader: 'stylus-loader' } - ] -}); diff --git a/webpack/module/rules/tag.ts b/webpack/module/rules/tag.ts deleted file mode 100644 index 706af35b40..0000000000 --- a/webpack/module/rules/tag.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Riot tags - */ - -export default () => ({ - test: /\.tag$/, - exclude: /node_modules/, - loader: 'riot-tag-loader', - query: { - hot: false, - style: 'stylus', - expr: false, - compact: true, - parserOptions: { - style: { - compress: true - } - } - } -}); diff --git a/webpack/module/rules/theme-color.ts b/webpack/module/rules/theme-color.ts deleted file mode 100644 index 7ee545191c..0000000000 --- a/webpack/module/rules/theme-color.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Theme color provider - */ - -const StringReplacePlugin = require('string-replace-webpack-plugin'); - -const constants = require('../../../src/const.json'); - -export default () => ({ - enforce: 'pre', - test: /\.tag$/, - exclude: /node_modules/, - loader: StringReplacePlugin.replace({ - replacements: [ - { - pattern: /\$theme\-color\-foreground/g, - replacement: () => constants.themeColorForeground - }, - { - pattern: /\$theme\-color/g, - replacement: () => constants.themeColor - }, - ] - }) -}); diff --git a/webpack/module/rules/typescript.ts b/webpack/module/rules/typescript.ts deleted file mode 100644 index eb2b279a55..0000000000 --- a/webpack/module/rules/typescript.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * TypeScript - */ - -export default () => ({ - test: /\.ts$/, - use: 'awesome-typescript-loader' -}); diff --git a/webpack/plugins/banner.ts b/webpack/plugins/banner.ts deleted file mode 100644 index a8774e0a39..0000000000 --- a/webpack/plugins/banner.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as os from 'os'; -import * as webpack from 'webpack'; - -export default version => new webpack.BannerPlugin({ - banner: - `Misskey v${version} | MIT Licensed, (c) syuilo 2014-2018\n` + - 'https://github.com/syuilo/misskey\n' + - `built by ${os.hostname()} at ${new Date()}\n` + - 'hash:[hash], chunkhash:[chunkhash]' -}); diff --git a/webpack/plugins/consts.ts b/webpack/plugins/consts.ts index 16a5691622..a01c18af6f 100644 --- a/webpack/plugins/consts.ts +++ b/webpack/plugins/consts.ts @@ -7,6 +7,7 @@ import * as webpack from 'webpack'; import version from '../../src/version'; const constants = require('../../src/const.json'); import config from '../../src/conf'; +import { licenseHtml } from '../../src/common/build/license'; export default lang => { const consts = { @@ -24,6 +25,7 @@ export default lang => { _LANG_: lang, _HOST_: config.host, _URL_: config.url, + _LICENSE_: licenseHtml }; const _consts = {}; @@ -32,7 +34,5 @@ export default lang => { _consts[key] = JSON.stringify(consts[key]); }); - return new webpack.DefinePlugin(Object.assign({}, _consts, { - __CONSTS__: JSON.stringify(consts) - })); + return new webpack.DefinePlugin(_consts); }; diff --git a/webpack/plugins/index.ts b/webpack/plugins/index.ts index 9850db485c..027f60224f 100644 --- a/webpack/plugins/index.ts +++ b/webpack/plugins/index.ts @@ -1,25 +1,22 @@ -const StringReplacePlugin = require('string-replace-webpack-plugin'); +const HardSourceWebpackPlugin = require('hard-source-webpack-plugin'); import consts from './consts'; import hoist from './hoist'; import minify from './minify'; -import banner from './banner'; const env = process.env.NODE_ENV; const isProduction = env === 'production'; export default (version, lang) => { const plugins = [ - consts(lang), - new StringReplacePlugin(), - hoist() + //new HardSourceWebpackPlugin(), + consts(lang) ]; if (isProduction) { + plugins.push(hoist()); plugins.push(minify()); } - plugins.push(banner(version)); - return plugins; }; diff --git a/webpack/webpack.config.ts b/webpack/webpack.config.ts index d67b8ef774..76d2980788 100644 --- a/webpack/webpack.config.ts +++ b/webpack/webpack.config.ts @@ -2,12 +2,31 @@ * webpack configuration */ -import module_ from './module'; +import * as fs from 'fs'; +const minify = require('html-minifier').minify; +import I18nReplacer from '../src/common/build/i18n'; +import { pattern as faPattern, replacement as faReplacement } from '../src/common/build/fa'; +const constants = require('../src/const.json'); + import plugins from './plugins'; import langs from '../locales'; import version from '../src/version'; +global['faReplacement'] = faReplacement; + +global['collapseSpacesReplacement'] = html => { + return minify(html, { + collapseWhitespace: true, + collapseInlineTagWhitespace: true, + keepClosingSlash: true + }).replace(/\t/g, ''); +}; + +global['base64replacement'] = (_, key) => { + return fs.readFileSync(__dirname + '/../src/web/' + key, 'base64'); +}; + module.exports = Object.keys(langs).map(lang => { // Chunk name const name = lang; @@ -16,11 +35,11 @@ module.exports = Object.keys(langs).map(lang => { const entry = { desktop: './src/web/app/desktop/script.ts', mobile: './src/web/app/mobile/script.ts', - ch: './src/web/app/ch/script.ts', - stats: './src/web/app/stats/script.ts', - status: './src/web/app/status/script.ts', - dev: './src/web/app/dev/script.ts', - auth: './src/web/app/auth/script.ts', + //ch: './src/web/app/ch/script.ts', + //stats: './src/web/app/stats/script.ts', + //status: './src/web/app/status/script.ts', + //dev: './src/web/app/dev/script.ts', + //auth: './src/web/app/auth/script.ts', sw: './src/web/app/sw.js' }; @@ -29,16 +48,109 @@ module.exports = Object.keys(langs).map(lang => { filename: `[name].${version}.${lang}.js` }; + const i18nReplacer = new I18nReplacer(lang); + global['i18nReplacement'] = i18nReplacer.replacement; + return { name, entry, - module: module_(lang), + module: { + rules: [{ + test: /\.vue$/, + exclude: /node_modules/, + use: [/*'cache-loader', */{ + loader: 'vue-loader', + options: { + cssSourceMap: false, + preserveWhitespace: false + } + }, { + loader: 'replace', + query: { + search: /%base64:(.+?)%/g.toString(), + replace: 'base64replacement' + } + }, { + loader: 'webpack-replace-loader', + options: { + search: '$theme-color', + replace: constants.themeColor, + attr: 'g' + } + }, { + loader: 'webpack-replace-loader', + query: { + search: '$theme-color-foreground', + replace: constants.themeColorForeground, + attr: 'g' + } + }, { + loader: 'replace', + query: { + search: i18nReplacer.pattern.toString(), + replace: 'i18nReplacement' + } + }, { + loader: 'replace', + query: { + search: faPattern.toString(), + replace: 'faReplacement' + } + }, { + loader: 'replace', + query: { + search: /^<template>([\s\S]+?)\r?\n<\/template>/.toString(), + replace: 'collapseSpacesReplacement' + } + }] + }, { + test: /\.styl$/, + exclude: /node_modules/, + use: [ + { loader: 'style-loader' }, + { loader: 'css-loader' }, + { loader: 'stylus-loader' } + ] + }, { + test: /\.css$/, + use: [ + { loader: 'style-loader' }, + { loader: 'css-loader' } + ] + }, { + test: /\.ts$/, + exclude: /node_modules/, + use: [{ + loader: 'ts-loader', + options: { + configFile: __dirname + '/../src/web/app/tsconfig.json', + appendTsSuffixTo: [/\.vue$/] + } + }, { + loader: 'replace', + query: { + search: i18nReplacer.pattern.toString(), + replace: 'i18nReplacement' + } + }, { + loader: 'replace', + query: { + search: faPattern.toString(), + replace: 'faReplacement' + } + }] + }] + }, plugins: plugins(version, lang), output, resolve: { extensions: [ '.js', '.ts' ] - } + }, + resolveLoader: { + modules: ['node_modules', './webpack/loaders'] + }, + cache: true }; });