1
0
mirror of https://github.com/MisskeyIO/misskey synced 2024-11-23 22:56:49 +09:00

Merge pull request #1116 from syuilo/vue-#972

Migrate to Vue
This commit is contained in:
syuilo 2018-02-23 02:13:40 +09:00 committed by GitHub
commit d178584828
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
463 changed files with 25569 additions and 25411 deletions

19
.eslintrc Normal file
View File

@ -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
}
}

2
.gitattributes vendored
View File

@ -1,5 +1,3 @@
*.svg -diff -text *.svg -diff -text
*.psd -diff -text *.psd -diff -text
*.ai -diff -text *.ai -diff -text
*.tag linguist-language=HTML

View File

@ -56,7 +56,7 @@ gulp.task('build:js', () =>
); );
gulp.task('build:ts', () => { gulp.task('build:ts', () => {
const tsProject = ts.createProject('./src/tsconfig.json'); const tsProject = ts.createProject('./tsconfig.json');
return tsProject return tsProject
.src() .src()

View File

@ -11,7 +11,7 @@ const loadLang = lang => yaml.safeLoad(
const native = loadLang('ja'); const native = loadLang('ja');
const langs = { const langs = {
'en': loadLang('en'), //'en': loadLang('en'),
'ja': native 'ja': native
}; };

View File

@ -81,9 +81,9 @@
"accesses": "2.5.0", "accesses": "2.5.0",
"animejs": "2.2.0", "animejs": "2.2.0",
"autwh": "0.0.1", "autwh": "0.0.1",
"awesome-typescript-loader": "3.4.1",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"body-parser": "1.18.2", "body-parser": "1.18.2",
"cache-loader": "^1.2.0",
"cafy": "3.2.1", "cafy": "3.2.1",
"chai": "4.1.2", "chai": "4.1.2",
"chai-http": "3.0.0", "chai-http": "3.0.0",
@ -99,6 +99,8 @@
"diskusage": "0.2.4", "diskusage": "0.2.4",
"elasticsearch": "14.1.0", "elasticsearch": "14.1.0",
"escape-regexp": "0.0.1", "escape-regexp": "0.0.1",
"eslint": "^4.18.0",
"eslint-plugin-vue": "^4.2.2",
"eventemitter3": "3.0.0", "eventemitter3": "3.0.0",
"exif-js": "2.3.0", "exif-js": "2.3.0",
"express": "4.16.2", "express": "4.16.2",
@ -118,12 +120,15 @@
"gulp-typescript": "3.2.4", "gulp-typescript": "3.2.4",
"gulp-uglify": "3.0.0", "gulp-uglify": "3.0.0",
"gulp-util": "3.0.8", "gulp-util": "3.0.8",
"hard-source-webpack-plugin": "0.6.0-alpha.8",
"highlight.js": "9.12.0", "highlight.js": "9.12.0",
"html-minifier": "^3.5.9",
"inquirer": "5.0.1", "inquirer": "5.0.1",
"is-root": "1.0.0", "is-root": "1.0.0",
"is-url": "1.2.2", "is-url": "1.2.2",
"js-yaml": "3.10.0", "js-yaml": "3.10.0",
"license-checker": "16.0.0", "license-checker": "16.0.0",
"loader-utils": "^1.1.0",
"mecab-async": "0.1.2", "mecab-async": "0.1.2",
"mkdirp": "0.5.1", "mkdirp": "0.5.1",
"mocha": "5.0.0", "mocha": "5.0.0",
@ -145,6 +150,7 @@
"recaptcha-promise": "0.1.3", "recaptcha-promise": "0.1.3",
"reconnecting-websocket": "3.2.2", "reconnecting-websocket": "3.2.2",
"redis": "2.8.0", "redis": "2.8.0",
"replace-string-loader": "0.0.7",
"request": "2.83.0", "request": "2.83.0",
"rimraf": "2.6.2", "rimraf": "2.6.2",
"riot": "3.8.1", "riot": "3.8.1",
@ -155,6 +161,7 @@
"serve-favicon": "2.4.5", "serve-favicon": "2.4.5",
"sortablejs": "1.7.0", "sortablejs": "1.7.0",
"speakeasy": "2.0.0", "speakeasy": "2.0.0",
"string-replace-loader": "^1.3.0",
"string-replace-webpack-plugin": "0.1.3", "string-replace-webpack-plugin": "0.1.3",
"style-loader": "0.20.1", "style-loader": "0.20.1",
"stylus": "0.54.5", "stylus": "0.54.5",
@ -165,15 +172,25 @@
"tcp-port-used": "0.1.2", "tcp-port-used": "0.1.2",
"textarea-caret": "3.0.2", "textarea-caret": "3.0.2",
"tmp": "0.0.33", "tmp": "0.0.33",
"ts-loader": "^3.5.0",
"ts-node": "4.1.0", "ts-node": "4.1.0",
"tslint": "5.9.1", "tslint": "5.9.1",
"typescript": "2.7.1", "typescript": "2.7.1",
"typescript-eslint-parser": "^13.0.0",
"uglify-es": "3.3.9", "uglify-es": "3.3.9",
"uglifyjs-webpack-plugin": "1.1.8", "uglifyjs-webpack-plugin": "1.1.8",
"uuid": "3.2.1", "uuid": "3.2.1",
"vhost": "3.0.2", "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", "web-push": "3.2.5",
"webpack": "3.10.0", "webpack": "3.10.0",
"webpack-replace-loader": "^1.3.0",
"websocket": "1.0.25", "websocket": "1.0.25",
"xev": "2.0.0" "xev": "2.0.0"
} }

View File

@ -305,7 +305,7 @@ class TlContext extends Context {
private async getTl() { private async getTl() {
const tl = await require('../endpoints/posts/timeline')({ const tl = await require('../endpoints/posts/timeline')({
limit: 5, limit: 5,
max_id: this.next ? this.next : undefined until_id: this.next ? this.next : undefined
}, this.bot.user); }, this.bot.user);
if (tl.length > 0) { if (tl.length > 0) {
@ -357,7 +357,7 @@ class NotificationsContext extends Context {
private async getNotifications() { private async getNotifications() {
const notifications = await require('../endpoints/i/notifications')({ const notifications = await require('../endpoints/i/notifications')({
limit: 5, limit: 5,
max_id: this.next ? this.next : undefined until_id: this.next ? this.next : undefined
}, this.bot.user); }, this.bot.user);
if (notifications.length > 0) { if (notifications.length > 0) {

View File

@ -194,6 +194,11 @@ const endpoints: Endpoint[] = [
withCredential: true, withCredential: true,
secure: true secure: true
}, },
{
name: 'i/update_client_setting',
withCredential: true,
secure: true
},
{ {
name: 'i/pin', name: 'i/pin',
kind: 'account-write' kind: 'account-write'

View File

@ -46,19 +46,13 @@ module.exports = async (params, user, _, isSecure) => new Promise(async (res, re
if (bannerIdErr) return rej('invalid banner_id param'); if (bannerIdErr) return rej('invalid banner_id param');
if (bannerId) user.banner_id = bannerId; 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, { await User.update(user._id, {
$set: { $set: {
name: user.name, name: user.name,
description: user.description, description: user.description,
avatar_id: user.avatar_id, avatar_id: user.avatar_id,
banner_id: user.banner_id, banner_id: user.banner_id,
profile: user.profile, profile: user.profile
'client_settings.show_donation': user.client_settings.show_donation
} }
}); });

View File

@ -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);
});

View File

@ -15,7 +15,7 @@ const home = {
'profile', 'profile',
'calendar', 'calendar',
'activity', 'activity',
'rss-reader', 'rss',
'trends', 'trends',
'photo-stream', 'photo-stream',
'version' 'version'
@ -23,8 +23,8 @@ const home = {
right: [ right: [
'broadcast', 'broadcast',
'notifications', 'notifications',
'user-recommendation', 'users',
'recommended-polls', 'polls',
'server', 'server',
'donation', 'donation',
'nav', 'nav',

View File

@ -17,7 +17,14 @@ export default class Replacer {
} }
private get(key: string) { 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 // Check the key existance
const error = key.split('.').some(k => { const error = key.split('.').some(k => {

3
src/web/app/app.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<router-view id="app"></router-view>
</template>

View File

@ -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>

View File

@ -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>

View File

@ -1,2 +0,0 @@
require('./index.tag');
require('./form.tag');

View File

@ -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>

View File

@ -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>

View File

@ -1,12 +1,12 @@
<mk-channel> <mk-channel>
<mk-header/> <mk-header/>
<hr> <hr>
<main if={ !fetching }> <main v-if="!fetching">
<h1>{ channel.title }</h1> <h1>{ channel.title }</h1>
<div if={ SIGNIN }> <div v-if="$root.$data.os.isSignedIn">
<p if={ channel.is_watching }>このチャンネルをウォッチしています <a onclick={ unwatch }>ウォッチ解除</a></p> <p v-if="channel.is_watching">このチャンネルをウォッチしています <a @click="unwatch">ウォッチ解除</a></p>
<p if={ !channel.is_watching }><a onclick={ watch }>このチャンネルをウォッチする</a></p> <p v-if="!channel.is_watching"><a @click="watch">このチャンネルをウォッチする</a></p>
</div> </div>
<div class="share"> <div class="share">
@ -15,17 +15,17 @@
</div> </div>
<div class="body"> <div class="body">
<p if={ postsFetching }>読み込み中<mk-ellipsis/></p> <p v-if="postsFetching">読み込み中<mk-ellipsis/></p>
<div if={ !postsFetching }> <div v-if="!postsFetching">
<p if={ posts == null || posts.length == 0 }>まだ投稿がありません</p> <p v-if="posts == null || posts.length == 0">まだ投稿がありません</p>
<virtual if={ posts != null }> <template v-if="posts != null">
<mk-channel-post each={ post in posts.slice().reverse() } post={ post } form={ parent.refs.form }/> <mk-channel-post each={ post in posts.slice().reverse() } post={ post } form={ parent.refs.form }/>
</virtual> </template>
</div> </div>
</div> </div>
<hr> <hr>
<mk-channel-form if={ SIGNIN } channel={ channel } ref="form"/> <mk-channel-form v-if="$root.$data.os.isSignedIn" channel={ channel } ref="form"/>
<div if={ !SIGNIN }> <div v-if="!$root.$data.os.isSignedIn">
<p>参加するには<a href={ _URL_ }>ログインまたは新規登録</a>してください</p> <p>参加するには<a href={ _URL_ }>ログインまたは新規登録</a>してください</p>
</div> </div>
<hr> <hr>
@ -33,7 +33,7 @@
<small><a href={ _URL_ }>Misskey</a> ver { _VERSION_ } (葵 aoi)</small> <small><a href={ _URL_ }>Misskey</a> ver { _VERSION_ } (葵 aoi)</small>
</footer> </footer>
</main> </main>
<style> <style lang="stylus" scoped>
:scope :scope
display block display block
@ -53,7 +53,7 @@
max-width 500px max-width 500px
</style> </style>
<script> <script lang="typescript">
import Progress from '../../common/scripts/loading'; import Progress from '../../common/scripts/loading';
import ChannelStream from '../../common/scripts/streaming/channel-stream'; import ChannelStream from '../../common/scripts/streaming/channel-stream';
@ -76,7 +76,7 @@
let fetched = false; let fetched = false;
// チャンネル概要読み込み // チャンネル概要読み込み
this.api('channels/show', { this.$root.$data.os.api('channels/show', {
channel_id: this.id channel_id: this.id
}).then(channel => { }).then(channel => {
if (fetched) { if (fetched) {
@ -95,7 +95,7 @@
}); });
// 投稿読み込み // 投稿読み込み
this.api('channels/posts', { this.$root.$data.os.api('channels/posts', {
channel_id: this.id channel_id: this.id
}).then(posts => { }).then(posts => {
if (fetched) { if (fetched) {
@ -125,7 +125,7 @@
this.posts.unshift(post); this.posts.unshift(post);
this.update(); 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++; this.unreadCount++;
document.title = `(${this.unreadCount}) ${this.channel.title} | Misskey`; document.title = `(${this.unreadCount}) ${this.channel.title} | Misskey`;
} }
@ -139,7 +139,7 @@
}; };
this.watch = () => { this.watch = () => {
this.api('channels/watch', { this.$root.$data.os.api('channels/watch', {
channel_id: this.id channel_id: this.id
}).then(() => { }).then(() => {
this.channel.is_watching = true; this.channel.is_watching = true;
@ -150,7 +150,7 @@
}; };
this.unwatch = () => { this.unwatch = () => {
this.api('channels/unwatch', { this.$root.$data.os.api('channels/unwatch', {
channel_id: this.id channel_id: this.id
}).then(() => { }).then(() => {
this.channel.is_watching = false; this.channel.is_watching = false;
@ -164,24 +164,24 @@
<mk-channel-post> <mk-channel-post>
<header> <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> <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 }/>
<mk-time time={ post.created_at } mode="detail"/> <mk-time time={ post.created_at } mode="detail"/>
<span>ID:<i>{ post.user.username }</i></span> <span>ID:<i>{ post.user.username }</i></span>
</header> </header>
<div> <div>
<a if={ post.reply }>&gt;&gt;{ post.reply.index }</a> <a v-if="post.reply">&gt;&gt;{ post.reply.index }</a>
{ post.text } { post.text }
<div class="media" if={ post.media }> <div class="media" v-if="post.media">
<virtual each={ file in post.media }> <template each={ file in post.media }>
<a href={ file.url } target="_blank"> <a href={ file.url } target="_blank">
<img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/> <img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/>
</a> </a>
</virtual> </template>
</div> </div>
</div> </div>
<style> <style lang="stylus" scoped>
:scope :scope
display block display block
margin 0 margin 0
@ -228,7 +228,7 @@
vertical-align bottom vertical-align bottom
</style> </style>
<script> <script lang="typescript">
this.post = this.opts.post; this.post = this.opts.post;
this.form = this.opts.form; this.form = this.opts.form;
@ -241,21 +241,21 @@
</mk-channel-post> </mk-channel-post>
<mk-channel-form> <mk-channel-form>
<p if={ reply }><b>&gt;&gt;{ reply.index }</b> ({ reply.user.name }): <a onclick={ clearReply }>[x]</a></p> <p v-if="reply"><b>&gt;&gt;{ 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> <textarea ref="text" disabled={ wait } oninput={ update } onkeydown={ onkeydown } onpaste={ onpaste } placeholder="%i18n:ch.tags.mk-channel-form.textarea%"></textarea>
<div class="actions"> <div class="actions">
<button onclick={ selectFile }>%fa:upload%%i18n:ch.tags.mk-channel-form.upload%</button> <button @click="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 @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) } onclick={ post }> <button :class="{ wait: wait }" ref="submit" disabled={ wait || (refs.text.value.length == 0) } @click="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 }/> <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> </button>
</div> </div>
<mk-uploader ref="uploader"/> <mk-uploader ref="uploader"/>
<ol if={ files }> <ol v-if="files">
<li each={ files }>{ name }</li> <li each={ files }>{ name }</li>
</ol> </ol>
<input ref="file" type="file" accept="image/*" multiple="multiple" onchange={ changeFile }/> <input ref="file" type="file" accept="image/*" multiple="multiple" onchange={ changeFile }/>
<style> <style lang="stylus" scoped>
:scope :scope
display block display block
@ -282,14 +282,14 @@
display none display none
</style> </style>
<script> <script lang="typescript">
this.mixin('api'); this.mixin('api');
this.channel = this.opts.channel; this.channel = this.opts.channel;
this.files = null; this.files = null;
this.on('mount', () => { this.on('mount', () => {
this.refs.uploader.on('uploaded', file => { this.$refs.uploader.on('uploaded', file => {
this.update({ this.update({
files: [file] files: [file]
}); });
@ -297,7 +297,7 @@
}); });
this.upload = file => { this.upload = file => {
this.refs.uploader.upload(file); this.$refs.uploader.upload(file);
}; };
this.clearReply = () => { this.clearReply = () => {
@ -311,7 +311,7 @@
this.update({ this.update({
files: null files: null
}); });
this.refs.text.value = ''; this.$refs.text.value = '';
}; };
this.post = () => { this.post = () => {
@ -323,8 +323,8 @@
? this.files.map(f => f.id) ? this.files.map(f => f.id)
: undefined; : undefined;
this.api('posts/create', { this.$root.$data.os.api('posts/create', {
text: this.refs.text.value == '' ? undefined : this.refs.text.value, text: this.$refs.text.value == '' ? undefined : this.$refs.text.value,
media_ids: files, media_ids: files,
reply_id: this.reply ? this.reply.id : undefined, reply_id: this.reply ? this.reply.id : undefined,
channel_id: this.channel.id channel_id: this.channel.id
@ -340,11 +340,11 @@
}; };
this.changeFile = () => { this.changeFile = () => {
Array.from(this.refs.file.files).forEach(this.upload); Array.from(this.$refs.file.files).forEach(this.upload);
}; };
this.selectFile = () => { this.selectFile = () => {
this.refs.file.click(); this.$refs.file.click();
}; };
this.drive = () => { this.drive = () => {
@ -375,7 +375,7 @@
<mk-twitter-button> <mk-twitter-button>
<a href="https://twitter.com/share?ref_src=twsrc%5Etfw" class="twitter-share-button" data-show-count="false">Tweet</a> <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', () => { this.on('mount', () => {
const head = document.getElementsByTagName('head')[0]; const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script'); const script = document.createElement('script');
@ -388,7 +388,7 @@
<mk-line-button> <mk-line-button>
<div class="line-it-button" data-lang="ja" data-type="share-a" data-url={ _CH_URL_ } style="display: none;"></div> <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', () => { this.on('mount', () => {
const head = document.getElementsByTagName('head')[0]; const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script'); const script = document.createElement('script');

View File

@ -3,10 +3,10 @@
<a href={ _CH_URL_ }>Index</a> | <a href={ _URL_ }>Misskey</a> <a href={ _CH_URL_ }>Index</a> | <a href={ _URL_ }>Misskey</a>
</div> </div>
<div> <div>
<a if={ !SIGNIN } href={ _URL_ }>ログイン(新規登録)</a> <a v-if="!$root.$data.os.isSignedIn" href={ _URL_ }>ログイン(新規登録)</a>
<a if={ SIGNIN } href={ _URL_ + '/' + I.username }>{ I.username }</a> <a v-if="$root.$data.os.isSignedIn" href={ _URL_ + '/' + I.username }>{ I.username }</a>
</div> </div>
<style> <style lang="stylus" scoped>
:scope :scope
display flex display flex
@ -14,7 +14,7 @@
margin-left auto margin-left auto
</style> </style>
<script> <script lang="typescript">
this.mixin('i'); this.mixin('i');
</script> </script>
</mk-header> </mk-header>

View File

@ -1,21 +1,21 @@
<mk-index> <mk-index>
<mk-header/> <mk-header/>
<hr> <hr>
<button onclick={ n }>%i18n:ch.tags.mk-index.new%</button> <button @click="n">%i18n:ch.tags.mk-index.new%</button>
<hr> <hr>
<ul if={ channels }> <ul v-if="channels">
<li each={ channels }><a href={ '/' + this.id }>{ this.title }</a></li> <li each={ channels }><a href={ '/' + this.id }>{ this.title }</a></li>
</ul> </ul>
<style> <style lang="stylus" scoped>
:scope :scope
display block display block
</style> </style>
<script> <script lang="typescript">
this.mixin('api'); this.mixin('api');
this.on('mount', () => { this.on('mount', () => {
this.api('channels', { this.$root.$data.os.api('channels', {
limit: 100 limit: 100
}).then(channels => { }).then(channels => {
this.update({ this.update({
@ -27,7 +27,7 @@
this.n = () => { this.n = () => {
const title = window.prompt('%i18n:ch.tags.mk-index.channel-title%'); const title = window.prompt('%i18n:ch.tags.mk-index.channel-title%');
this.api('channels/create', { this.$root.$data.os.api('channels/create', {
title: title title: title
}).then(channel => { }).then(channel => {
location.href = '/' + channel.id; location.href = '/' + channel.id;

View File

@ -1,14 +1,14 @@
<mk-authorized-apps> <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> <p>%fa:info-circle%%i18n:common.tags.mk-authorized-apps.no-apps%</p>
</div> </div>
<div class="apps" if={ apps.length != 0 }> <div class="apps" v-if="apps.length != 0">
<div each={ app in apps }> <div each={ app in apps }>
<p><b>{ app.name }</b></p> <p><b>{ app.name }</b></p>
<p>{ app.description }</p> <p>{ app.description }</p>
</div> </div>
</div> </div>
<style> <style lang="stylus" scoped>
:scope :scope
display block display block
@ -18,17 +18,16 @@
border-bottom solid 1px #eee border-bottom solid 1px #eee
</style> </style>
<script> <script lang="typescript">
this.mixin('api'); this.mixin('api');
this.apps = []; this.apps = [];
this.fetching = true; this.fetching = true;
this.on('mount', () => { this.on('mount', () => {
this.api('i/authorized_apps').then(apps => { this.$root.$data.os.api('i/authorized_apps').then(apps => {
this.apps = apps; this.apps = apps;
this.fetching = false; this.fetching = false;
this.update();
}); });
}); });
</script> </script>

View File

@ -1,13 +1,13 @@
<mk-signin-history> <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 }/> <mk-signin-record each={ rec in history } rec={ rec }/>
</div> </div>
<style> <style lang="stylus" scoped>
:scope :scope
display block display block
</style> </style>
<script> <script lang="typescript">
this.mixin('i'); this.mixin('i');
this.mixin('api'); this.mixin('api');
@ -19,7 +19,7 @@
this.fetching = true; this.fetching = true;
this.on('mount', () => { this.on('mount', () => {
this.api('i/signin_history').then(history => { this.$root.$data.os.api('i/signin_history').then(history => {
this.update({ this.update({
fetching: false, fetching: false,
history: history history: history
@ -42,15 +42,15 @@
</mk-signin-history> </mk-signin-history>
<mk-signin-record> <mk-signin-record>
<header onclick={ toggle }> <header @click="toggle">
<virtual if={ rec.success }>%fa:check%</virtual> <template v-if="rec.success">%fa:check%</template>
<virtual if={ !rec.success }>%fa:times%</virtual> <template v-if="!rec.success">%fa:times%</template>
<span class="ip">{ rec.ip }</span> <span class="ip">{ rec.ip }</span>
<mk-time time={ rec.created_at }/> <mk-time time={ rec.created_at }/>
</header> </header>
<pre ref="headers" class="json" show={ show }>{ JSON.stringify(rec.headers, null, 2) }</pre> <pre ref="headers" class="json" show={ show }>{ JSON.stringify(rec.headers, null, 2) }</pre>
<style> <style lang="stylus" scoped>
:scope :scope
display block display block
border-bottom solid 1px #eee border-bottom solid 1px #eee
@ -97,14 +97,14 @@
</style> </style>
<script> <script lang="typescript">
import hljs from 'highlight.js'; import hljs from 'highlight.js';
this.rec = this.opts.rec; this.rec = this.opts.rec;
this.show = false; this.show = false;
this.on('mount', () => { this.on('mount', () => {
hljs.highlightBlock(this.refs.headers); hljs.highlightBlock(this.$refs.headers);
}); });
this.toggle = () => { this.toggle = () => {

View File

@ -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
});
}
});
}

View File

@ -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];
});

View File

@ -0,0 +1 @@
require('./bytes');

View File

@ -1,9 +1,15 @@
import Vue from 'vue';
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from 'eventemitter3';
import * as riot from 'riot'; import api from './scripts/api';
import signout from './scripts/signout'; import signout from './scripts/signout';
import Progress from './scripts/loading'; import Progress from './scripts/loading';
import HomeStreamManager from './scripts/streaming/home-stream-manager'; 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 //#region environment variables
declare const _VERSION_: string; declare const _VERSION_: string;
@ -12,6 +18,41 @@ declare const _API_URL_: string;
declare const _SW_PUBLICKEY_: string; declare const _SW_PUBLICKEY_: string;
//#endregion //#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 * Misskey Operating System
*/ */
@ -26,6 +67,16 @@ export default class MiOS extends EventEmitter {
private isMetaFetching = false; 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 * A signing user
*/ */
@ -34,7 +85,7 @@ export default class MiOS extends EventEmitter {
/** /**
* Whether signed in * Whether signed in
*/ */
public get isSignedin() { public get isSignedIn() {
return this.i != null; return this.i != null;
} }
@ -45,11 +96,28 @@ export default class MiOS extends EventEmitter {
return localStorage.getItem('debug') == 'true'; return localStorage.getItem('debug') == 'true';
} }
public apis: API;
/** /**
* A connection manager of home stream * A connection manager of home stream
*/ */
public stream: HomeStreamManager; 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 * A registration of service worker
*/ */
@ -60,6 +128,11 @@ export default class MiOS extends EventEmitter {
*/ */
private shouldRegisterSw: boolean; private shouldRegisterSw: boolean;
/**
*
*/
public windows = new WindowSystem();
/** /**
* MiOSインスタンスを作成します * MiOSインスタンスを作成します
* @param shouldRegisterSw ServiceWorkerを登録するかどうか * @param shouldRegisterSw ServiceWorkerを登録するかどうか
@ -69,6 +142,9 @@ export default class MiOS extends EventEmitter {
this.shouldRegisterSw = shouldRegisterSw; this.shouldRegisterSw = shouldRegisterSw;
this.streams.serverStream = new ServerStreamManager();
this.streams.requestsStream = new RequestsStreamManager();
//#region BIND //#region BIND
this.log = this.log.bind(this); this.log = this.log.bind(this);
this.logInfo = this.logInfo.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.getMeta = this.getMeta.bind(this);
this.registerSw = this.registerSw.bind(this); this.registerSw = this.registerSw.bind(this);
//#endregion //#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) { public log(...args) {
@ -139,8 +227,10 @@ export default class MiOS extends EventEmitter {
// When failure // When failure
.catch(() => { .catch(() => {
// Render the error screen // Render the error screen
document.body.innerHTML = '<mk-error />'; document.body.innerHTML = '<div id="err"></div>';
riot.mount('*'); new Vue({
render: createEl => createEl(Err)
}).$mount('#err');
Progress.done(); Progress.done();
}); });
@ -153,30 +243,13 @@ export default class MiOS extends EventEmitter {
// フェッチが完了したとき // フェッチが完了したとき
const fetched = me => { const fetched = me => {
if (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)); localStorage.setItem('me', JSON.stringify(me));
// 自分の情報が更新されたとき
me.on('updated', () => {
// キャッシュ更新
localStorage.setItem('me', JSON.stringify(me));
});
} }
this.i = me; this.i = me;
// Init home stream manager this.emit('signedin');
this.stream = this.isSignedin
? new HomeStreamManager(this.i)
: null;
// Finish init // Finish init
callback(); callback();
@ -200,8 +273,6 @@ export default class MiOS extends EventEmitter {
// 後から新鮮なデータをフェッチ // 後から新鮮なデータをフェッチ
fetchme(cachedMe.token, freshData => { fetchme(cachedMe.token, freshData => {
Object.assign(cachedMe, freshData); Object.assign(cachedMe, freshData);
cachedMe.trigger('updated');
cachedMe.trigger('refreshed');
}); });
} else { } else {
// Get token from cookie // Get token from cookie
@ -223,7 +294,7 @@ export default class MiOS extends EventEmitter {
if (!isSwSupported) return; if (!isSwSupported) return;
// Reject when not signed in to Misskey // Reject when not signed in to Misskey
if (!this.isSignedin) return; if (!this.isSignedIn) return;
// When service worker activated // When service worker activated
navigator.serviceWorker.ready.then(registration => { 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 * Convert the URL safe base64 string to a Uint8Array
* @param base64String base64 string * @param base64String base64 string

View File

@ -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) });
};

View File

@ -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];
};

View File

@ -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);
}
};

View File

@ -1 +0,0 @@
export default x => typeof x.then == 'function';

View File

@ -16,7 +16,9 @@ export default class Connection extends Stream {
}, 1000 * 60); }, 1000 * 60);
// 自分の情報が更新されたとき // 自分の情報が更新されたとき
this.on('i_updated', me.update); this.on('i_updated', i => {
Object.assign(me, i);
});
// トークンが再生成されたとき // トークンが再生成されたとき
// このままではAPIが利用できないので強制的にサインアウトさせる // このままではAPIが利用できないので強制的にサインアウトさせる

View File

@ -1,48 +0,0 @@
declare const _URL_: string;
import * as riot from 'riot';
import * as pictograph from 'pictograph';
const escape = text =>
text
.replace(/>/g, '&gt;')
.replace(/</g, '&lt;');
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;
};

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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');

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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);

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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, '&gt;')
.replace(/</g, '&lt;');
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);
}
});

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,5 @@
export default {
inserted(el) {
el.focus();
}
};

View File

@ -0,0 +1,5 @@
import Vue from 'vue';
import focus from './focus';
Vue.directive('focus', focus);

29
src/web/app/config.ts Normal file
View File

@ -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_;

View File

@ -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');
}
});
}

View File

@ -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);
});
}

View File

@ -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);
}

View File

@ -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);
});
}

View File

@ -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);
});
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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);
});
}
};

Some files were not shown because too many files have changed in this diff Show More