diff options
405 files changed, 31017 insertions, 0 deletions
diff --git a/.ci-files/config.yml b/.ci-files/config.yml new file mode 100644 index 0000000000..1875748d68 --- /dev/null +++ b/.ci-files/config.yml @@ -0,0 +1,26 @@ +maintainer: '@syuilo' +url: 'https://misskey.xyz' +secondary_url: 'https://himasaku.net' +port: 80 +https: + enable: false + key: null + cert: null + ca: null +mongodb: + host: localhost + port: 27017 + db: misskey + user: syuilo + pass: '' +redis: + host: localhost + port: 6379 + pass: '' +elasticsearch: + host: localhost + port: 9200 + pass: '' +recaptcha: + siteKey: hima + secretKey: saku diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..952d6cd0e9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.svg -diff -text +*.psd -diff -text +*.ai -diff -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..83620b66fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/.config +/.vscode +/node_modules +/built +npm-debug.log diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..1490a8c83b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: node_js +node_js: + - "7.3.0" +before_script: + - "mkdir -p ./.config && cp ./.ci-files/config.yml ./.config" +cache: + directories: + - node_modules diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..6236d2d77c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2016 syuilo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..626d6da06d --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Misskey + +[![][travis-badge]][travis-link] +[![][dependencies-badge]][dependencies-link] +[![][mit-badge]][mit] + +A miniblog-based SNS. + +## Dependencies +* Node.js +* MongoDB +* Redis +* GraphicsMagick + +## Optional dependencies +* Elasticsearch + +## Get started +Misskey requires two domains called the primary domain and the secondary domain. + +* The primary domain is used to provide main service of Misskey. +* The secondary domain is used to avoid vulnerabilities such as XSS. + +**Ensure that the secondary domain is not a subdomain of the primary domain.** + +## Build +1. `git clone git://github.com/syuilo/misskey.git` +2. `cd misskey` +3. `npm install` +4. `npm run config` +5. `npm run build` + +## Launch +`npm start` + +## License +[MIT](LICENSE) + +[mit]: http://opensource.org/licenses/MIT +[mit-badge]: https://img.shields.io/badge/license-MIT-444444.svg?style=flat-square +[travis-link]: https://travis-ci.org/syuilo/misskey +[travis-badge]: http://img.shields.io/travis/syuilo/misskey.svg?style=flat-square +[dependencies-link]: https://gemnasium.com/syuilo/misskey +[dependencies-badge]: https://img.shields.io/gemnasium/syuilo/misskey.svg?style=flat-square diff --git a/elasticsearch/README.md b/elasticsearch/README.md new file mode 100644 index 0000000000..c7fcb245f0 --- /dev/null +++ b/elasticsearch/README.md @@ -0,0 +1,6 @@ +How to create indexes +===================== + +``` shell +curl -XPOST localhost:9200/misskey -d @path/to/mappings.json +``` diff --git a/elasticsearch/mappings.json b/elasticsearch/mappings.json new file mode 100644 index 0000000000..654ab17450 --- /dev/null +++ b/elasticsearch/mappings.json @@ -0,0 +1,65 @@ +{ + "settings": { + "analysis": { + "analyzer": { + "bigram": { + "tokenizer": "bigram_tokenizer" + } + }, + "tokenizer": { + "bigram_tokenizer": { + "type": "nGram", + "min_gram": 2, + "max_gram": 2, + "token_chars": [ + "letter", + "digit" + ] + } + } + } + }, + "mappings": { + "user": { + "properties": { + "username": { + "type": "string", + "index": "analyzed", + "analyzer": "bigram" + }, + "name": { + "type": "string", + "index": "analyzed", + "analyzer": "bigram" + }, + "bio": { + "type": "string", + "index": "analyzed", + "analyzer": "kuromoji" + } + } + }, + "post": { + "properties": { + "text": { + "type": "string", + "index": "analyzed", + "analyzer": "kuromoji" + } + } + }, + "drive_file": { + "properties": { + "name": { + "type": "string", + "index": "analyzed", + "analyzer": "kuromoji" + }, + "user": { + "type": "string", + "index": "not_analyzed" + } + } + } + } +} diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000000..5bd947f72d --- /dev/null +++ b/gulpfile.js @@ -0,0 +1 @@ +eval(require('typescript').transpile(require('fs').readFileSync('./gulpfile.ts').toString())); diff --git a/gulpfile.ts b/gulpfile.ts new file mode 100644 index 0000000000..b01c5f829e --- /dev/null +++ b/gulpfile.ts @@ -0,0 +1,568 @@ +/** + * Gulp tasks + */ + +import * as gulp from 'gulp'; +import * as gutil from 'gulp-util'; +import * as babel from 'gulp-babel'; +import * as ts from 'gulp-typescript'; +import * as tslint from 'gulp-tslint'; +import * as glob from 'glob'; +import * as browserify from 'browserify'; +import * as source from 'vinyl-source-stream'; +import * as buffer from 'vinyl-buffer'; +import * as es from 'event-stream'; +const stylus = require('gulp-stylus'); +const cssnano = require('gulp-cssnano'); +import * as uglify from 'gulp-uglify'; +const ls = require('browserify-livescript'); +const aliasify = require('aliasify'); +const riotify = require('riotify'); +const transformify = require('syuilo-transformify'); +const pug = require('gulp-pug'); +const git = require('git-last-commit'); +import * as rimraf from 'rimraf'; + +const env = process.env.NODE_ENV; +const isProduction = env === 'production'; +const isDebug = !isProduction; + +import { IConfig } from './src/config'; +const config = eval(require('typescript').transpile(require('fs').readFileSync('./src/config.ts').toString())) + ('.config/config.yml') as IConfig; + +const project = ts.createProject('tsconfig.json'); + +gulp.task('build', [ + 'build:js', + 'build:ts', + 'build:copy', + 'build:client' +]); + +gulp.task('rebuild', [ + 'clean', + 'build' +]); + +gulp.task('build:js', () => + gulp.src(['./src/**/*.js', '!./src/web/**/*.js']) + .pipe(babel({ + presets: ['es2015', 'stage-3'] + })) + .pipe(gulp.dest('./built/')) +); + +gulp.task('build:ts', () => + project + .src() + .pipe(project()) + .pipe(babel({ + presets: ['es2015', 'stage-3'] + })) + .pipe(gulp.dest('./built/')) +); + +gulp.task('build:copy', () => { + gulp.src([ + './src/**/resources/**/*', + '!./src/web/app/**/resources/**/*' + ]).pipe(gulp.dest('./built/')); + gulp.src([ + './src/web/about/**/*' + ]).pipe(gulp.dest('./built/web/about/')); +}); + +gulp.task('test', ['lint', 'build']); + +gulp.task('lint', () => + gulp.src('./src/**/*.ts') + .pipe(tslint({ + formatter: 'verbose' + })) + .pipe(tslint.report()) +); + +gulp.task('clean', cb => + rimraf('./built', cb) +); + +gulp.task('cleanall', ['clean'], cb => + rimraf('./node_modules', cb) +); + +gulp.task('default', ['build']); + +const aliasifyConfig = { + aliases: { + 'fetch': './node_modules/whatwg-fetch/fetch.js', + 'page': './node_modules/page/page.js', + 'NProgress': './node_modules/nprogress/nprogress.js', + 'velocity': './node_modules/velocity-animate/velocity.js', + 'chart.js': './node_modules/chart.js/src/chart.js', + 'textarea-caret-position': './node_modules/textarea-caret/index.js', + 'misskey-text': './src/common/text/index.js', + 'strength.js': './node_modules/syuilo-password-strength/strength.js', + 'cropper': './node_modules/cropperjs/dist/cropper.js', + 'Sortable': './node_modules/sortablejs/Sortable.js', + 'fuck-adblock': './node_modules/fuckadblock/fuckadblock.js', + 'reconnecting-websocket': './node_modules/reconnecting-websocket/dist/index.js' + }, + appliesTo: { + 'includeExtensions': ['.js', '.ls'] + } +}; + +gulp.task('build:client', [ + 'build:ts', 'build:js', + 'build:client:scripts', + 'build:client:styles', + 'build:client:pug', + 'copy:client' +], () => { + gutil.log('ビルドが終了しました。'); + + if (isDebug) { + gutil.log('■ 注意! 開発モードでのビルドです。'); + } +}); + +gulp.task('build:client:scripts', done => { + gutil.log('スクリプトを構築します...'); + + // Get commit info + git.getLastCommit((err, commit) => { + glob('./src/web/app/*/script.js', (err, files) => { + const tasks = files.map(entry => { + let bundle = + browserify({ + entries: [entry] + }) + .transform(ls) + .transform(aliasify, aliasifyConfig) + + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + console.log(file); + return source; + })) + + // tagの{}の''を不要にする (その代わりスタイルの記法は使えなくなるけど) + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + const html = tag.sections.filter(s => s.name == 'html')[0]; + + html.lines = html.lines.map(line => { + if (line.replace(/\t/g, '')[0] === '|') { + return line; + } else { + return line.replace(/([+=])\s?\{(.+?)\}/g, '$1"{$2}"'); + } + }); + + const styles = tag.sections.filter(s => s.name == 'style'); + + if (styles.length == 0) { + return tag.compile(); + } + + styles.forEach(style => { + let head = style.lines.shift(); + head = head.replace(/([+=])\s?\{(.+?)\}/g, '$1"{$2}"'); + style.lines.unshift(head); + }); + + return tag.compile(); + })) + + // tagの@hogeをref='hoge'にする + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + const html = tag.sections.filter(s => s.name == 'html')[0]; + + html.lines = html.lines.map(line => { + if (line.indexOf('@') === -1) { + return line; + } else if (line.replace(/\t/g, '')[0] === '|') { + return line; + } else { + while (line.match(/[^\s']@[a-z-]+/) !== null) { + const match = line.match(/@[a-z-]+/); + let name = match[0]; + if (line[line.indexOf(name) + name.length] === '(') { + line = line.replace(name + '(', '(ref=\'' + camelCase(name.substr(1)) + '\','); + } else { + line = line.replace(name, '(ref=\'' + camelCase(name.substr(1)) + '\')'); + } + } + return line; + } + }); + + return tag.compile(); + + function camelCase(str): string { + return str.replace(/-([^\s])/g, (match, group1) => { + return group1.toUpperCase(); + }); + } + })) + + // tagのchain-caseをcamelCaseにする + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + const html = tag.sections.filter(s => s.name == 'html')[0]; + + html.lines = html.lines.map(line => { + (line.match(/\{.+?\}/g) || []).forEach(x => { + line = line.replace(x, camelCase(x)); + }); + return line; + }); + + return tag.compile(); + + function camelCase(str): string { + str = str.replace(/([a-z\-]+):/g, (match, group1) => { + return group1.replace(/\-/g, '###') + ':'; + }); + str = str.replace(/'(.+?)'/g, (match, group1) => { + return "'" + group1.replace(/\-/g, '###') + "'"; + }); + str = str.replace(/-([^\s0-9])/g, (match, group1) => { + return group1.toUpperCase(); + }); + str = str.replace(/###/g, '-'); + + return str; + } + })) + + // tagのstyleの属性 + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + + const styles = tag.sections.filter(s => s.name == 'style'); + + if (styles.length == 0) { + return tag.compile(); + } + + styles.forEach(style => { + let head = style.lines.shift(); + if (style.attr) { + style.attr = style.attr + ', type=\'stylus\', scoped'; + } else { + style.attr = 'type=\'stylus\', scoped'; + } + style.lines.unshift(head); + }); + + return tag.compile(); + })) + + // tagのstyleの定数 + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + + const styles = tag.sections.filter(s => s.name == 'style'); + + if (styles.length == 0) { + return tag.compile(); + } + + styles.forEach(style => { + const head = style.lines.shift(); + style.lines.unshift('$theme-color = ' + config.themeColor); + style.lines.unshift('$theme-color-foreground = #fff'); + style.lines.unshift(head); + }); + + return tag.compile(); + })) + + // tagのstyleを暗黙的に:scopeにする + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + + const styles = tag.sections.filter(s => s.name == 'style'); + + if (styles.length == 0) { + return tag.compile(); + } + + styles.forEach((style, i) => { + if (i != 0) { + return; + } + const head = style.lines.shift(); + style.lines = style.lines.map(line => { + return '\t' + line; + }); + style.lines.unshift(':scope'); + style.lines.unshift(head); + }); + + return tag.compile(); + })) + + // tagのtheme styleのパース + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + + const styles = tag.sections.filter(s => s.name == 'style'); + + if (styles.length == 0) { + return tag.compile(); + } + + styles.forEach((style, i) => { + if (i == 0) { + return; + } else if (style.attr.substr(0, 6) != 'theme=') { + return; + } + const head = style.lines.shift(); + style.lines = style.lines.map(line => { + return '\t' + line; + }); + style.lines.unshift(':scope'); + style.lines = style.lines.map(line => { + return '\t' + line; + }); + style.lines.unshift('html[data-' + style.attr.match(/theme='(.+?)'/)[0] + ']'); + style.lines.unshift(head); + }); + + return tag.compile(); + })) + + // tagのstyleおよびscriptのインデントを不要にする + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + const tag = new Tag(source); + + tag.sections = tag.sections.map(section => { + if (section.name != 'html') { + section.indent++; + } + return section; + }); + + return tag.compile(); + })) + + // スペースでインデントされてないとエラーが出る + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + return source.replace(/\t/g, ' '); + })) + + .transform(transformify((source, file) => { + return source + .replace(/VERSION/g, `'${commit ? commit.hash : 'null'}'`) + .replace(/CONFIG\.theme-color/g, `'${config.themeColor}'`) + .replace(/CONFIG\.themeColor/g, `'${config.themeColor}'`) + .replace(/CONFIG\.api\.url/g, `'${config.scheme}://api.${config.host}'`) + .replace(/CONFIG\.urls\.about/g, `'${config.scheme}://about.${config.host}'`) + .replace(/CONFIG\.urls\.dev/g, `'${config.scheme}://dev.${config.host}'`) + .replace(/CONFIG\.url/g, `'${config.url}'`) + .replace(/CONFIG\.host/g, `'${config.host}'`) + .replace(/CONFIG\.recaptcha\.siteKey/g, `'${config.recaptcha.siteKey}'`) + ; + })) + + .transform(riotify, { + template: 'pug', + type: 'livescript', + expr: false, + compact: true, + parserOptions: { + style: { + compress: true, + rawDefine: config + } + } + }) + // Riotが謎の空白を挿入する + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + return source.replace(/\s<mk\-ellipsis>/g, '<mk-ellipsis>'); + })) + /* + // LiveScruptがHTMLクラスのショートカットを変な風に生成するのでそれを修正 + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + return source.replace(/class="\{\(\{(.+?)\}\)\}"/g, 'class="{$1}"'); + }))*/ + .bundle() + .pipe(source(entry.replace('./src/web/app/', './').replace('.ls', '.js'))); + + if (isProduction) { + bundle = bundle + .pipe(buffer()) + // ↓ https://github.com/mishoo/UglifyJS2/issues/448 + .pipe(babel({ + presets: ['es2015'] + })) + .pipe(uglify({ + compress: true + })); + } + + return bundle + .pipe(gulp.dest('./built/web/resources/')); + }); + + es.merge(tasks).on('end', done); + }); + }); +}); + +gulp.task('build:client:styles', () => { + gutil.log('フロントサイドスタイルを構築します...'); + + return gulp.src('./src/web/app/**/*.styl') + .pipe(stylus({ + 'include css': true, + compress: true, + rawDefine: config + })) + .pipe(isProduction + ? cssnano({ + safe: true // 高度な圧縮は無効にする (一部デザインが不適切になる場合があるため) + }) + : gutil.noop()) + .pipe(gulp.dest('./built/web/resources/')); +}); + +gulp.task('copy:client', [ + 'build:client:scripts', + 'build:client:styles' +], () => { + gutil.log('必要なリソースをコピーします...'); + + return es.merge( + gulp.src('./resources/**/*').pipe(gulp.dest('./built/web/resources/')), + gulp.src('./src/web/app/desktop/resources/**/*').pipe(gulp.dest('./built/web/resources/desktop/')), + gulp.src('./src/web/app/mobile/resources/**/*').pipe(gulp.dest('./built/web/resources/mobile/')), + gulp.src('./src/web/app/dev/resources/**/*').pipe(gulp.dest('./built/web/resources/dev/')), + gulp.src('./src/web/app/auth/resources/**/*').pipe(gulp.dest('./built/web/resources/auth/')) + ); +}); + +gulp.task('build:client:pug', [ + 'copy:client', + 'build:client:scripts', + 'build:client:styles' +], () => { + gutil.log('Pugをコンパイルします...'); + + return gulp.src([ + './src/web/app/*/view.pug' + ]) + .pipe(pug({ + locals: { + themeColor: config.themeColor + } + })) + .pipe(gulp.dest('./built/web/app/')); +}); + +class Tag { + sections: { + name: string; + attr?: string; + indent: number; + lines: string[]; + }[]; + + constructor(source) { + this.sections = []; + + source = source + .replace(/\r\n/g, '\n') + .replace(/\n(\t+?)\n/g, '\n') + .replace(/\n+/g, '\n'); + + const html = { + name: 'html', + indent: 0, + lines: [] + }; + + let flag = false; + source.split('\n').forEach((line, i) => { + const indent = line.lastIndexOf('\t') + 1; + if (i != 0 && indent == 0) { + flag = true; + } + if (!flag) { + source = source.replace(/^.*?\n/, ''); + html.lines.push(i == 0 ? line : line.substr(1)); + } + }); + + this.sections.push(html); + + while (source != '') { + const line = source.substr(0, source.indexOf('\n')); + const root = line.match(/^\t*([a-z]+)(\.|\()?/)[1]; + const beginIndent = line.lastIndexOf('\t') + 1; + flag = false; + const section = { + name: root, + attr: (line.match(/\((.+?)\)/) || [null, null])[1], + indent: beginIndent, + lines: [] + }; + source.split('\n').forEach((line, i) => { + const currentIndent = line.lastIndexOf('\t') + 1; + if (i != 0 && (currentIndent == beginIndent || currentIndent == 0)) { + flag = true; + } + if (!flag) { + if (i == 0 && line[line.length - 1] == '.') { + line = line.substr(0, line.length - 1); + } + if (i == 0 && line.indexOf('(') != -1) { + line = line.substr(0, line.indexOf('(')); + } + source = source.replace(/^.*?\n/, ''); + section.lines.push(i == 0 ? line.substr(beginIndent) : line.substr(beginIndent + 1)); + } + }); + this.sections.push(section); + } + } + + compile(): string { + let dist = ''; + this.sections.forEach((section, j) => { + dist += section.lines.map((line, i) => { + if (i == 0) { + const attr = section.attr != null ? '(' + section.attr + ')' : ''; + const tail = j != 0 ? '.' : ''; + return '\t'.repeat(section.indent) + line + attr + tail; + } else { + return '\t'.repeat(section.indent + 1) + line; + } + }).join('\n') + '\n'; + }); + return dist; + } +} diff --git a/init.js b/init.js new file mode 100644 index 0000000000..380ff6cb8b --- /dev/null +++ b/init.js @@ -0,0 +1,182 @@ +const fs = require('fs'); +const yaml = require('js-yaml'); +const inquirer = require('inquirer'); + +const configDirPath = `${__dirname}/.config`; +const configPath = `${configDirPath}/config.yml`; + +const form = [ + { + type: 'input', + name: 'maintainer', + message: 'Maintainer name(and email address):' + }, + { + type: 'input', + name: 'url', + message: 'PRIMARY URL:' + }, + { + type: 'input', + name: 'secondary_url', + message: 'SECONDARY URL:' + }, + { + type: 'input', + name: 'port', + message: 'Listen port:' + }, + { + type: 'confirm', + name: 'https', + message: 'Use TLS?', + default: false + }, + { + type: 'input', + name: 'https_key', + message: 'Path of tls key:', + when: ctx => ctx.https + }, + { + type: 'input', + name: 'https_cert', + message: 'Path of tls cert:', + when: ctx => ctx.https + }, + { + type: 'input', + name: 'https_ca', + message: 'Path of tls ca:', + when: ctx => ctx.https + }, + { + type: 'input', + name: 'mongo_host', + message: 'MongoDB\'s host:', + default: 'localhost' + }, + { + type: 'input', + name: 'mongo_port', + message: 'MongoDB\'s port:', + default: '27017' + }, + { + type: 'input', + name: 'mongo_db', + message: 'MongoDB\'s db:', + default: 'misskey' + }, + { + type: 'input', + name: 'mongo_user', + message: 'MongoDB\'s user:' + }, + { + type: 'password', + name: 'mongo_pass', + message: 'MongoDB\'s password:' + }, + { + type: 'input', + name: 'redis_host', + message: 'Redis\'s host:', + default: 'localhost' + }, + { + type: 'input', + name: 'redis_port', + message: 'Redis\'s port:', + default: '6379' + }, + { + type: 'password', + name: 'redis_pass', + message: 'Redis\'s password:' + }, + { + type: 'confirm', + name: 'elasticsearch', + message: 'Use Elasticsearch?', + default: false + }, + { + type: 'input', + name: 'es_host', + message: 'Elasticsearch\'s host:', + default: 'localhost', + when: ctx => ctx.elasticsearch + }, + { + type: 'input', + name: 'es_port', + message: 'Elasticsearch\'s port:', + default: '9200', + when: ctx => ctx.elasticsearch + }, + { + type: 'password', + name: 'es_pass', + message: 'Elasticsearch\'s password:', + when: ctx => ctx.elasticsearch + }, + { + type: 'input', + name: 'recaptcha_site', + message: 'reCAPTCHA\'s site key:' + }, + { + type: 'input', + name: 'recaptcha_secret', + message: 'reCAPTCHA\'s secret key:' + } +]; + +inquirer.prompt(form).then(as => { + // Mapping answers + const conf = { + maintainer: as['maintainer'], + url: as['url'], + secondary_url: as['secondary_url'], + port: parseInt(as['port'], 10), + https: { + enable: as['https'], + key: as['https_key'] || null, + cert: as['https_cert'] || null, + ca: as['https_ca'] || null + }, + mongodb: { + host: as['mongo_host'], + port: parseInt(as['mongo_port'], 10), + db: as['mongo_db'], + user: as['mongo_user'], + pass: as['mongo_pass'] + }, + redis: { + host: as['redis_host'], + port: parseInt(as['redis_port'], 10), + pass: as['redis_pass'] + }, + elasticsearch: { + enable: as['elasticsearch'], + host: as['es_host'] || null, + port: parseInt(as['es_port'], 10) || null, + pass: as['es_pass'] || null + }, + recaptcha: { + siteKey: as['recaptcha_site'], + secretKey: as['recaptcha_secret'] + } + }; + + console.log('Thanks. Writing the configuration to a file...'); + + try { + fs.mkdirSync(configDirPath); + fs.writeFileSync(configPath, yaml.dump(conf)); + console.log('Well done.'); + } catch (e) { + console.error(e); + } +}); diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000000..8d4204dbc2 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,14 @@ +{ + // Please visit https://go.microsoft.com/fwlink/?LinkId=759670 for more information about jsconfig.json + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "jspm_packages", + "tmp", + "temp" + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..8d91857ff3 --- /dev/null +++ b/package.json @@ -0,0 +1,135 @@ +{ + "private": true, + "name": "misskey", + "version": "0.0.0", + "description": "A miniblog-based SNS", + "author": "syuilo <i@syuilo.com>", + "license": "MIT", + "repository": "https://github.com/syuilo/misskey.git", + "bugs": "https://github.com/syuilo/misskey/issues", + "main": "./built/index.js", + "scripts": { + "config": "node ./init.js", + "start": "node ./built/index.js", + "build": "gulp build", + "rebuild": "gulp rebuild", + "clean": "gulp clean", + "cleanall": "gulp cleanall", + "lint": "gulp lint", + "test": "gulp test" + }, + "dependencies": { + "@types/bcrypt": "0.0.30", + "@types/body-parser": "0.0.33", + "@types/browserify": "12.0.30", + "@types/chalk": "0.4.31", + "@types/compression": "0.0.33", + "@types/cors": "0.0.33", + "@types/elasticsearch": "5.0.0", + "@types/event-stream": "3.3.30", + "@types/express": "4.0.34", + "@types/glob": "5.0.30", + "@types/gm": "1.17.29", + "@types/gulp": "3.8.32", + "@types/gulp-babel": "6.1.29", + "@types/gulp-tslint": "3.6.30", + "@types/gulp-typescript": "0.0.32", + "@types/gulp-uglify": "0.0.29", + "@types/gulp-util": "3.0.30", + "@types/inquirer": "0.0.31", + "@types/js-yaml": "3.5.28", + "@types/mongodb": "2.1.34", + "@types/ms": "0.7.29", + "@types/multer": "0.0.32", + "@types/ratelimiter": "2.1.28", + "@types/redis": "0.12.32", + "@types/request": "0.0.33", + "@types/rimraf": "0.0.28", + "@types/serve-favicon": "2.2.28", + "@types/shelljs": "0.3.32", + "@types/uuid": "2.0.29", + "@types/vinyl-buffer": "0.0.28", + "@types/vinyl-source-stream": "0.0.28", + "@types/websocket": "0.0.32", + "accesses": "1.2.0", + "aliasify": "2.1.0", + "argv": "0.0.2", + "babel-core": "6.20.0", + "babel-polyfill": "6.20.0", + "babel-preset-es2015": "6.18.0", + "babel-preset-stage-3": "6.17.0", + "bcrypt": "1.0.1", + "body-parser": "1.15.2", + "browserify": "13.1.1", + "browserify-livescript": "0.2.3", + "chalk": "1.1.3", + "chart.js": "2.4.0", + "compression": "1.6.2", + "cors": "2.8.1", + "cropperjs": "1.0.0-alpha", + "deepcopy": "0.6.3", + "del": "2.2.2", + "elasticsearch": "12.1.2", + "escape-regexp": "0.0.1", + "event-stream": "3.3.4", + "express": "4.14.0", + "file-type": "4.0.0", + "fuckadblock": "3.2.1", + "git-last-commit": "0.2.0", + "glob": "7.1.1", + "gm": "1.23.0", + "gulp": "3.9.1", + "gulp-babel": "6.1.2", + "gulp-cssnano": "2.1.2", + "gulp-livescript": "3.0.1", + "gulp-pug": "3.2.0", + "gulp-replace": "0.5.4", + "gulp-stylus": "2.6.0", + "gulp-tslint": "7.0.1", + "gulp-typescript": "3.1.3", + "gulp-uglify": "2.0.0", + "gulp-util": "3.0.7", + "inquirer": "2.0.0", + "js-yaml": "3.7.0", + "livescript": "1.5.0", + "log-cool": "1.1.0", + "mime-types": "2.1.13", + "mongodb": "2.2.16", + "ms": "0.7.2", + "multer": "1.2.0", + "nprogress": "0.2.0", + "page": "1.7.1", + "prominence": "0.2.0", + "pug": "2.0.0-beta6", + "ratelimiter": "2.1.3", + "recaptcha-promise": "0.1.2", + "reconnecting-websocket": "3.0.3", + "redis": "2.6.3", + "request": "2.79.0", + "rimraf": "2.5.4", + "riot": "3.0.5", + "riot-compiler": "3.1.1", + "riotify": "2.0.0", + "rndstr": "1.0.0", + "serve-favicon": "2.3.2", + "shelljs": "0.7.5", + "sortablejs": "1.5.0-rc1", + "subdomain": "1.2.0", + "summaly": "1.2.7", + "syuilo-password-strength": "0.0.1", + "syuilo-transformify": "0.1.2", + "tcp-port-used": "0.1.2", + "textarea-caret": "3.0.2", + "tslint": "4.0.2", + "typescript": "2.1.4", + "uuid": "3.0.1", + "velocity-animate": "1.4.0", + "vhost": "3.0.2", + "vinyl-buffer": "1.0.0", + "vinyl-source-stream": "1.1.0", + "websocket": "1.0.23", + "whatwg-fetch": "2.0.1", + "xml2json": "0.10.0", + "yargs": "6.5.0" + } +} diff --git a/resources/apple-touch-icon.png b/resources/apple-touch-icon.png Binary files differnew file mode 100644 index 0000000000..1b1b1b3ecf --- /dev/null +++ b/resources/apple-touch-icon.png diff --git a/resources/favicon.ico b/resources/favicon.ico Binary files differnew file mode 100644 index 0000000000..4fd944c3da --- /dev/null +++ b/resources/favicon.ico diff --git a/resources/favicon/128.png b/resources/favicon/128.png Binary files differnew file mode 100644 index 0000000000..75b93cb1e1 --- /dev/null +++ b/resources/favicon/128.png diff --git a/resources/favicon/16.png b/resources/favicon/16.png Binary files differnew file mode 100644 index 0000000000..527ea7f41e --- /dev/null +++ b/resources/favicon/16.png diff --git a/resources/favicon/256.png b/resources/favicon/256.png Binary files differnew file mode 100644 index 0000000000..8e0077b4bf --- /dev/null +++ b/resources/favicon/256.png diff --git a/resources/favicon/32.png b/resources/favicon/32.png Binary files differnew file mode 100644 index 0000000000..2a40365456 --- /dev/null +++ b/resources/favicon/32.png diff --git a/resources/favicon/64.png b/resources/favicon/64.png Binary files differnew file mode 100644 index 0000000000..2dd9d71a81 --- /dev/null +++ b/resources/favicon/64.png diff --git a/resources/icon.ai b/resources/icon.ai new file mode 100644 index 0000000000..605a16a3b6 --- /dev/null +++ b/resources/icon.ai @@ -0,0 +1,1794 @@ +%PDF-1.5
%
+1 0 obj
<</Metadata 2 0 R/OCProperties<</D<</ON[5 0 R 26 0 R]/Order 27 0 R/RBGroups[]>>/OCGs[5 0 R 26 0 R]>>/Pages 3 0 R/Type/Catalog>>
endobj
2 0 obj
<</Length 43446/Subtype/XML/Type/Metadata>>stream
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> +<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.3-c011 66.145661, 2012/02/06-14:56:27 "> + <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <rdf:Description rdf:about="" + xmlns:dc="http://purl.org/dc/elements/1.1/"> + <dc:format>application/pdf</dc:format> + <dc:title> + <rdf:Alt> + <rdf:li xml:lang="x-default">icon</rdf:li> + </rdf:Alt> + </dc:title> + </rdf:Description> + <rdf:Description rdf:about="" + xmlns:xmp="http://ns.adobe.com/xap/1.0/" + xmlns:xmpGImg="http://ns.adobe.com/xap/1.0/g/img/"> + <xmp:MetadataDate>2016-07-11T02:43:45+09:00</xmp:MetadataDate> + <xmp:ModifyDate>2016-07-11T02:43:45+09:00</xmp:ModifyDate> + <xmp:CreateDate>2016-07-11T02:43:25+09:00</xmp:CreateDate> + <xmp:CreatorTool>Adobe Illustrator CS6 (Windows)</xmp:CreatorTool> + <xmp:Thumbnails> + <rdf:Alt> + <rdf:li rdf:parseType="Resource"> + <xmpGImg:width>216</xmpGImg:width> + <xmpGImg:height>256</xmpGImg:height> + <xmpGImg:format>JPEG</xmpGImg:format> + <xmpGImg:image>/9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA
AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK
DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f
Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgBAADYAwER
AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA
AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB
UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE
1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ
qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy
obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp
0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo
+DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYq7FXYq7FXYq7
FUt1XzHomkj/AE+7jhftFXlIf9gtW/DKMupx4/qNOXptDmzfREn7vmw/U/zdtEJTTbJ5T2lnYIP+
BXkT94zW5e1wPpF+932n9mZHfJKvIbsavfzL813NQk6Wqn9mGNR+L82/HMGfaeaXWvc7bF2Dpocw
Ze8/qpJbjzDr1yaz6jcyexlen3VpmNLUZJc5H5uwhosMPphEfAIF5JHNXYsR0JNf15STbkiIHJbi
lVhu7qGnozPHTpwYr+o5ITI5FhLHGXMApla+bvM9qR6Wp3FB0DuZB9z8hl0dZljykXEydmaefOEf
lX3J9Yfmt5hgIF3HDeJ3JX03+9Ph/wCFzLx9q5BzoutzezmCX0mUftH2/rZTpf5qaBdFUvEksZD1
LD1I6/6y7/eubDF2rjl9XpdLqPZ3PDeBEx8j+Piy20vbO8hE1pMk8R6PGwYfhmxhOMhYNh0mXFPG
akCD5q2Sa3Yq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqlGv+atG0OKt7NWYiqW0dGlb/Y9h7mmY2o1
UMQ9R37nO0fZ2bUH0DbvPJ5nr35la7qJaKzP6PtTtxjNZSPeTqP9jTNHn7TyT2j6Q9do+wcOLefr
l58vl+tiTuzsXclmY1Ziakk9yc1xNu8AAFBrFLsVdirsVdirsVdirsVdiqJsNSv9PmE9lcPby/zR
sRX2I6H6cnjyygbiaac2CGUVMCQZ95f/ADXYFYNciqOn1yEb/N4/+afuzb6ftbpkHxDzOt9nB9WE
/wCaf0H9fzeh2V9Z31utzaTJPA/2ZENR8vY+2bmE4zFxNh5bLhnjlwzFFXybW7FXYq7FXYq7FXYq
7FXYq7FWmZVUsxCqoqzHYADucSUgW8782/maqF7LQmDMPhkviKge0QPX/WP0eOaXV9p16cfz/U9V
2b7P3U8/+l/X+p5tNPNPK008jSyuavI5LMT4knNJKRJs83rIQERQFALMDJ2KuxV2KuxV2KuxV2Ku
xV2KuxV2KuxVMdE8warotz9YsJihP95Ed43Hgy9/15dg1E8RuJcTV6LFqI8Mxf3h675U876br0Yi
NLfUVFXtmP2qdWjP7Q9uo/HOj0mujl25S7nh+0eycmmN/VDv/WyPM11LsVdirsVdirsVdirsVU7i
4gtoHnndYoYlLSSMaAAdzglIRFnkyhAzIjEWS8i86+fLjWJHsrBmi0tTQndWmp3b/J8F+/25zW68
5PTH6Pve57J7HjgHHPfJ/uf2+bD81rvnYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqvillhkW
WJ2jlQhkdSQwI6EEYQSDYYyiJCjuHq/kfz+mp8NO1NgmodIptgs3t7P+vOg0PaHiemf1fe8V2v2K
cN5Mf0dR3fsZvm1eddirsVdirsVdirTMqqWYgKBUk7AAYpAt4/5886yaxcNYWTldLibcjb1mU/aP
+T/KPp+XN6/W+IeGP0fe912P2SMEeOf94f8AY/t72H5rXfOxV2KuxV2KuxV2KuxV2KuxV2KuxV2K
uxV2KuxV2KuxVtWZWDKSGBqCNiCMUEW9d8gedRq0I06+ampQr8Dn/dyKOv8Arjv9/jnR9n63xBwy
+ofa8N212V4B8SH92f8AYn9Xd8mZ5s3QOxV2KuxV2KvOvzN82lFbQrJ6MwBvpF7A7iIfPq33eOaX
tPV1+7j8f1PV+z/Zt/v5j+r+v9TzPNG9c7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FX
Yq7FVW1uZ7W4jubdzHPEweN16gjJRkYmxzDDJjjOJjIWC9y8p+ZINe0pLlaLcx0S6iH7L06j/Jbq
M6rSakZYX16vnPaWglpspj/CeR8k6zKde7FXYqlHmrX4tD0aa9NDMf3dsh/alYfD9A6n5ZjarUDF
Ay69HO7O0Z1GYQ6cz7nhE80s80k8zF5ZWLyOepZjUk5ycpEmzzfSYQEQANgFmBk7FXYq7FXYq7FX
Yq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqn3k3zG+h6zHOxP1Sakd2n+QT9qnip3zL0Wp8Kd9
Dzdb2roRqMRj/ENx7/2vckdXUOhDKwBVhuCD0IzqwbfOSCDRbxQ7FXjn5la8dR102cTVtdPrGtOh
lP8AeH6D8P0ZzXaefjycI5Re87B0fhYeM/VPf4dP1sRzXO9dirsVdirsVdirsVdirsVdirsVdirs
VdirsVdirsVdirsVdirsVdirsVewfllrx1DRTYzNW50+iCvUxH7B/wBjTj92dH2ZqOOHCecfueE9
oNH4WbjH0z+/r+tmObN0KW+Y9VGk6Jd3/wC3DGfSHjI3wp/wxGUanL4eMy7nL0Om8bNGHefs6vAX
dnYu5LMxJZjuST1JzkCbfTQABQaxS7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7F
XYq7FXYqyDyLrB0vzJayM1ILg/V5/DjIQAT8moczNDm8PKD0Ozq+2NL42nkOsdx8P2Pcc6p86ee/
m7qZS0stNQ7yu08o9kHFfvLH7s03a+WgI9+71Pszp7lLIemw+LzDNE9g7FXYq7FXYq7FXYq7FXYq
7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXAkGo64q9/8t6l+k9Csb4mrzRD1D/xYvwv/AMMp
zr9Nl48Yl3h8x1+Dwc0odx+zp9jyr8y736z5rnQGq2qRwr9C8z/wznOf7TnxZj5bPadgYuDSg/zi
T+j9DFcwHdOxV2KuxV2KuxV2KuxV2KuxVnflX8srm/jS81dmtbZqNHbrtK48Wr9gfj8s22l7MMxx
T2Hd1eb7R7fjjJhi9Uu/oP1vQtO8r+X9PVRa2EKMvSRlDv8A8G1W/HNxj0uOHKIeWz9oZ8v1TP3D
5BHtZ2jqVaCNlPUFVI/VlxgO5xhlkORKQax+X/lrUkNLYWc/7M1sAm/ug+A/dmJm7PxT6UfJ2el7
a1GI/VxDulv9vN5d5n8o6n5fnAnAltJDSG6QfC3sw/Zb2+7NBqtHPCd+Xe9j2f2nj1I9O0hzH45h
I8xXZOxV2KuxV2KuxV2KuxV2KuxV61+U16ZdBntWNTbTniPBJFBH/DBs6Hsmd4yO4vEe0mLhziX8
6P3fgPNfMNwbnXtRnP8Auy5lI+XM0/DNJqJcWSR8y9boocGGEe6I+5L8pcp2KuxV2KuxV2KuxV2K
uxVnf5ZeVY7+5bV7xOVtatxt42GzyjfkfZP1/LNt2ZpRM8cuQ5e95vt/tE44+FA+qXPyH7Xq2dA8
U7FXYq7FUPqGn2moWctndxiSCZeLqf1jwI7HIZMYnExPItuDNLFMTiakHhPmLRJ9F1aewlPIIeUU
n88bbq39ffOT1OA4pmJfSNDq46jEJj4+RSzKHMdirsVdirsVdirsVdirsVeg/lBcldQ1G2rtJEkl
P+MbFf8AmZm47Hl6pDyeX9p8dwhLuJHz/sYBI5kkZz1clj9JrmnJsvTxFCluKXYq7FXYq7FXYq7F
XYq7FXvvlfTl07y/YWgFGSFWk/13HJ/+GJzr9Lj4McR5PmXaGc5c85ef2DYJpl7huxV2KuxV2KvO
/wA3tOU29hqIFHV2t3bxDAug+ji2aXtfHtGXweq9mc54p4/879B/Q8yzRvXuxV2KuxV2KuxV2Kux
V2Ksy/KmTh5nda09S2kWnj8SN/xrmy7KP734Og9o43pvdIfpYbmtd+7FXYq7FXYq7FXYq7FXYq7F
X0ZZsr2kDqaq0akH2KjO0gdg+U5RUiPNVyTB2KuxV2KuxVhX5sui+W4VO7PdJx+hHJOavtY/uh/W
/W9B7Ng/mCf6B+8PI8517l2KuxV2KuxV2KuxV2KuxVl35XAnzWlB0hlr9wzY9l/33wLo/aH/ABY/
1gxa5i9K5li6em7LT/VNM18hRIdzjlxRB7wpYGbsVdirsVdirsVdirsVdir238v9YTUvLVsK/v7M
C2mXv8Aoh+lKZ1PZ+bjxDvGz5521pTi1Eu6XqHx5/ayTM11LsVdirsVdiryv82dXSfUbbTI2qLRT
JNT/AH5JSgPyUV+nNB2tmuQgOj2ns3pTHHLIf4uXuH7fuYFmoeldirsVdirsVdirsVdirsVZr+U0
RbzJO/aO1c/SXQUzadkj96f6v6nnvaSVacDvkPuKQea7Y23mXU4qUH1iRlH+S7cx+DZh6uPDlkPN
2nZuTj08D/RH2bJTmO5rsVdirsVdirsVdirsVdiqeeUfM8/l/UxOAZLSUBLqEftL2Yf5S9vuzK0e
qOGd9Orre0+zxqcfDykOR/HQvbdP1Cz1C0ju7OVZoJBVXX9R8CO4zqceSMxcTYfPc2CeKRjMVIIj
JtTsVdirH/N3m6z0Gzb4lk1CRf8AR7f/AI3fwUfjmHrNZHDH+l0DtOzOzJ6mfdAcz+gebxK5uJrm
4kuJ3Mk0rF5HPUsxqTnLSkZGzzfQscBCIjHYBTwM3Yq7FXYq7FXYq7FXYq7FXo35P2pM2pXRGyrF
Ep8eRZm/4iM3XY8d5H3PKe0+TaEfefuSr807A2/mX6yB8N5Cj1/yk/dkfcozH7Vx1lvvDm+zubi0
/D/NJ+3dh2a13zsVdirsVdirsVdirsVdirsVTPRPMWraLOZbCcoG/vIm+KN/9ZT+vrl+DUzxG4lw
9XocWojUx8eoZ1p35vW5UDUbB1cfae3YMD8kcrT/AILNrj7XH8Ufk85n9mZX+7n/AKb9Y/Uj3/Nn
y2qVWG6dj+zwQfeS+XHtbF3S/HxcUezeovcw+Z/UkGr/AJs6jOjR6ZbLaA7etIfUk+YFAo+muYmb
taR2gKdppfZvHE3klxeXIfr+5g1xc3FzM89xI0s0hq8jksxPuTmplIyNnm9FDHGA4YigFPAzdirs
VdirsVdirsVdirsVdir2L8rbE2/lgTsKNdzPKCf5VpGP+IHOk7Lx8OK+8vB+0Obi1NfzQB+n9KG/
NjSzcaLBfoKvZSUc/wDFctFP/DBch2tivGJfzf0tvs3qOHMYH+MfaP2W8mznnt3Yq7FXYq7FXYq7
FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqvhikmlSKNeUkjBEUdSzGgGEAk0GMpCIJ
PIPoTSrBNP0y1sk3W3iSOo7lRQn6TvnY4sfBER7g+XanMcuSUz/Ebb1Kwh1DT7iym/u7iNo2PhyF
AR8uuHLjE4mJ6rgzHFMTHOJt8+3tpPZ3c1pOvGaB2jkHuppnHTgYyIPMPp+LKMkRKPIi1HItjsVd
irsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirLvyz0U3/AJgW6da2+nj1WPb1
DtGPv+L6M2PZmHjyX0j+A6Pt/V+Hg4R9U9vh1/V8XsedK8E7FXmH5reXzFcx61AvwT0iuqdnUURv
9kop9GaHtXT0fEHXm9j7Oa24nCeY3Hu6vPc071DsVdirsVdirsVdirsVdirsVdirsVdirsVdirsV
dirsVdirsVdirsVbAJNBuT0GKHuPkjQP0LoUUMi8buf99deIZhsv+xG3zzqtDp/CxgHmeb512trf
zGYkfSNh+PNP8zHWOxVD6jp9tqNjPZXK8oJ1KOO/sR7g7jIZMYnExPItuDNLFMTjzi8F1zR7vR9T
msLkfHGfgfs6H7Lj2Izkc+E45mJfS9Jqo58YnHr9h7kBlTkuxV2KuxV2KuxV2KuxV2KuxV2KuxV2
KuxV2KuxV2KuxV2KuxV2Ks2/LTyub/UP0rcpWzs2/dA9HmG4+hOvzpm07M0vHLjP0j73nu3+0PCh
4UT65c/Iftet50Tw7sVdirsVY1548ppr2n8oQF1G3BNu525DqY2Pge3gcwddpPFjt9Q5O37I7SOm
nR/u5c/1vFpopYZXhlQxyxsVdGFCrA0IIzmCCDRfQIyEgCNwVmBk7FXYq7FXYq7FXYq7FXYq7FXY
q7FXYq7FXYq7FXYq7FXYqm/lny7d69qaWkNViX4rmfsiV6/M9hmRpdMcsqHxcHtDXR02PiPPoO8v
ctPsLXT7KGztU9OCBQqL/E+JPU51ePGIRERyD5zmzSyzM5G5FEZNqdirsVdirsVYX588jDVkbUdP
ULqSL+8jFAJlH/G47Hv0zV6/Q+J6o/V970HY3a/gnw8n92f9j+x5I6OjsjqVdSQykUII2IIOc6RT
3AIIsLcUuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KpjoWhX+tX6WdmlSd5JD9iNO7Mcuw
YJZZcMXE1mshp4cc/wC17b5f8v2Oh6etnaCp+1NMftSP3Y/wGdTp9PHFHhD57rdbPUZOOXwHcmeX
uG7FXYq7FXYq7FXYqxLzn5DttbVryz4waoBux2SUDs9Oh8G/zGu1ugGX1R2n97vOyu2Jaf0T3x/d
7v1PIr6xvLG5e1vImguIzR42FD/aPcZzk8coGpCi9xhzQyREoG4lQyLa7FXYq7FXYq7FXYq7FXYq
7FXYq7FXYq7FXYqnflnylqev3HGBfStENJrth8C+w/mb2zK02knmO3Lvdd2h2lj00fVvLoPxyD2X
Q9B07RbJbSyj4jrJIaF3b+ZjnTYMEcUai8DrNZk1E+KZ/UExy5xXYq7FXYq7FXYq7FXYq7FUq8we
WdK1229K9j/eKD6Nwm0iE+B8PY7Zj6jTQyipfNzdFr8umlcDt1HQvJPMvkjWNDZpHX6zY1+G6jBo
B/lr1Q/h75zup0M8W/OPe9voO1sWo2Hpn3H9Hex7MN2rsVdirsVdirsVdirsVdirsVdirsVXIjyO
qIpZ2ICqoqST0AAxAtBIAss+8rflfcXHC61ysEHVbNTSRv8AXI+wPbr8s3Gl7LJ3ybDueZ7R9oIx
9OHc/wA7p8O/7ve9MtbW2tbdLe2jWGCMUSNBQAZvIwERQ2DyGTJKcjKRslVyTB2KuxV2KuxV2Kux
V2KuxV2KuxVplVlKsAVIoQdwQcUg0wnzP+WenXyvc6SFs7zcmH/dLn5fsH5be2arVdmRnvDY/Y9D
2f2/kx+nL6o9/UfreWXtjd2N1Ja3cTQ3ERo8bChH9QexzQTgYGpCi9nizRyREom4lQyLY7FXYq7F
XYq7FXYq7FXYqnXlnypqWv3JS3Hp20ZAnuW+ytew/mb2zK02klmO3Lvdf2h2lj00blvI8g9c8v8A
lDRdDQG1i9S6pR7qSjSHxp/KPYZ0Wn0cMXIb97w2t7Tzag+o1HuHL9qd5lOvdirsVdirsVdirsVd
irsVdirsVdirsVdirsVY55z8o2+vWJaMKmpQgm3m6cu/psf5T+GYWt0gzR2+ocnbdldpy00998Z5
j9I/G7xSaGWCZ4ZkMcsbFJEbYhgaEHOXlEg0X0GExIAjcFZgZOxV2KuxV2KuxV2Kpz5V8t3Ov6mt
tHVLdKPdT/yJXt/lHtmTpdMc06HLq4HaOvjpsfEfqPId5/V3vcNP0+00+zis7SMRwQrxRR+s+JPc
51WPGIRERyD51nzSyzM5G5FEZNqdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVecfml5YBU
a7apuKJfKO46JJ/xqfozSdq6X/KD4/res9ne0P8AIyP9X9I/T83muaR612KuxV2KuxV2Kr4YpJpU
hiUvLIwREHUsxoAPmcIBJoMZSEQSeQe6+U/L0WhaRHaihuH/AHl1IP2pCN9/BegzrNJpxihXXq+c
dpa46nKZfw9PcnOZLr3Yq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqp3NtDdW8tvOgeGZ
SkiHoVYUIyMoiQo8izx5DCQlHYh4H5h0abR9XuLCSpETfunP7UZ3Rvu6++cjqMJxzMS+l6LVDPiE
x15+/ql2UuW7FXYq7FXYqz38q/Lv1m9fWZ1rDakpbV7ykbt/sVP3n2zb9lafilxnkOXveZ9otdwQ
GKPOXP3fteqZv3jHYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqwj80fLxvdMTVI
FrcWIIlA6tCTv/wB3+Vc1Xamn4ocY5x+56L2e1vh5PCl9M+Xv/b+p5LnPPbuxV2KuxVXsrO4vbuG
0t15zzuEjX3JpkoQMiAOZa8uWOOJlLkHvui6Vb6Tpdvp8G6QLQt3ZjuzH5nOvwYhjgIjo+ZavUyz
ZDOXVG5a47sVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVadEkRkdQyOCrKRUEHYg
jARaQSDYeGecfLcuhau8IBNnNV7SQ90/lJ8V6HOV1mmOKdfwnk+jdl68anED/GPq/HmkWYjsnYq7
FXpn5W+WDGp126SjOClip6hTs0n09B9ObzsvS1+8Pw/W8h7Q9oX+5if636A9FzdPKuxV2KuxV2Ku
xV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVLfMGgWOuae1ndCn7UMo+1G/Zh/EZRqNPHL
HhLl6LWz0+Tjj8R3h45r3k3XdGlYTQNNbA/BdRAshHvT7J9jnNZ9HkxHcWO97zR9qYc42NS7jz/a
k0UUsrhIkaR26KoJJ+gZjAE8nYSkALJoM38o/lve3cyXesxtb2SnkLZqrLJ7EdUXxrv+vNppOzZS
PFPaPc872n27CAMcJ4p9/QfrerRokaLHGoVEAVVAoABsABnQAU8WSSbPNvCh2KuxV2KuxV2KuxV2
KuxV2KuxV//Z</xmpGImg:image> + </rdf:li> + </rdf:Alt> + </xmp:Thumbnails> + </rdf:Description> + <rdf:Description rdf:about="" + xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" + xmlns:stRef="http://ns.adobe.com/xap/1.0/sType/ResourceRef#" + xmlns:stEvt="http://ns.adobe.com/xap/1.0/sType/ResourceEvent#"> + <xmpMM:InstanceID>uuid:577a9a77-0f3a-449e-a3e6-c1dc0aec6600</xmpMM:InstanceID> + <xmpMM:DocumentID>xmp.did:638502CEC546E61188ECE0D1B3990392</xmpMM:DocumentID> + <xmpMM:OriginalDocumentID>uuid:5D20892493BFDB11914A8590D31508C8</xmpMM:OriginalDocumentID> + <xmpMM:RenditionClass>proof:pdf</xmpMM:RenditionClass> + <xmpMM:DerivedFrom rdf:parseType="Resource"> + <stRef:instanceID>uuid:4e413f3b-d2a3-475f-a59f-2bd2d181921e</stRef:instanceID> + <stRef:documentID>xmp.did:972B0CA11846E611AAF7DAEC96866847</stRef:documentID> + <stRef:originalDocumentID>uuid:5D20892493BFDB11914A8590D31508C8</stRef:originalDocumentID> + <stRef:renditionClass>proof:pdf</stRef:renditionClass> + </xmpMM:DerivedFrom> + <xmpMM:History> + <rdf:Seq> + <rdf:li rdf:parseType="Resource"> + <stEvt:action>saved</stEvt:action> + <stEvt:instanceID>xmp.iid:972B0CA11846E611AAF7DAEC96866847</stEvt:instanceID> + <stEvt:when>2016-07-10T06:03:47+09:00</stEvt:when> + <stEvt:softwareAgent>Adobe Illustrator CS6 (Windows)</stEvt:softwareAgent> + <stEvt:changed>/</stEvt:changed> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <stEvt:action>saved</stEvt:action> + <stEvt:instanceID>xmp.iid:638502CEC546E61188ECE0D1B3990392</stEvt:instanceID> + <stEvt:when>2016-07-11T02:43:26+09:00</stEvt:when> + <stEvt:softwareAgent>Adobe Illustrator CS6 (Windows)</stEvt:softwareAgent> + <stEvt:changed>/</stEvt:changed> + </rdf:li> + </rdf:Seq> + </xmpMM:History> + </rdf:Description> + <rdf:Description rdf:about="" + xmlns:illustrator="http://ns.adobe.com/illustrator/1.0/"> + <illustrator:Type>Document</illustrator:Type> + <illustrator:StartupProfile>Print</illustrator:StartupProfile> + </rdf:Description> + <rdf:Description rdf:about="" + xmlns:xmpTPg="http://ns.adobe.com/xap/1.0/t/pg/" + xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#" + xmlns:xmpG="http://ns.adobe.com/xap/1.0/g/"> + <xmpTPg:HasVisibleOverprint>False</xmpTPg:HasVisibleOverprint> + <xmpTPg:HasVisibleTransparency>False</xmpTPg:HasVisibleTransparency> + <xmpTPg:NPages>1</xmpTPg:NPages> + <xmpTPg:MaxPageSize rdf:parseType="Resource"> + <stDim:w>256.000000</stDim:w> + <stDim:h>256.000000</stDim:h> + <stDim:unit>Pixels</stDim:unit> + </xmpTPg:MaxPageSize> + <xmpTPg:PlateNames> + <rdf:Seq> + <rdf:li>Magenta</rdf:li> + <rdf:li>Yellow</rdf:li> + </rdf:Seq> + </xmpTPg:PlateNames> + <xmpTPg:SwatchGroups> + <rdf:Seq> + <rdf:li rdf:parseType="Resource"> + <xmpG:groupName>初期設定のスウォッチグループ</xmpG:groupName> + <xmpG:groupType>0</xmpG:groupType> + <xmpG:Colorants> + <rdf:Seq> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>ホワイト</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>255</xmpG:red> + <xmpG:green>255</xmpG:green> + <xmpG:blue>255</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>ブラック</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>37</xmpG:red> + <xmpG:green>30</xmpG:green> + <xmpG:blue>28</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>CMYK レッド</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>196</xmpG:red> + <xmpG:green>0</xmpG:green> + <xmpG:blue>24</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>CMYK イエロー</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>255</xmpG:red> + <xmpG:green>239</xmpG:green> + <xmpG:blue>0</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>CMYK グリーン</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>0</xmpG:red> + <xmpG:green>151</xmpG:green> + <xmpG:blue>75</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>CMYK シアン</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>0</xmpG:red> + <xmpG:green>158</xmpG:green> + <xmpG:blue>229</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>CMYK ブルー</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>35</xmpG:red> + <xmpG:green>37</xmpG:green> + <xmpG:blue>131</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>CMYK マゼンタ</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>193</xmpG:red> + <xmpG:green>0</xmpG:green> + <xmpG:blue>123</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=15 M=100 Y=90 K=10</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>166</xmpG:red> + <xmpG:green>21</xmpG:green> + <xmpG:blue>39</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=90 Y=85 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>199</xmpG:red> + <xmpG:green>58</xmpG:green> + <xmpG:blue>45</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=80 Y=95 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>203</xmpG:red> + <xmpG:green>85</xmpG:green> + <xmpG:blue>32</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=50 Y=100 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>220</xmpG:red> + <xmpG:green>149</xmpG:green> + <xmpG:blue>15</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=35 Y=85 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>230</xmpG:red> + <xmpG:green>180</xmpG:green> + <xmpG:blue>62</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=5 M=0 Y=90 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>246</xmpG:red> + <xmpG:green>236</xmpG:green> + <xmpG:blue>53</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=20 M=0 Y=100 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>218</xmpG:red> + <xmpG:green>222</xmpG:green> + <xmpG:blue>26</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=50 M=0 Y=100 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>157</xmpG:red> + <xmpG:green>193</xmpG:green> + <xmpG:blue>56</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=75 M=0 Y=100 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>99</xmpG:red> + <xmpG:green>169</xmpG:green> + <xmpG:blue>69</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=85 M=10 Y=100 K=10</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>68</xmpG:red> + <xmpG:green>143</xmpG:green> + <xmpG:blue>67</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=90 M=30 Y=95 K=30</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>46</xmpG:red> + <xmpG:green>105</xmpG:green> + <xmpG:blue>58</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=75 M=0 Y=75 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>98</xmpG:red> + <xmpG:green>171</xmpG:green> + <xmpG:blue>107</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=80 M=10 Y=45 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>81</xmpG:red> + <xmpG:green>160</xmpG:green> + <xmpG:blue>152</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=70 M=15 Y=0 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>100</xmpG:red> + <xmpG:green>165</xmpG:green> + <xmpG:blue>221</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=85 M=50 Y=0 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>62</xmpG:red> + <xmpG:green>110</xmpG:green> + <xmpG:blue>179</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=100 M=95 Y=5 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>35</xmpG:red> + <xmpG:green>45</xmpG:green> + <xmpG:blue>131</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=100 M=100 Y=25 K=25</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>30</xmpG:red> + <xmpG:green>33</xmpG:green> + <xmpG:blue>96</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=75 M=100 Y=0 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>83</xmpG:red> + <xmpG:green>31</xmpG:green> + <xmpG:blue>129</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=50 M=100 Y=0 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>124</xmpG:red> + <xmpG:green>15</xmpG:green> + <xmpG:blue>128</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=35 M=100 Y=35 K=10</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>139</xmpG:red> + <xmpG:green>19</xmpG:green> + <xmpG:blue>92</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=10 M=100 Y=50 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>183</xmpG:red> + <xmpG:green>0</xmpG:green> + <xmpG:blue>80</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=95 Y=20 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>196</xmpG:red> + <xmpG:green>26</xmpG:green> + <xmpG:blue>112</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=25 M=25 Y=40 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>195</xmpG:red> + <xmpG:green>186</xmpG:green> + <xmpG:blue>155</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=40 M=45 Y=50 K=5</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>155</xmpG:red> + <xmpG:green>137</xmpG:green> + <xmpG:blue>120</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=50 M=50 Y=60 K=25</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>116</xmpG:red> + <xmpG:green>105</xmpG:green> + <xmpG:blue>87</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=55 M=60 Y=65 K=40</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>92</xmpG:red> + <xmpG:green>78</xmpG:green> + <xmpG:blue>66</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=25 M=40 Y=65 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>188</xmpG:red> + <xmpG:green>157</xmpG:green> + <xmpG:blue>102</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=30 M=50 Y=75 K=10</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>164</xmpG:red> + <xmpG:green>128</xmpG:green> + <xmpG:blue>75</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=35 M=60 Y=80 K=25</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>134</xmpG:red> + <xmpG:green>97</xmpG:green> + <xmpG:blue>58</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=40 M=65 Y=90 K=35</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>116</xmpG:red> + <xmpG:green>80</xmpG:green> + <xmpG:blue>40</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=40 M=70 Y=100 K=50</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>95</xmpG:red> + <xmpG:green>59</xmpG:green> + <xmpG:blue>19</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=50 M=70 Y=80 K=70</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>60</xmpG:red> + <xmpG:green>38</xmpG:green> + <xmpG:blue>23</xmpG:blue> + </rdf:li> + </rdf:Seq> + </xmpG:Colorants> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:groupName>グレー</xmpG:groupName> + <xmpG:groupType>1</xmpG:groupType> + <xmpG:Colorants> + <rdf:Seq> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=0 Y=0 K=100</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>37</xmpG:red> + <xmpG:green>30</xmpG:green> + <xmpG:blue>28</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=0 Y=0 K=90</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>63</xmpG:red> + <xmpG:green>60</xmpG:green> + <xmpG:blue>60</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=0 Y=0 K=80</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>89</xmpG:red> + <xmpG:green>88</xmpG:green> + <xmpG:blue>88</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=0 Y=0 K=70</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>112</xmpG:red> + <xmpG:green>112</xmpG:green> + <xmpG:blue>112</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=0 Y=0 K=60</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>134</xmpG:red> + <xmpG:green>134</xmpG:green> + <xmpG:blue>135</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=0 Y=0 K=50</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>157</xmpG:red> + <xmpG:green>157</xmpG:green> + <xmpG:blue>157</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=0 Y=0 K=40</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>179</xmpG:red> + <xmpG:green>179</xmpG:green> + <xmpG:blue>179</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=0 Y=0 K=30</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>199</xmpG:red> + <xmpG:green>200</xmpG:green> + <xmpG:blue>200</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=0 Y=0 K=20</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>219</xmpG:red> + <xmpG:green>219</xmpG:green> + <xmpG:blue>219</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=0 Y=0 K=10</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>238</xmpG:red> + <xmpG:green>238</xmpG:green> + <xmpG:blue>238</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=0 Y=0 K=5</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>247</xmpG:red> + <xmpG:green>247</xmpG:green> + <xmpG:blue>247</xmpG:blue> + </rdf:li> + </rdf:Seq> + </xmpG:Colorants> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:groupName>輝き</xmpG:groupName> + <xmpG:groupType>1</xmpG:groupType> + <xmpG:Colorants> + <rdf:Seq> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=100 Y=100 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>196</xmpG:red> + <xmpG:green>0</xmpG:green> + <xmpG:blue>24</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=75 Y=100 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>206</xmpG:red> + <xmpG:green>96</xmpG:green> + <xmpG:blue>22</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=0 M=10 Y=95 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>247</xmpG:red> + <xmpG:green>224</xmpG:green> + <xmpG:blue>25</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=85 M=10 Y=100 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>73</xmpG:red> + <xmpG:green>152</xmpG:green> + <xmpG:blue>71</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=100 M=90 Y=0 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>33</xmpG:red> + <xmpG:green>52</xmpG:green> + <xmpG:blue>139</xmpG:blue> + </rdf:li> + <rdf:li rdf:parseType="Resource"> + <xmpG:swatchName>C=60 M=90 Y=0 K=0</xmpG:swatchName> + <xmpG:mode>RGB</xmpG:mode> + <xmpG:type>PROCESS</xmpG:type> + <xmpG:red>110</xmpG:red> + <xmpG:green>52</xmpG:green> + <xmpG:blue>137</xmpG:blue> + </rdf:li> + </rdf:Seq> + </xmpG:Colorants> + </rdf:li> + </rdf:Seq> + </xmpTPg:SwatchGroups> + </rdf:Description> + <rdf:Description rdf:about="" + xmlns:pdf="http://ns.adobe.com/pdf/1.3/"> + <pdf:Producer>Adobe PDF library 10.01</pdf:Producer> + </rdf:Description> + </rdf:RDF> +</x:xmpmeta> + + + + + + + + + + + + + + + + + + + + + +<?xpacket end="w"?>
+endstream
endobj
3 0 obj
<</Count 1/Kids[7 0 R]/Type/Pages>>
endobj
7 0 obj
<</ArtBox[48.0 32.0 208.0 224.0]/BleedBox[0.0 0.0 256.0 256.0]/Contents 28 0 R/LastModified(D:20160711024345+10'00')/MediaBox[0.0 0.0 256.0 256.0]/Parent 3 0 R/PieceInfo<</Illustrator 29 0 R>>/Resources<</ColorSpace<</CS0 30 0 R>>/ExtGState<</GS0 31 0 R>>/Properties<</MC0 26 0 R>>>>/Thumb 32 0 R/TrimBox[0.0 0.0 256.0 256.0]/Type/Page>>
endobj
28 0 obj
<</Filter/FlateDecode/Length 242>>stream
+HLQN0+I ɕ8vW8 $-kD=vSp>F8@::[|^Oq=
c~߿qEȟ\"$%t:Nwmܐ|X6FmuU$T-*T8PEM D[JFrʢd\$A$.EKX"m2{Z]yW3<p` +endstream
endobj
32 0 obj
<</BitsPerComponent 8/ColorSpace 33 0 R/Filter[/ASCII85Decode/FlateDecode]/Height 32/Length 148/Width 32>>stream
+8;YPfgBkYL#i'p2jJs60:(!/!cI7lY7M$ic]ipP6VY0ZHEnF/QqtoTd+qoOlCg5@S +!SoKHNpg5f"H8DNiTc;En6\q_5P.1_^["Yuf'a[n!#u+Q\),c.85Rt<639r:'Gbi; +EN/2PbSU>!3BfD~>
+endstream
endobj
33 0 obj
[/Indexed/DeviceRGB 255 34 0 R]
endobj
34 0 obj
<</Filter[/ASCII85Decode/FlateDecode]/Length 428>>stream
+8;X]O>EqN@%''O_@%e@?J;%+8(9e>X=MR6S?i^YgA3=].HDXF.R$lIL@"pJ+EP(%0 +b]6ajmNZn*!='OQZeQ^Y*,=]?C.B+\Ulg9dhD*"iC[;*=3`oP1[!S^)?1)IZ4dup` +E1r!/,*0[*9.aFIR2&b-C#s<Xl5FH@[<=!#6V)uDBXnIr.F>oRZ7Dl%MLY\.?d>Mn +6%Q2oYfNRF$$+ON<+]RUJmC0I<jlL.oXisZ;SYU[/7#<&37rclQKqeJe#,UF7Rgb1 +VNWFKf>nDZ4OTs0S!saG>GGKUlQ*Q?45:CI&4J'_2j<etJICj7e7nPMb=O6S7UOH< +PO7r\I.Hu&e0d&E<.')fERr/l+*W,)q^D*ai5<uuLX.7g/>$XKrcYp0n+Xl_nU*O( +l[$6Nn+Z_Nq0]s7hs]`XX1nZ8&94a\~>
+endstream
endobj
26 0 obj
<</Intent 35 0 R/Name(Layer)/Type/OCG/Usage 36 0 R>>
endobj
35 0 obj
[/View/Design]
endobj
36 0 obj
<</CreatorInfo<</Creator(Adobe Illustrator 16.0)/Subtype/Artwork>>>>
endobj
31 0 obj
<</AIS false/BM/Normal/CA 1.0/OP false/OPM 1/SA true/SMask/None/Type/ExtGState/ca 1.0/op false>>
endobj
30 0 obj
[/ICCBased 37 0 R]
endobj
37 0 obj
<</Filter/FlateDecode/Length 281/N 3>>stream
+Hb``2ptqre``+) +rwRR`? +KRSj!ABPi +endstream
endobj
29 0 obj
<</LastModified(D:20160711024345+10'00')/Private 38 0 R>>
endobj
38 0 obj
<</AIMetaData 39 0 R/AIPrivateData1 40 0 R/AIPrivateData2 41 0 R/AIPrivateData3 42 0 R/AIPrivateData4 43 0 R/AIPrivateData5 44 0 R/ContainerVersion 11/CreatorVersion 16/NumBlock 5/RoundtripStreamType 1/RoundtripVersion 16>>
endobj
39 0 obj
<</Length 1019>>stream
+%!PS-Adobe-3.0
+%%Creator: Adobe Illustrator(R) 16.0
+%%AI8_CreatorVersion: 16.0.3
+%%For: (Eiji Shinoda) ()
+%%Title: (icon.ai)
+%%CreationDate: 7/11/2016 2:43 AM
+%%Canvassize: 16383
+%%BoundingBox: 48 -224 208 -32
+%%HiResBoundingBox: 48 -224 208 -32
+%%DocumentProcessColors: Magenta Yellow
+%AI5_FileFormat 12.0
+%AI12_BuildNumber: 691
+%AI3_ColorUsage: Color
+%AI7_ImageSettings: 0
+%%RGBProcessColor: 0 0 0 ([レジストレーション])
+%AI3_Cropmarks: 0 -256 256 0
+%AI3_TemplateBox: 128.5 -128.5 128.5 -128.5
+%AI3_TileBox: -161.4399 -540.5596 416.96 284.5601
+%AI3_DocumentPreview: None
+%AI5_ArtSize: 14400 14400
+%AI5_RulerUnits: 6
+%AI9_ColorModel: 1
+%AI5_ArtFlags: 0 0 0 1 0 0 1 0 0
+%AI5_TargetResolution: 800
+%AI5_NumLayers: 1
+%AI9_OpenToView: -1243 767 0.5 1373 914 18 0 0 293 117 0 1 0 1 1 0 1 1 0 1
+%AI5_OpenViewLayers: 7
+%%PageOrigin:-178 -524
+%AI7_GridSettings: 32 8 32 8 0 0 0.415686 0.415686 0.415686 0.707843 0.707843 0.707843
+%AI9_Flatten: 1
+%AI12_CMSettings: 00.MS
+%%EndComments
+
+endstream
endobj
40 0 obj
<</Length 13037>>stream
+%%BoundingBox: 48 -224 208 -32
+%%HiResBoundingBox: 48 -224 208 -32
+%AI7_Thumbnail: 108 128 8
+%%BeginData: 12900 Hex Bytes
+%0000330000660000990000CC0033000033330033660033990033CC0033FF
+%0066000066330066660066990066CC0066FF009900009933009966009999
+%0099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF3300FF66
+%00FF9900FFCC3300003300333300663300993300CC3300FF333300333333
+%3333663333993333CC3333FF3366003366333366663366993366CC3366FF
+%3399003399333399663399993399CC3399FF33CC0033CC3333CC6633CC99
+%33CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF660000660033
+%6600666600996600CC6600FF6633006633336633666633996633CC6633FF
+%6666006666336666666666996666CC6666FF669900669933669966669999
+%6699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066FF33
+%66FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF
+%9933009933339933669933999933CC9933FF996600996633996666996699
+%9966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC33
+%99CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFF
+%CC0000CC0033CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399
+%CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933
+%CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFF
+%CCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0033FF0066FF0099FF00CC
+%FF3300FF3333FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699
+%FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33
+%FFCC66FFCC99FFCCCCFFCCFFFFFF33FFFF66FFFF99FFFFCC110000001100
+%000011111111220000002200000022222222440000004400000044444444
+%550000005500000055555555770000007700000077777777880000008800
+%000088888888AA000000AA000000AAAAAAAABB000000BB000000BBBBBBBB
+%DD000000DD000000DDDDDDDDEE000000EE000000EEEEEEEE0000000000FF
+%00FF0000FFFFFF0000FF00FFFFFF00FFFFFF
+%524C45FD2BFFCACAC3C3C2C2BBBCBBC2BBBCBBC2BBC2C2C9C9CACAFD53FF
+%CAC3C2FD12BBB4FD04BBC3C3CFFD4CFFCAC2C2FD07BBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCFD05BBC2C3CFFD46FFCAC2BCFD22BBB5C2C3CFFD41
+%FFC9C2BBBBBBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBC2BBBBBBC3CAFD3CFFCFC9FD2CBBB5BBBCCAFD39
+%FFC3C2BBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCFD05BBC2CAFD35FFCABBBBB5FD
+%33BBC2FD33FFC9BBBBBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBBBBBCAFD2FFFCABCB4FD3ABBC2CAFD2CFFC3FD04BBBCBBBBBBBCBBBB
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBC
+%BBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBCCAFD2AFFBCFD41BB
+%C2FD28FFBBBBBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBBBC2FD25FFCAFD46BBBCFD23FFCABBBBBBBCBB
+%BBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBB
+%BBBBBCFD04BBBCFD21FFC9B4FD4ABBCFFD1EFFCABBBCBBBCBBC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2
+%BBBCBBC2BBBBBBFD1DFFCAFD4FBBFD1BFFCFFD05BBBCBBBBBBBCBBBBBBBC
+%BBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBC
+%BBBBBBBCBBBBBCFD1AFFFD52BBC2FD18FFC2BBBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBBBC9FD16FFC2FD55BBCAFD14FFCABBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBB
+%BBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBC2FD13FFCABCFD56BBB4
+%C3FD12FFC9BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBCAFD10FFCAFD5BBBFD10FFC2BBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBB
+%BBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCFD04BBC3FD0EFFC3FD5DBBCAFD0DFFC2BB
+%BCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBB
+%C2BBC9FD0CFFC9B4FD5EBBCFFD0BFFBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBB
+%BBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCFD04BBC3FD0AFFC3FD61BBCAFD
+%09FFC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBB
+%C2BBBCBBC2BBBCBBC2BBC9FD08FFCAFD63BBFD08FFC2BBBBBCBBBBBBBCBB
+%BBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBB
+%BBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCFD04BB
+%CAFD06FFCABCFD62BBB4C3FD06FFCABBBCBBC2BBBCBBC2BBBCBBC2BBBCBB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBBCBBFD06FFC2
+%FD65BBC9FD05FFC2BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBC
+%BBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBC
+%BBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBC3FD04FFCAFD36BBB4FD30
+%BBFD04FFC3BBBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBBBBC2C3CACAFFCAFFCACA
+%C2BCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2CAFFFFFFC2FD2DBBC9CAFD09
+%FFCAC3FD2CBBB4CAFFFFFFFD05BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBC2FD0EFFCFFD05
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBC
+%BBBBBBBCBBBBBBBCBBBBC2FFFFCAFD2BBBC3FD11FFBCFD2ABBC2FFFFCABB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBBBC9FD13FFC2BBBBC2BBBCBBC2BBBCBBC2BBBCBB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBBBBFFFF
+%C3FD29BBC2FD15FFFD2ABBCAFFC3BBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBC
+%BBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCFD05BBFD16FFCABBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBB
+%BBBBBCBBBBBBBCBBCAFFBCFD27BBB4C9FD17FFC2FD28BBC3FFC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2FD18FFCABBBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBC9FFFD28BBC2FD19FFFD
+%28BBC2FFBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBCAFD19FFC3BBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBC9FF
+%FD28BBCAFD19FFC3FD27BBBCFFBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBFD1AFFCABBBCBB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC3FFFD27BBBCCAFD19FFC9FD27BBC2FFBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBB
+%BBBBFD1AFFCABBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBC2FFFD28BBCAFD19FFC2FD27BBC2FF
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBCAFD19FFC2BBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBC9FFFD28BBC2
+%FD19FFFD28BBC2FFC2BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBCAFD17FFC3FD04BBBCBBBB
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBC
+%BBBBBBC9FFBCFD27BBB5C3FD17FFBCB5FD27BBC3FFC9BBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2
+%BBBBBBFD16FFC9BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBCFFFC3FD2ABBFD14FFCAFD2A
+%BBCAFFCABBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBC2FD13FFFD05BBBCBBBBBBBCBBBB
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCFD05BBFF
+%FFCAFD2BBBBCFD10FFCAFD2ABBB5C2FFFFFFBBBBBBC2BBBCBBC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBBBBBCAFD0DFFC9BBBBBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBBBC3FFFFFFC2FD2DBB
+%C2C9FD09FFC3BCB4FD2BBBB4CAFFFFFFC3FD04BBBCBBBBBBBCBBBBBBBCBB
+%BBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBC3C3CAC9CAC3C2FD05BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBCAFFFF
+%FFCAB5FD2FBBB4BBB5BBB4FD32BBFD05FFC2BBBCBBC2BBBCBBC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBC3
+%FD05FFC2FD65BBC9FD05FFCABBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBC
+%BBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBC
+%BBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBFD06FFCAFD64BB
+%C2FD07FFC2BBBBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBC2BBBBC9FD07FFCAB5FD62BBFD09FFC2BBBCBB
+%BBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBB
+%BBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBC9FD09FFC3FD61BBCAFD0AFFBBBBBBBCBBC2BBBCBBC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBBC2FD0BFFC9B4FD5E
+%BBCFFD0CFFC2BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBC
+%BBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBBCBBBBBBC9FD0DFFC3FD5DBBCAFD0EFFC2BBBBBCBBC2BBBCBB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBBC3FD0FFFCFFD5A
+%BBBCFD11FFC9BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBC
+%BBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBFD13FFC2B4FD55BBB4C9FD13FFCAC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBC2FD15FFC3FD54BBC2CAFD16FFC3FD
+%04BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBB
+%BBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBCAFD18FFC2FD51BBC3
+%FD1AFFC2BBBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBBBC3FD1CFFFD4EBB
+%C2FD1EFFBCBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBC
+%BBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCFD04BBC3FD20FFFD4ABBC2FD22FF
+%C2BBBBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBBC3FD24FFC2FD45BBC9FD26FFC3FD04BBBCBB
+%BBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBB
+%BCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBB
+%C2CAFD28FFC9BCB5FD3DBBB4C2CAFD2BFFC2BBBCBBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBBBBC9FD2EFFC9FD3BBBCAFD31
+%FFC2FD04BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBC
+%BBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBC3FD34FF
+%C3BCB5FD31BBB4C2CAFD37FFC9BBBBBBBCBBC2BBBCBBC2BBBCBBC2BBBCBB
+%C2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BB
+%BBBBCAFD3AFFCFBCFD2DBBC3FD3EFFC3FD04BBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBC2CAFD
+%40FFC9FD27BBB4C2CAFD42FFCABCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2FD45FFCAFD24BBC2
+%CAFD46FFC9BBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBBCBBBBBBC2CFFD48FFC2FD21BBC9FD4AFFBCBBBBBCBBC2BBBC
+%BBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBBC3FD4BFFCAFD
+%1FBBFD4DFFC3BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBCFFD4DFFCABBB5FD19BBB5C2FD4FFFC3BBBBBCBBC2BBBCBBC2
+%BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCCAFD50FFFD1ABBC3FD51FFCA
+%BBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBBBBFD53FFC2B5
+%FD15BBB5C9FD53FFCABBBBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2BBBCBBC2
+%BBC2FD55FFBCFD15BBC3FD55FFCABBBCBBBBBBBCBBBBBBBCBBBBBBBCBBBB
+%BBBCBBBBBBFD57FFC2B4FD11BBB5CAFD57FFCAC2BBBCBBC2BBBCBBC2BBBC
+%BBC2BBBCBBC2BBC2FD59FFC2FD11BBCAFD5AFFFD05BBBCBBBBBBBCBBBBBB
+%BCBBBBC2FD5BFFCAB5FD0EBBFD5DFFCABBBBBBC2BBBCBBC2BBBCBBBBBBFD
+%5FFFC9FD0BBBCFFD60FFCABBBBBBBCFD04BBBCFD64FFC3BCBBBBBBC2C3FD
+%32FFFF
+%%EndData
+
+endstream
endobj
41 0 obj
<</Length 65536>>stream
+%AI12_CompressedDataxKeYv6/Cx@[p~6W[&$Eɪd3BVVS'bׄx`D0<1`˖!X2_gx}ZqUؙqWcZz_`٫?~p|ogo^
7?z7..8j;ū?nzwzg/n~/^o}w^ŧ^>{~Esoc<{g_}=eʫ_}/Oxכxo"/~wt|_<g_}>/?w?Nܽ<8~g\:WGz]Wo;!_<FJ*7_~?7]/x3y$oN/yŗ˛p~M7?v<!6Zo~rR7QX~5ަ/^<֫^YˢO?7rיo짯>{1_yP7kOs_Ϋʳ+,x[y_Ís.=BOc3~/^DK>7>mt)/e)/Ly=O`}\Q~i7/QIDg_D[}G_}_ɫ?{ +4ҍ\o}~Ga/m'J[iYZ-e'$(
kVi">KKҢ e{ݮVi.KKҢ4va祹ݲ[?;Gim\~_Oq)g]\c
e=GݺeMk\]9ؗZRJ*"q'y)\E9}K'yITSI9KK<ţ]\c9K8h_% O|X?> fqGy{W]="ОJ>%{~+8OY}~{]&=^?|"M+yi~eƄ/yrKZ%W +tvv"ey2,!#NElü>J;Iw,x2262i_27ܬL=VG9J;I+w"
oK]c.ߝYeK}xwuJ{^{~efu2iV.VEْI-jLU&9A Eee.*n/(崻dYI^Oe\eUeqeɲwߗV\K{cNXQ^YB!\{2_^WtYP!CdO"p&CY?|An<pxw;-''͟d-")o|ͧ;.rCV<~<mpc?,7qiGy"ູT<_SZ#WƎ/#1܂6/{QoEbTdK]\ƋorSbFЋ?y_ť>r^W^噳gga>d9}2:r1uhw'7Zq|NIK_5ʒԙ]{FX/ }$12{$GK/ue9_<ڸO#Q#͖vY4o?ee e;|rwNd'D!oة'ڮ7wuo!~mWƞssɞߢP7s-k["O(K.k"8ێm=.c7q'ZdKK`]8_)h~s6_eQ&yev2N6+sNp،)CzWb[(xx;nJvٖ+廕BC-;R7v0=&rKy)x(^Н4ig$'^e"/^(\\Fȍ7#Sc 읲2^:9e<lU`ad86_*j3 +=DCP8PX]@p(NKQ7@w䟪L3cG/[23JfґsiΝkDEf +#5uBQb7
AūJ-!E|yj +wAƑF>˩cs%cOG:33"3^q$еUteNýI_*i;Yv4nstּК?Zkjmgmo_R&vmo-XRoٚiV?;mw5axkZފZ&=]_mIBw6UXLwEwXd:j+Yn!~*Ӊ +jɞVW0mB01Į1lu5@g86zޭl~9,5͵S_aD7:_,ѹfD}63_HSƑM_%&! +ȲE2l4B0/KV OԽ
FWhCrݣ\{xsDfF2ͲVy4ҙ6`pPMskgGcm16>2 +d*QiWbV4&)Q2e08]M <ɜi՜cCMm?r4#D_3% ӷ;]zz<^Oflmܧ +se㏮ô5JD˗Z8cGɾ8BjVSqTZmJ?=A֟71֭@3%ۡYom~on~{ +
ĸ@Q)k2i8F3 u'"qxlL21hY:8"ʩ):xc(p@NcqgCZ*L}/wUjUGՋ;Q傩6<i%}<PV.ZMxE;2 +s[jpu$>,W(ۙ-ވ}d&xPՊ|0ԟA= 5wk;
mJΰۯfOƭV{ܽ;"VA{Q +52] $kpeinj+B0>(iC*CXwB-ůe.ч-ŷm&[3H>qS3$QxyXW75G{Go=B +캞";Z,b~E +Wۼ'{'3^8yxQ)7@<PU||g"B楗=>&%KKz[41ݟp Z0\kMgef:)@SB.aUɠ{5 `Byxɦ lw\ 1*4w+<GOrG|K!!]7ay'曤D=E0D{>lC?D͝!LlSԀЄU +
cs3sȚݽtN(8dUmSfgSElZF'4's֣6ذLwQ#0"{::b9Et0[^$(M3IZ 7}{;ޕp;lg1C߸0b.cb4"-ʔI=ܙ\[jYe1Z\+nMzuxw5⚭o\Py
p-#'5yX6HsO9!m-}aBˁt?>ckL'q5Fs\Q!A|V^☏k/1ѐLZ4P֒O>!L~p콍1~h6s$Ef߄7kt8LXq|
C]9`falлœiz.ڼ 4SkLyH]p+pхMP_1ZNkT[>G]sYdFnjSb긡3&O[Ll)Sc|pZ0ݦ\߯e/*5G{DUفŞQbrbǴneO[~C&wىu|ކF,0J4D10E4<¥Іl[<ΠϹd~V7mzä SKfٓxMʞoXz
K$9lÖ-7"#L7ҋ 'H3ZojhCB~iSKuB?X'D&2fj,0`n7y鞦?qa\$;E;#*`U0K5{t^?ɻnicmOeKSeɜ(̻}4 ]s{ɋغ>][uc%"K2aͶ0~?mݝ6N$1d}M[p俕k];5u.`d7'{M~2~D@;C0YXR0J" +w;/,yw)/$UHFl iݯz7_ݲNY3팣ۘ۷Zt79}n>gϋ69T5FUFƨi}:ҽݟAAW+ʠ)!:Zde-R["bo$K] Ms}{udXnU6-&p*4U7|֓tzxn<IԽM8K&ЀEQ7ʍ(zА>"5G=Ų鴪s1:t'WN{HVbrcj<̈́nՊ|gwFxZL?GTFԋM"`b^NSZ\o;E]Hq&(+zDoʏZ*;+?:jJu8RaŞ6]8ލ}zEyֈYFl={Iy/\qg6+17"R2N2J"Wb#樈)"b 1E@ȇТ6Q
=vAc\}<Й.uݬ&}j!RUjI^u7)U"^5T"
[|fp;nrC-ƹVPэcϻ"J<E'J͗=<(rޓ>+ϹϮ˖{_-<tEX7lzzCUt JῈQ=ylleDІ亣zs^wћh2ƏZi{E"-J#685]L?.;G#zS(g~7)q|eN|zxK{cBl[yBK+mвq8&zO]zw7תQ|Oj!#9GrׂSȆ
'ZHVwݿfkmsl>DA$5ڴ\~Suش{yзc @&yEO^((6w$QŴSI1җ>hV;}d,ȁձku2J#1A@!ʇ\*r|ȥ!ʇ\*r|ȥ!ʇ\*r|ȥ!ʇ\*r<ur|ȥ!ʇ\*r|ȥ"JʷEӹI?dSM%]w4IYmc&>Rlb3}ff`Cﴰt-cѴDc5^S`GGeXd\>1\tӅi%mLh5Ʃ.$Tvz${j@E#MTB.sVYOd3}l+)STpZ7ϺIwMh/^ͶpһA
=ˌm6ine!96;.}4Ε2Y-Xcd-Q<oCH%[p͑^!"Kj6$uavob0W쟠MP2}"[ACjBUHAkTz<X
CGA贀\8iGHh\D^F|8ti2!m< +4dQ[:=;%c$jyBE?ɒ6K~v;_[d4RFF9/䎿#;lL=ZV_))Zǧ0mY*WIz<b0EݱgOdFja`f=ʤ~E<X.v)յ}_<*YxѢfSo#H3#z✍Ox0'SNL,/ڨ%MǑWr<yˌ2p^)JfW}tc.Q2wm)m<;<ifeG撑?rM3lU PZbw +/L +*h 疖`BY_~Z +eEǮ
n֮PƬ\>9}S5}Sɪ=хTG_#Yӽ)k*<)G'kd<qZGYHHLd=#Ґ!YdbD0\`ya&="V=:Q)H[U.BkpYXf^홬:mvmnU;ZNg>-2ZX`#}2\{XV1L ,
+,6Ȑ3iQ]c +\aǵZܞMqcX{?j"ʺZ0re\j5RĮ@-;E7bUN@%9Oۜ/ 6g^?䬇"'or|-z8?dPy +s{VuJ8O8<Pړm +qOZzaceu%%cʨM}uN='[+'GZũQdͨ;vPօ)VCq.}1mB\>#ٓŞmPQ6宼qD~Uoo_/~7oVC>3QN->tqqGHGK.-k9SP282K3JΩ(;xl^*ǹtTF3zn0̳'{ywwsM[w_|_m
EeOZZfky +~LSf40Ie9xc\tWbj%w$X)XiPTllc?zNS}IwqCuCvC2;d%(S>9!\J=TdY÷IGk6(3R:iGmx%4]%sosU +w?2Rܦ;/qt_}XA^z%%`x,,VgrVpŨ<
h&[Dґ*a:-|oOee.iw=iss3sXw}㦏Ю{.Bbۮ҂aZp0fLAgT>lr +58'qtfUj
TK8HYG*W3h$j|Md0jJw7{rP|ڝd"QW+}6SN4eۛz~b*t]_̷ɮiymaŷroo[|777yW +|B{PcYRo9_dbBMi/Ztua-'kuvi]XO7#drdSnߧۜ@ۃpSm)n+<t?av!}/~our9.|[*u9%n.{B7qa(t:75|M!HdD!ren#[yVsμ峙aN-;:UНH;"q쮤qL:\IRJyͭ9.@̵9l\K뤮+u.UyV.rTpꗑMr9F}oņl0OFQߎaHq̯bf%Ф+T41gq/( ЀUsgc]l9Urh.6V0*#Fp8|teVȹ.[f-oy|檖6cl£GÏ?Y.*}X3zb/Du"F1Y5#Ci +aV>MWd@I;gnd91|vJ߾N>uS?Z-PŇoo]/|0T1/<k2B_1Z`#F}Ǎb߹ɵb&H~u4sof +}ɄCņA)20+30="KԷ\L.#FU&#s + ۿ +pAnuUƭ1Td P֛RtJ9~~M?k泏yq@V/.x
O6Cv~sŧo^z_P>s7^'^yOO~?O|? +~[=RV|)5yũ&E\-ԴoCqN_
AsQEA1JBr6|Vo苷|]nOUxM.A^cоेxDaRbUN)B"EnN/ Q" +D ReIPdV/WrN,r2nc2' U#ʜ**"kG=3rjlX|Ѻf88F[L+o2te%{yD@E֓RDg&2Q)Iv6+znAQքd0ύl̷aI}RԹg-:J70&HL j'rɪJ-nidt_;n",P\R6vb&Hv$GBPPN +&o?58H9(:RܪI+T gȲ9ؓpitZ;S9G֔fY~K/xE^< j[pfa;.*11uiVUmV<SU8[mSjĢ!;( SI&~;+̃ +ϐሔ(B(a^Sی6@/b{,KU2a7 +,a2_.=
~.*9a䳹*aÎyޟqdk`YYErUrZ0ѷweg +: Х,zB}0E64O'DkXU#aB\bOJXgpf=TZt)kabFNy%ȀW[kiW^HVY2o-"2QChSUDE%pla=DLp4˖ٖ+ҳ^w$/ui#SCsь<̴U4 +";Ȼsx;r.4Sea$ER`i'mtUHGdP(ӌ(S+Zzce@ C8)aHzX +)<o3XXze +n8,*˖"#,jz<
:u+59Ӑiʫ|`V<"seI^鼅rrdd8P^}rs܌ptF. e\D9l,. +EtIEq"ÍYᠧAU|:P.'rSBl:"z^m5CW9A7J_;%Ubߐ* 6Ol,NdX[ӓ։~^ʣ.6^ .6B8LX%
YU֥ TbIɍ>rj@B+SDW0CU`P:]? ̌ZGĸ-+bnAOYH@ܰ<٩8FCƀtBa5*6 ҨLz,\?4ͺ0ZD9,Nˤwjpb"zal:eF*@-SI6Sp+UuǑZ}>(*\X;QzkC#妸N +* :${5~ylɌ"JzDe wIrXa r~ +E<fQ18SA^ bn}&5D + r`9;n7 +C^z0Tl3ayMXM٩d5~Q2!(D7,`LV 3v8|REςQDM;qu +*0@(X<G۸Ab[`lC1bc2F&DjݱSFNDiFqS Xeő'CA)B6N +)ZcRJ2:#JVt9-/"J22My
7*Qx`I7.b+rtJ0h +.&u
%)?i=rw
+ ;
:%+B9VWX{{o @p.Q kWR/0J,{4LaZ)f]l{bR,@LDL| +Cy: +P`6zf[[
p3-:w +\O7hw|L.m_&0i8A[wCB($ B)C0!`MBV_^`x(%oC!|^xK +&Q":əP3CJoA@_,} m
^^+-8Z(a + +5m"oH[A8B= P"N[ΓL*R +Q$.msk3k$;6[Xm,8 +LqŲ"eE@'[3uև1,U$|! + +uqVJƢ6
y )L1T>pp{P(t +^M )7T,lVi#%tJs7nnU4ʣ3Dx+ )` +,yAB +
+&I].9*>t{]*H\KL1(lbQb_5d5KT ++E1Svͬ$lTՈ`A2 <cy +;BHJQGLfT +8x)`ŠDZ"@Yl$o6lʊ`+SX Ƌ;9w灘Q0.tTG9GPQcUQVL<=ע}/@ZF*y244Y`^r7;|HoeD]b=ҪzU41:NdY_
X/ gjTB8Hj㰏 +6W,ShPЉաڦ@X/!
NAQ'`vVH(R9J;2%ٳqOVBX+1P艹 @{f8$|t
t$nuq +X9)U%D6]EB/dpp,riP㈽>È6R +22:0upOcn+^#R*zjAs*0(*06cP?R/kbmXDTӀŤJ}3|
Bڪ4,*B9dd ǰ䄮cw%L̯ +(.?hv^_'E./ +^gF+Ii6B=i= +P:;^ɧ mβ( Kn,*d@t1Pհb mG +K- qn-R
+l
y0Y,CF@8 +K#4Ɉ\c>~2nTgY +^Sd@@<3|6ҁh>+Yө0f(+%>POp-E(bk1p"l;1x#9gp
, +f5qЍ<FЊ9PwChgZ2u +*2tMq( EӅzEyժ%e=q0e'%Do|R4aYH +~GD~`m>p"ue !,Zahl\Ш̜JJ'@2p:=
ow\qF idy|߭HX4h9Za^[>[J
`ZJ05QHcH?%Nz<Y5tJS0Zeno02ښqHI<ض
74' +˚+5E3i9_`12 +O_` :=eRy(9;07JiQcggQ7:!Ep#qzqh4xKMRτ.Ga/wi:UP?BFS/z B+Դ)Yf<E'nc:rCÕ ''շ7#pEd~8tGS%*Oe?BFm L$Qsڀx!-闺jjBWE5wWPڀsObrJq:bIG%3%~wefY0Ƽb: +}Kpԧ=[IR6JWXjJwFjsCgYUϦMX¢)JR-y15%1%UЗkM<>S]Z4>,]7XxTPt(n&O5c H:r]41+.@lWaX'2)85=CuB&D3ojp}C@b!y2@NҲvLI/03OU/55s%P5UF{k00ƹגcӔA +t1>ì8oѰb;}T$b6EZ@ļeD<T,씁d^(krf<0ϖmc\'RtUD>^d34c@;lMп+sX!= ]²sY`f>Fȑq7!Di +"ǒfU]bJP٘аf+dCj\K8!ouzzw +~鬬~7/z^˿՟^KS~<]q#\&ݹh(5@57 -Fg%ljyLբՎG:ůgE<]v"uF:=Tz:PP[|TE6pV'$ȣJ+ըЅyVNKy%<S uޏuTҊ)Qu%8ը%Tjԑ:jԑ5ݬF;mjy晳ga[v
Q?.RXFDkMdQDP(Q?ɔΊԹbvn"֖άJoJ:+SG(SGJHaEٝZ:DJ("*uL$J~Rs,hF]uxsdԳuOOCAuN*ummZ #\8zVIB|l #gEH$٪ԑcU/Vn#X :ꯠ%QN*;+S,8<sēS瀷>Ris
4i:0ȥ3VJR6߫]0ǚWWPdUOXuJ]QJ]`R7UHՍ*u1*u~ԡUYr]L(L]2uM2u"!$)%Q.0.Q.Bm:Rs]G:R"xVtVP< +Ձ"7BuXYȎd AizTF!=Qp.mՁQaV_}VUvT"[]T'R~V(G:ޖg.E1*y#C+y~ÌT*Ցhe:j>!mKՁ*"@JՑ0~s5Tm<9^hETGHaJyf<fz
MA5GAG6
gS\&RaLs/U7SNRu|F [:,6<kՑ0<Vgfqbr/U!q[kV<Q h ̥:CkvjA=oKy$vJpo=|Ju¦T]RuI<rvBԪDX:oǞjyB.
Zuת5vU*eVcKYIiOඵ@ԓ- "L^.0@]z:jHv
_Y1Z8DdXL1܋HjwɇnՁJIZ|$buL넍pSTcZQuسָꠉ^Jܯ`U/miT|eّ +>k0bu^Zu.-,']J/V-tkUeݐPߋu\[:k\j$tmq>02UM?n[uJVsko%XZu,b!wju¦V]ZuLVbEsm(8c/U Rujں^um@GexdzvfK֪#51評':CZlkՁZu ȟCUVn+{:V^cUtr亭U']C/V7sA5>0B4^7c^02V8AXq)VשX]`,V0aעMNmn+CZm +mSk&Q7IYm#xsef^ PB(ly4] +]7.^{*W'
U8΄]:-}\kWo}]}y#/\]$G--u_+{2FƹoZ]V崺.husefҴiuN /&[i$0A#XVqѣiu]}r$61mX*pu-quDUWǩ#煫 ]:}
Wu7*
^V.`ݒX~7tF^Ⱥ<CHps:mFIGU0{;iruޔ\IOpI
ygN2סO:If:}Z"<A˔t8pPp:{q`mN9Pvo8Nn<+P@dxnpݖ
\WlN%/pI~u_.\Ժu(.pdG\κu*z벊&]iȦkr:=EA\gS"npݡlp/ݧytG!uHcUrͭˬgp$]:$;85!.cU&έonݡܺ-w?48:$zr2.*!7߹uY~x.IԹuZX'u0.au6.u.m ]6+!<,"Tɶw\w('nA˖:i:ڌ2glpJ2!c +pߴ9 >)DOa +o:T{!U~uE
<'iYNW\MC&U:)G:)y8n#븦sRgYG$)ݔN&X\:ES}# +5V:|A)\x(
Y̅SӍ<ف9^h..d)YG@Mk%&H}CHd +hUue}Z"C`)V~|1hVb1 :}NA +Q?:u[yU@Venf~e~uɦͬæ~<ì#tb֡: +.f fёނd}z1Sz!@m}@ YPǑhC]?:~'Mg:.AI&5NḤML"鱚yXW0LC̺lPY:Uh:#7aEәVOr!~{zYW(.}#4RV'X[?G=e`%*~4
V^XWObDJςX'a>Fؠmuz4XPobpQV7~ +aKsó9MPOd)uTXXDŽ*us$߉u9՛X'7жC~pkúA"+~h[N
:c +edi\Yz湐u֜Y/!ⅬӆYJedd +d%=1sr%e X[0NJI ThSF>(G)KͬSŬSiʑumm/d f]&k/:mANf㿑:bYGìSav;3of]SZGHdβuR,:z/jg:e:{)mj>o6N?ԕ:|ٱuƎ'N'R;:]7N__ԺfZϩuʊ/Ժ+{)dAӫn{q]j]:Ma?ؖ:}*E#x:i!qhBmuCFeO_:͜- ΐ>:}XG[5 lk=vi\rغ0N +ZZV>ǩu(jE)!hjRYBEPp$LVP""RMek +Nڍ>uR>C2c.hI<*S:Ն^:ňvw7uʽüu)j*b+umiڅSʅ4u +S*nn]fW?:BO`ygk/m\0q딹!:R}UZم#b;e6YRu x]W)"Zu4SPӺ`2oxNc8 xQ[ [uR%@CէڥS#huz^gͶvz>o]6z]Lz.
c՞, +[vRa9 O[{%toŰcUʋ;.Q[b)CbP%v`9N:v~o{BŰbe߅R3!;R*vZ`GIS\{]"q +I\酯K-_IN3lMuT%L|Yso>:dX_'uq~߅7NjMb$k: N|mPX+ZZZ4딁b|Jхg:<jV^J$a:drZS|+p,y=:NmF3)
'NES n6vȦ +'/!"%/NSY ~DN7Әh_GE}tX|먖 z2mރ_S-Bgr7W_:5$Z%ux
+TDHh`Wd1pd~AN3nzWLJ^L:4ARI\bYihnzX{^Giꃭ
+eM3zCHeޞ: tLNשYױQ'{rt7
czŮӓ^|uȊ/vߺmx]7Y|MCaоu-}T/~L-J/~whzzu딸{boM"34L}~`̢5INY +z +]at&iI($;e.Xt7E" e*49N'.DbY> +M*;AvJ\
+9ߏ}oQPO9v3pӇLs^?iWmtrK:NI-A:j8vNBOJE3j;d/f +P46G(@vZ3tN0OE.FEdNإ +ScY:hsɕ诃/{d~Hqe.'Nq*@vXnAvX}Ȯ`vIaxqg ;vɫ9>;T>7NѸ8v/66NUb-+ +E 0`:$BZ"f; + +5d ea~# +S;6tT&17űCNmN5] ;"D"de옧Pv +ZWJexee +JeǛRzTʔC+\0;G0l:[t(DR>S^4;\&1nfHp^0e#<fgJYvK4deb%+(VYFifn +ʅ;dG^A`֭uMeG*%ACN w.yUC2!R6N/a.dIU8n6>$K9N/b I:d'QokOT8k{?$;zr('ǧo.Rc7`νNB棭ULBPrQLVF3(v*ӘJ2}oPTSƆOܚx7bɉ-cpFbah%~a$I1qyl߶9vt/:
8vY2%f;cwpR ӣvq(Ӎtp htZnnf"@vM2>̗l2rPNݖdP_ˠPѡ/M<[%N;'N
5zf,/؆vo!5VaS4;3&hvf1hv29Yv?MŲ3(7rJV8l1@G;N}Saٙ\Ct~KaNm +>mPNّ/avFu촰[{Ttc^+a6XݪndNJ1`vl-pv;8Kc3[t.#
Nm@fi#e/`vRls0\0;\7R2þfGEReWudUrj,Zͧm_N[@q~eS.@٩ЧWnTe'*"(; ?/js8Oxa+^6:,E!oL,^-]aK3(u>PvE!~ ir(;G4ͲS:LffU)&6]ɳCLi}γnԓg'11vVAʁs7N<;}BP9u|h^B--5xvϮPͳSmEZ|w).n;ˋgEgG{ +ٙZųPU;iʡg'|4gWC,ώxvRmpb:=6N< +\<%<%ώ~yɇ@wvo9ô-JE-5ڢ +yrPN9ɳ.Qr|,A<^Q`Otd:aֵ<;N{?:xvquXj@@Sy!x +*t7ϞpUiS UO0wkFm`,E֠qvyxnTMDhhS9܅Sa6ΎF4;u{T +kWNSTd):J/ffObVS)gG'UvӶ[3"ٍbuN%YVCT4)Ԅe7e^Z w7NDo]4x2VV{7nz"رn`C.~,2U^Hl~] o~]V7|ˆA}Ic봬u +uN}{pT<Z`Q6NB+cC:5YW=uʘ)0:9y<u\:m.^]-Ⴂ-udf07NKH:赸ԩ碏Nn}7NxݜLJPʋPmNn>]vë +t[8tK
<]nسsnv̒{6=lE7u| tFЩ~i/.Fe/]qş+FbM+M+Z@ʕl]7z4PeF]9u,PNXsi`p"cQ +\{Q09҈9Tr˖"-jlT8=-.XBs.>r9ZN_`S|+ËձriIT{mҺ[SN4.\oQN[T Ʌ89mC +L܈_ɩOM!^^@I. +lҢ"G}{DNAb>.)i1 +(A1uV-.|q-8A[E[j䰊)]ldFŎuRj +0+Ux2pzZ{qul>ǁ`#KAm8]tV0]Mǭ.~RW
01Bz V?4 +݃qqD<^ȊZB]sp*1
'?8ڨ,J3%NW,oJrߒD.JNsh6?p*pĜ +D
N}88m(([7})̓ӽ4_wV.!;.Yl_&">ypY/ypR<8!g(|$
VȊ-QFul8n 7n7M|Z`@דǁ +4wYQ@6zQ
/eqnܖg!HX\ƃÒMG,.xpX8KKWk6^8\߹ypi볗̓Լyp[xp[RƼ! +I~nD%piBHinx0>o8G0Y-췥Mi-KM{٭hu(!̐74koMnT~"yƒw7
o|R[8o[4ޛ7n aFz +zbO{2F%cO#Imd-5PoGW{0 @Z\-MO0+HoŘ.iBdAzS΅z0YRS'I VR&Fg0?buɅ\:!zÕY3B +2\%Ӌ&o/!iM64 + +[sSLsO2m:*`n,7,06M` 7+iaL¸)}RqX7>0flᦿkFm枴mF%$bnso۾ߟ]^mpKY6c\6i{pQ$j6ĩ|4mjfSBDDOq0|UO\N^t\*M
kSYꢢ IScÿͦjM%
oԦ5g_e[MmYʆ6_6Q1FuDRO:mံeR6w>:k}Y-9 +ols: F3/m ƇͦԛMÓl9̆GR5mgnPٴT傲DVgi''5l()~@oV86%AQ#^_BcLuʦ.'bދ:3+0l;`9M܄fMaOR@?}16?MŶɅ . +M}X)UUS+f`1䛨608;mҼqjwXT(45VJFpCȜilZ45[L}c`%)mY%Ta+pIKQUR$Nɣn)8ꦩ!
+fXYVᤩ`,4D9,&Y\45͈.9;RFLaj +
I[߀)maF'MM.$hjR,LZЪ㦩zm45O874ASM45 ijlSIҦY nJ}.ZSƦ8^g3Ԥ(5 (5i mFDƶWmNd_-Z@)9"$?9Rㇴa`/v/kXjI,5"ԍKJK-bs0⭂%H3K
JG%r-. *,5'/J#/层bă&{L)m̚?45dXM
4
ASD+<AS[{PT]454Ⴆhun5P2Qs:+qOMMc}CASÆGuhjl?14M/ASKYQ +S}Sj }||N
SLB)6Dn +E4"LlLoY05^05%^DoDyԠ\05pSe]s05R9Ď.}05ZLMojF1F
ES`j\+y,i
SlRTtL}FajtehY05If>Td^pڊ&05}K"L-?H[052,NRmE,7o0(XjTS,5}KM)2K-3Vus[h^瓥f\;Aں +B%7;tNʐ`fHYaej3C;^H\Sy 5?Ix\5CGWGwHMWlt7{'ѕ78IjJ +bRnqqԔ%{`ԸFDڋ.0jJ F+U:MQ٠a+0j)+8jڛX5i-9j)56o[DgR'|0$5Nڻ +-h1/&q<TG}C?F@v0&Q%25<NPC~1@MgV,jT弳5 +>AP#Ƹ j6AP@ +EBM2EG 4"
ѵr1.V +8BM%PӓB틠FFI72 L?A
Ayd?Z^lLhSu 9Bm'kPsbmFR + GGB
+iE N" G!w,jX#\P*B:j^\_l)j`erH- L\5dN-d Ԗp_Pfv
Z>-B
~->&K}C
UYQCFU̡6D
QUAQuP$<59DMkѠBV?qMp;FC
;
+pڟ6}j5v/
.a`1Z9c7TBI;BX54STݯh(1fTBMT'j +5m)2
].F#B5ҚO^5;D#FB:em7g5][;9Ek(i!'EMe*(Ɓ\|=uS$0M};>!D QӖ]i-]qae!jFk"^o)H"5d&݂IrShBg5#E-vPc|5J"H-:.ZeY5U͂vȞ#l lC|ԴḆfBԶ45mAS8mxԐyBQzԤ7hj,J05kyg#,-Phjr`l9hjzS@_<K
b`0EXH;KmKmR>qܢ4-=nиPjVS-Y~yf^nbpJ-?]NRK)iԈ S;$5tӛkNIM]&Z.1nAQ#MQ,Ua{( j&Qwy1jhHRhBEݩnZsPc?SOÓltZ/OknxH'G^98
..MJOf`ԳkoP`@svBd Q4 Ih/gnb7v{ +5U߬3asF_.YQyGZߢOYA8k˶pFjץfE[^Tn6Gljy7LW']]Vŋkarp`X3w53R7lU,-;ffk./ v%kuHl.L.!M(#wȸIԍ5o+8f$i[t)͍5 e\3&95f75KfR:&5cV5tƗ]:E%,9LàqflF/fRl`\`3w56ÇwlF}f %i\d3\f\d3lѳylӠqm&UE~A6Ӧ$f"ѢQǿcʹɾfRN +.*38OoXiiR0 flsh/X",12[?"iVHdF\fK
D]YB7
x(|'lN5Sj4KN8filgXW9fa!8fz6LͳD3Kqc2-_@=$JY͖r͖D3{@35/݆#
Ֆ́f +Rۦ1#g@3chfj)hFV47Ќ/"KA{͚4ð3oM3:%6L
Ngf!>|Ӫ,cpt[k;LUݬ.'l:Hѣ=aʬS5'όLuj@K<S33X83.%N?E䙡fQ@gf:n>|3<3SJPֹ6v@368Luj +VW^<3Lat<3kyh$Tqt3#9Qgp5:BYX
3̨۪Qkxi|ŌZzg&<pιg7Ό8Uṵb6-pf)8-8:LuEzS%z!9T)Y\83TmO.^m3cp̶p̖<3X9lsUݒZ?{m5)w@oA\DeXѤ0X +hfm/fmLfzaq`~H=Yf +V+bƆc8,;U/xejJn3c(Ac2Bmjdʘ,ܙqP; +eɗY~- +P-3I6o^CRa^VK6C(䪓Ax"[L'ZZRbוX"y0BsDQ*\BaUzv`̪2WX22Ctuk'&Unhe.1偢mrk +,y owfHpYL3K'k@d)N4K@$&^/e)q&6n8H{%-Yd/!WV+Y9D596Ui0+t^*!V.Zw +E+*=(* 1r˕L1dRJt+')rO->toV_P옳sdu~Xߝ,ZC
w@}FKK*s~Wƣ|]qOBoXv:ps`UF|U2<U7i]vJ(<+ +JSWnQXAVjQȯ1<BݶKSnKYfFg B@ںhYlѦ)gZ/|hu!fU +]*z(%9g.$o{anܕ*c灡v+:)3:DkiULwZBBxI5JEDʵĘB(crܙ{jN10yAyq0ADjm9"R}
ǙM$
T<K2 +@%"Jլ6'VS8H>pپçIZq0%BJcB(F{l)NJ7c0B7UGEŬԬbEP?E}mYQ* s47W489PAN۲V&U1/ժ4-ߵq`'F/ƈZ-ukiט>lͅI?<Nr%7|حA +|{;]v7|-EƶAW;i%:z +3E;?f/!TR{kb +_͑ꃪfj?3@8*݊q\Ե>2YUTf-U^fwc,dSm +,U9Hwf5c1 ?-(!ltwoW +}nF[,f苽YPge]VƧ4ТInFe)$FAI fC~ۢAE7UA
>6!͝j+4EMmnbT`Vn*ܓ%b2O@Uc[\+ +hӁ؏h5GAJ!Zk4h/T,oѶeYhVPE'/m>Y^6fcŁ7sS0SQ?۲7\E4Ҹ18
+Ƌ-'#}<k +MEK_zTk%3h,C9F[r:}
b^uvʶ@x_PN-w{@ElU{)~0ߐajǟmj&Q9L+D +Ӧ<O
]i3UEKbN! +uϘGޡb*83Rjy'S@U,8-ƳSцO.}qcy2_dBR(CaáIA;N]6RtWL iCWzL8̪*Qu`nVdݘZ8R(b^~.W7l?&lrt ,NoFpŎOj_,A+^
W\f]K}C'!>ogU8 +R{)ZIHe%$۰9E9))PP"IUxZLJV +糕['6z쑨Zi<_ʿc~mRJb̊2*=g3(,TNvrpz MHƆE9q[2`
C62/t[ʬٻwH'N}*?ItKioE=Xy+!n*QͅJ"c2ѝB_Z>PE(EtrSBTy-Ra
,2G@ceaO<2CJI&>PDt)w?^r^Xkl6[VWzppr˴͕#C})(Zfm^]d~9}}f-)ݢY,\ +J
Et3wB]߀ޡ<ۧ~ +*`n.z>ژKK^XjT,1Rؖa41 +-]+Z +eN%!eC$)ckSUZL"V+k4>VLj +*)K+0Gm0,/0:'Ba9=W(IN]9ɶH5\H@UmP$e(fKHM@7߂[/Q"Zop4kE[w9hݹs66mm m@ y:gm +ՠszQs#_2Ъ33Zๆ%sj)I\ 9dJ͐3P:*B[j%dMZdv\pшOHFo t;'-Qǀ,5D12?IfQzLQ#`/Ha.̗n#^0Re$UyIdyt>T] SEW&%uB!¤L#tN"`ŭv)-ԋx07fIy95yi_SJqΉJu6NUI*%5Hbt)O-7 +Jt0ES-&kZҁAsk u5{mxVPՆY7cߑA`SBRRoVb+MӞh%$ ibKX$_ȾF
Ft4hpWizJg~eOR\R/niYSh=|tkZG@ +zuy16N +0V^Lv6X<pu0wH|K_>cݢ)E' vA +Ti?B{n;odXD; +J&HK +Oރ}Ģ,HŖܠdSp9;k
d[iՀQ&BຬXxGC5s(7seuM->9j RiPcC"h/i?irKz#i&ɴ^xܭȫe?/H7Cr-+kVUGstt*h-ڇF#"M jF<e7 Og{6 YBWp7+Ank`.Ki)kT4镍PE[[l+p3wr3ud*Z=(M>[m-[*M6%^FSw?Zy :GoOZF:T1l:)eQ +5+ܕfP/hLT$8Lͫ Jt{ؖ|.{|T/z)JlaU?,Uq/]q|X"B1^٩'y)vNFRT#FYNWB90iS +7UŭC4yؙi%{Ӡ$&ߴ{&B֒X3۩T8t $LnZ%cTç'`i[dM/FJcܞNbQU +ޤIXriHKqfNzb߂pCIHDy2z'Z-+B7 +c(l|2MkdS%۴aPU'J_j8Ƨ2hfj9u:˿vXQrz;~st>IkjO=Tw(2xFڒ3uYc)=!D1)۪5dx|F|RJ-i'SEf_Vx|ե/1Y%õ^qVIM!RX>^(nʣ$ yw'rR,dYYLJ,'5VP_,R"\Sp71i־ebeeTOS)QeȥLLˌT魒!R<j[(4L+Jx8L;x|RaP,ܩ'^STY+^gR\? +
Je1|I:h>3"_֎6d"tvO\X
` e +^9-#twӗ'ǶXn_eLR~@3/;~j/OiHH>dBT-l_XJUBeם%_F]9bo;V4yR砂\-eZ[&WRLrhMݖZ-&k/Pý-dAʬ5-jAfVSL#V'F#MfR믅t'm62n~# zt$X.J+(Y
2`ݥPHHQ|a^Z`[ip&Bg)*𬾰S*%Кhп(gPj~R(|JtZ*tX>@VXdP _@ +^]U+t>wWqӂxZSh#rAMT +hQ)hV_T% +UT$~WuċWC=zgX厒H~Y> +.,, +.8'E ğ
)J7/=ym(ÅXx<""A2C>nHNЊ]nfLvjg#eZuϖ,7M +\We˹tS+IWR\JFbɣ1}Ѫ]ʆ!6/^Pkj=Zdl66PzX ;
dluzbMrrTeQ|``0MB@MVyc\~o#k r9G0jsTf6P^s"TKE2[ؿ1W`I]AtLsm¯!{61Y +̨aME$i~?ɢG&ITjÓZ
fD|p'-BE}(dU@"IfzYRB]M |E20$.&J-mm*J)b:}_`@uOV;sEUst(B-bÄ!4pnV"UeK E̾ͯ
'ӿg +cP91%6)p$(x +T^IZ<Ł 1~LdxxԨSBZтvc<d/xTKt]<Q!P&4\;<ؔ/ +c4%,. M6*ن l{݊VP'zxR9P{=RZ-*M, Ng{z)~>[˶<+4@3./ G#Rile5L҉Fg$[`@"vlecEd +HvX! vBv$XwSQ)fhV^x<u=)wݻATI>
,z)|CV}
l=4ʋ;&BJᯨXqK}ȫW5<BDB1Q6o jbhX3NB0;G*m:Y7)E#("Oa@jp%,TʖNuTKBQ'M&=mJtQlQYSS2g&mqǀ0@N\RͮR-F?5o1lhJwuZ/TBUU@Ijr +PuFda0n+2_t-?X%\zŚxd(!B.}`BLNo~q(ϐ)1i7i ~`%ګ(Mr%0x[r9PUߗc#lߴ]R I<'VP;nA^c"̪[^Mn:PExB|h05zY̪ ©bA-U]iPlV1kGGG=[,|1j5e؍u +YkinY4(ټDZp3V.sNkPU=-V
iTTDDgyh17sJ:[BmExzC(Ӫ)iݰ(1m5L0q,wX@ס,mTG2 VsvxzJyL|u%cށB=0J>0^7&{P@MZi(2.
Kl^55Ň88f~EՋ[:u|eiy?R{yfs}}2ݢHZŨ6gqMu=ʻJ<g5D%ydPDHs$; FZ?7P?8q$bRkRo!zLTLw!v(üeVfǜ5kRyC?rTq2fRs$(Ojŵ
Cpહz`M^D='J䨮v6x4+TFYd +2:'"mdoi{Ǻ{vE/cGd+s\xSV2gf6Oe +yIXB߈@ MR.eUmX4!jclUy0Z=oq,M^ÏmLgI>P0ԡ3e.t/}}7gRULՈ(ܲ(Y*LZY|3<û:Td?:NV}]{Z*.m4F'dV^D5 D {jݛuu26f"*@{"ER6Po`uzpDJU݂>|AbNi|
KTUEԚ ?o+ҫ +.ׄLXʎl_ۋRЅ +Z[xxڙ0M +8Ѽtfw巃zႛX!jajON$F:P5^>Dy'wmrR)orb&2MNDTr|G q''ED_&'*{(&'cHo١-91a4@DFMG79QPOc{&'n$'Z`l.^MuQH$.t֝G!79QBZ&'RF99Ô&'n"'oU5ZCW[=ĭ\CvrHZaT̓F'f:# Q::o&}NT=%6M~@'[gI=xa6|Hɯ̒>Dնn<#X00ۇ(ev" +CFQ0<Mv3雚6:Q=?G=Ȩw|؉זf'tNV.%\D,~sf'ե9f'"k}:5f'N]Dm|F'*y!ۇX}f'[;߲, +5ى*6h*GDHt^0ЉvXDDhj/t"{N~@'nB'n9Љ Z6`<?DY&>Y傿,"\-9Qq;oe==q Orſw:[3Љ(322>D}F'J{D)MN9:ϼщ`:6n>?D7]j(~f'HrdX?}n$>Gu\*^Nrh{P-r}T_aOrJHd$<8S.ẃjD,YRZ/vќfwx)ڟẇ$wDnkii5Z('(:wfxȎN,sDk?D>˓f'Ji,q՜_|el|"g!택DM\3[gs|V.|!;>]g&p|/z"ץ⩦YrO}9<Q#v*(̎Od#yEv{`Xr)2m)mxbH>'|N1oz"qvEļ)UyLE͙?qnz"jP#X=єJ +<biD1-٧8Gq+GqQ5QOEAVwDSq]ZT۞^. +
"AځsrqlEK,z"Oɛʵ(bhs|Q43BN>)v"٥ZLz2rqE=!E#i(ڔ=82@(n(Δm[#ʎB(r*MT
6Fǵ(J}1(f~SS
+@H?ysXxa^'F:"SrGFNb~M MA)HQGrG|Hھ79^H"JstQ4AQ&L"g(R6J+8qSub%pDtY*&
=TBq+ՠEU@(nʎPL9t\Y6xn9>7٥йʁPL9zTǎ@(R<xZq0AP^2!(2g$AQgλ?>)IZq_fimv9vՒTbNP}#{Eq±^afb*3"(6kbQIP@Aes9p/p6Pԍ wY>Ac~(g/&A[;L ?E=T%?Q\lD⏥' !Lt!(*악@EPTpIP<{;J)8J,"28P$Ǣb(Zq(شv"XPG^cb$EH|a( *PWE] +f~K@(J1J"_EvPD@(J&B3;]W_E_Ep.EՒk|AP7E]_
'@Qz'-#3y5AXg|(*Pԗ +/yє(b?, +," +hA܌ޫ +yitV{cr孯-NA诛!";dFEPd M $wEdzA (a$I"m3Ps(?E-ڈ0/6TWx,"ugAPvi:H{ +ED0jZ@zE(겑v")O-H`o(T)EݖPYEbb$΄(p%tR*;Dz% +3Yar@ U QʇAH'mE(fDQ_RlE[
T5>EdC3VLHk(Z!,]34K+^ˇ/k(LP;HbEN9LQ$QDT"9DDYQ]EQ@5!f)΄(ҞG0SҥĂ(RΤW@e| TM'd{/! +I Q-E3&.(, .M"^d>P]봕b(*ǖ(A:DX'DC +l+PWuNQTU(R^OQ"S%j߽hؒw|@$[ ErT"ᲷމRT-Kќ5,ECOO[]RgX,EZLqu
>R']GWR$u3<N:mnî!N~!TYq.)=x\1SbMo碕Bqc)jpl
S嗺C.To8E)VC)ZSto\T&p$y"%<TԩV@E-' + KHE&ڲ0|B*2Ǒ<
(*MRXHEs$*JG.Pcb.+[@E1>@EjHz:PQ=_o{STᅳ!uaN|NX,=phOѺ-e(Z_,M\/NQo])V̅}GȄ5MNj֝zB)"zH;x!hKv;OuSfV<E"FP +I|@)6X7WJ "ANh\o@*PQɀO8_@/~?@Emasvk`<Eȥ$8E'N=)j3V[S9pu$NS~XDP78E_8E`NXxSyh)ӉS2otShj)[f-xS4Ez>>0EgҿGb,()6B+ SĘ`0EaL_0<`x S0E-^g:Z}S՚iLQÂuLt[9L`'3Sd$S`,EƢe}zg )IڇL@?PR~K;YRHͼ)Zv~> SDy<0^'q]}HESR#|<}{+C|ZIxxJzOQIOkk ѽ)iS$#xR><Et4
XSBk)ZR)Zy:)*)ҘASN)9x +[Hn9STܼ?Tj1%%<EO'b)6xӢ4Sw/91Sۧ%v" Tgm TTqa*6Əw&PQK*ҜG +e}UT%݂*jBHpVnq7/v"2a +.w< V$l7VPE'X-($
A;<~pi:ذuÇGWW2A<NUmeZPR4TEpLXȁpك(h=wR^TE"FVQٿXEkw1열-7\b&'WN,͐;3Jf#RwXElV
u.Q5H{{j MדcpGbU߄*ƿ|7 +T`e +Y +"46@+sx[Qw +V\V4yd+'k#B"|Qax؊ +IAWĤrh8! +V:tVLwu`nlES"+R4ȊQR}$+9ȊdŔN[&rb'+ftC[.dE*bp"=0NVNC&XQ(VbiH<i>S-:QNj./X`E]O;XqSvx&;Xc{YʼH@u^*PGD=U$sBS:P~Tq)'Tdh;-*qsTQ~HʋwE TԑTE#*ӡ+u"ǘNUFHQt:wV>Lic`7e*lXE}AUy_ULQ6K^Wh;¬\Ee|+6He\TbU1үnYs<*x|L7dEeLZQ/
u0mxLlEځS 9<؊z&f˸ Wl&ZV4?. +kh֙NVAV +6X.A$+J6w +` dEL8dEYSU+\Wa~Y4݂)Y1bc +,3/ +j7Z?Hf;dTbAVԲ"&bQA+b\bgB7e+tEWl+;QxW~YrvTWDj?`/v-J`4w׃cxMlR9)^WdߊWvU07![px΅WTWH9XxEQJ[vW$FW_k+6,@ONnd(xEN%fGxE]L{ʎW\+z&5,OMm`)'YW\&SL)BLr+8\O!W4("ZG+rF\:_GwT"P>4K8& *@ڴnm;Nu,t^QM<x+}ͥZf2"-}:+nH'`Qx[oQ- +E +h +Mv*56tz)~*ouAX-"2u"aQ",>VP!|ť^Q4V*˞XF⻚(QEW4enm>etE,4rmIWg^5[mY#tE-|Ƹg醦t+2
b|6:2QWSaޓHunq6_fYlTbAWr}t"=V"g:Btſ#|Ѽ +yH<_ +
XTY1ԅDeLС2Cԗe;҂! +XisւZ&ɂF1p='J:= =%;|f jn,ALa .5 f6w"^>>+ޭsD>VK@z 'j,*Xލs{PI%8ln"ՙ-!a7&un; +endstream
endobj
42 0 obj
<</Length 65536>>stream
+\hÿo/@)m(_'ڐ&ڰTPuٰ|ebذPWXTzp
U-t>ݞj+bb +T5o ZZJQD +8t`Xz?APuv(%TZ*;@Y(
6EjɆ@ +Su\/6V(^>;*]ssBˣE^dTXTX`x'G^@:vPd#VbzXM
m!ܢ+)J0OTXH4`BW +s*^@*O2[BmP2*3ADS4CB$Sȇkpr +\y9Wb +iL+0Dۈ-/Psb +وc5Bۚc;B)Dl8M~)v+~U|سRvLa)TߞAV$0_}?P$ЫoBM
U^'>Zr +i$~fw|8+?uI..Rp +S98)sB~>2u;АPa*0 +9bc}&aSvY]NY?'q]VN)ܔRrP +=2cA:R灕w7P +0ꓸ>m^wJ%L6L!>Nś|[e)dB]QB)"VB;'PBEgQCm5 +ӆpr +9:oɋS)d2ϚBQ` +.$^ +S9%*辞Na闣SXv[5|/`r +eZ(Q_r:LeHdKS8oKBoAa_3B 6x1wBj@ 'DcxB'F'|8N(.cʻ*Byl4!)9˰>ez]p oc9v` iE%DL cLB-\0 $܇DBqV +$JdSqK
!^ +h +:X +(:xIlZ4)` +`kh^ +1/:(5'q 4zNd=:TBS_:v`XˠKmQS9)uPoHax!I XE#8$G`ʚj(VzyU(x;|
J6
N $>ĩ !|^JoPޠ i
rS`U8guGADU1[ʞ]v;kPMu"
j<Ry
BfPGD +T )OMNw/vx.wn2V7w!0|X%8N0'0|[8AZPn[}(? 'α\,ɏ8Al%NP +
>'OZ`о<ALlI<Ac<A^'Ȗ[m$ rcB +.p.;NPPPA[Sv`X>8A, N$'HtIk'H-!йps;p r<k:h "$Mx/X&H"4A+{E8;>4APt|1 } +8A&Mj3,[MX +*X;&P +شT:8) + +(( +J!H(=CP~I 4;dl +Tu5)pxo|'%U$kB +|#@^w[`OjiQpPBdJeh@ͺ^L~'J>kEHGRO"ZP@g:P,T5hD߀r~<\_(z-ajN#,@; +PQ +V8br +Ֆn DIJMyLNYyZVm1\Z?-0)sb+diح3wŢg w=8*FA"d9.#-m2V/;jD?%ğ˰coMNB +7nu K|(!ES oS5Ks^rZ V'įyI +B#mGV>pCߧfdwz9>2OxZެPDMXR}Ekk>k}ŜNhS̾ͻcN#>[0d imWpM
*/]ľB;2-œWRd!
f +`zfLf5#꽧Q0[ub0\>
&Vnib`p0> T#$rw +w'udI+؇5}R<bY,]^Kh1wbxf +ؚYm>0 f(XZ>Qky2df1,W/}*6J+DFHfCx8bT,v'a(g>8 Rj +ɞ>l<k-s躐}t9?>g;@Mz죗Eѫg{&(9Md_v=dV^i^>-vd%ZMdq+ʛ>N~L"!N>hP d2Mc4U) +s(T\*&WHf"],Ⱦ#ѭASEEbČN]G |k53};pObPтi:ZI7AXC0TU*nqwىԯ,bxGF2loWVl>vlCק);KH՜z9O_|فS؏bsp^}>j{M\ϼnXr*3a}aGF֧ӺЖ/Ӹ~S:ϒSuUOe9h}"7h}W(Ǿi} +2 +XI;a}Z@[i +FGhykg#M>by>@!ɐQ`q|mu^rn&s]rN3yF>ɔ[>Bw~/`~>;fxkMVyS!Rf$bI!yr +)ܛZ +K! +Շ8x*ow[>T%sVԐq2?v +iDždF#X}줵2XjF#Y*:էGޚBVy&:O};i}M
_ 7؇ȏ +HD3b $Oîu>6F2P%OE*MXsܙ}wt,fߦE')O81cR>>?pw؈})ľM*ř3+T]ا3.} yVioi" +MufMSO>vh($;3ׇZ6ZЋW띴>BoMXCAW57mU?){I=pN껯,:vw?>g$O5BE/¶ivGeBGC!=+绛/6QMPO a8Op) +{9`OG׃J<x;vtֻf +1jY/TQRX]hN,ԓu,$uPߞ@]OW3-߽Xzbp1ʷ[
0<pf1dmF _oRԗ<6Jß=/e_5p#2!p;a0ϓW&TWOyf>J n%Ku߅5DO|->@ط]%;ny(=yJHvxs::' Z<UX1yڡUyxϙNϓlؠIypz{>Ox8<Px(83KQgیd)
7o[My&{n'1W>%+8k24q${op!4@O2Շϓ`2.Ԇ}߳$(?O?OJ'<)g8ypǩ
mJ +EI~ +ރ?0Õh<,lx;A*S5QP~^֎aQr+FaRyK
vbYc&9tNC-+σ
C6nTCs;6hzpPjn+x<<m=yk䂛K9y'?N橬ʸ'!Rqn[@tGb r`LV)yTl^QWm6OYp6O(q '$y.>yyHRtGSgǃN[Qp^05tOjnƁ*}ܕtujޒWǕbNW C^\ߩy<ց_ ֮xڇ'u<; +8:;5jc؋'IӗSEͣ^ɠ-w^Na?<XD{(aG'[emS85O?Kvj@$y<g n/ߢѩ`n, mC(7y錻zD5|BkAC~6hŠмUvBwQReES@6emCL4Xx/^C&H
D@pgyu@)8y4nj¢)֒W"ڕrPTHdsEe)W8* nv֒$3BҞl^p8y2+yZs@$תy1ܜ͛<#dց{^=y2%*qg<"k˞xݙ'O\ZM-?|_o?_/zZp,d,mc䷈{bgI*ZOXl=QwՒ)?cuEe$9@IRuKN-HI@#ӋۇtyO]7^WFRarjBBkH%zoԼaՓ6H_& + N+6IzOW$zż,j>Z1㦭['&qӄwxi,Po)rGX +W6.),ޞ<e<F[ʬ;r/oZ`~#,;@.u?U_y_B"ip8]IȠltW0{XB/!!q|JJ +CkiB?tj] Oiȏ~c,Ɲ;>V~yidmļr|^7R&HNFi-zX)UĄ[q>u+[!Qr*2t;1|zk_o{;* )י̧Wš.tmGu['6n#n]MC4.2*?EMfø|Rm*T;3MkPb`?)klp{އ]<uIeI!({։U|RXYh1H]FYʼ*bWk4 5}ȜcoYs~evG斸"+UjJ9.;8 dR~QHIYj9:R@^7,LhN;y]VM +%ʉwxY?Tt(M@}ZblB^08h*Zf~*ہЇ1"oJO\ߦV_,]mР7~^
ez.%qDi~ ݊gm2R@렰)~/bNǿ&ԴyݓySKYN*oPT|C+@Kˬbpo:f|\#)~U9J˿<x̆ҮʵyVukYfb:Y6 U=֯_·$X?,t
SviڳH5Ld<&LKQeI}5E0^h +%)p2 I+
R
Y59(~>Ť.1xҠ4DKIfAR +ePsRceé1&ԍwGSc}nbnk˦V+9fJhЙx֭2# +? +Ƈ-;(}=B<t6I-F!PJh;orSvM`yN} +5hX*5Ƈu"63sKeT%HoH,ᕅ_zl2d^^\!بP/ +.+G}_&eLtTvZ0I|nFrq+otvCozp:q +ۂYpD^dKBzNe6/e5RKACɯ)p_3Sz(Lv!K)*Nt;XU&4au=ΏEިB=4.}{pyϱySU CXM:[{I|Y2eVMT;uye*ϣ%M
֫6vC뙡IQ~2:#eXZP<T2C7٩ziص5S:h,ZƋl]>lÁ?)$SrgX8і:+..몤R͏-,:UTb}A+?*1qlPFËCn?_}RUݝJO/UB +*Մ/"l],*(˦0'U 1Q[%e5EnKRV!fa_X0!ɦG.acAH +T +Y3onՇz_K*R[9fi$V:B{(yonKF{! +WBuS-\y 6v,Tr[dV:=
eTnhwO%@r@) _(a]њ6.Rd)QcS5=II柍 +՝2&pJa`02)p|6B8:V4}++Mu8 +2=Qrg' |0#MNL2 2-,H;`dez4V.oR=TUBAPfMqaƻ&aDȣ
lnL|ئo[njG$!EdS K:ɀ +eqrMJ'$$rUeDsꂂۗjnc''DDa$v_گ벰0R=xr\*oƊs9ow뻁;MW*dA{J&rAbRnUV OBI{ةD;$P]}i-/5[*`L֊/q%neԾpS1bn:wwq~Bj!|t4lᮑ=M0/V'%!V~}{e=i%HO&E)lP;3$e.rk5xhF(I'My-?1E +D=déNa` +xDVqr$;EZ5|32}3ﰀj/?ᶕ.F8l7` e
mOӾ ԉ|3WBB7vVy#wl\*4b"Gf% /%4!b2D~}'k$7PϚէ*aC/Rd;ֆ +)(^Ё2L8Edߡ7DhѣTvZG^кgZ4B +:z2]-t{cb +hRl|A
4xR>ܼ?E@2n5|lԽC8t7WQ
bbV$^#cTK6Oa7i{VgUbbOjԽ<R⪿ t'Q&Qe* IЉ[ru&m*e]ֻƏ$;2,P0Q]2/ZW}
kPLeՃw=#]l|J|~<s.g[=UE~pdÉb$yt;*I$:){&* h[\jƶ't"όTjR;Kͨj5tw4i~я @|iPxW=R5VB[e<Β{Tw3:Y +xRlAHůyՆ +e95C7_)@𢪢-'jg,BE)t+F@5j`j<BjTkH dFg8ׁ2RQI,ɔg[u*!Jc6,NNA=H)ez߬+ldBJh9*욐W1lV_m +Ư/7ܰ7N!yoVm<iNenB{4QOϰgTH1Y
H[(#mF*4uYc)O(YΔn%X(,}F*d9ܺrí,:GPTɚf]3U*JBQWABĆpXp8fVKJ[&XVGڭՍRy5!Nu">bW}ԕi3e!4OiYaZӝuA:ASSBSy{ +mrJ(Hlњ,I3oZjd0;m0-EJB,g] G:ea<MRDO|SS7WXPSNޑQ{Pr<ssx!)t,Td">̂mhU!KfxZM-3;}tuU+|W #4)
RZ/Ta'geSD>iVǓux8~-K +&2L>cmWAGzF(kP3C?|S6ljQ^}KnU/pmg&eKtGқNk0TGYk8|q3jYx/iYڱ#/< mL + SwDUewUqaԖtu2R,8}6` + яZZ8XB39MfͶ'F#-j=B҃[G4HNd݄,`ɠ/wB!+ύ] +-k51*ܔ굑 +-GQQؔsjfT%qӤ`p +n%)7{wǡR)@t٢bT+^D^EJ('Nwv*%wR,hat.0,)@dhc:Tv,Pyon3Z.cR=/pPr+wl5J͌j<Cxۻ85ר*wpg7ؘpywTx5Y:jL!s.*JH`;Y+|w#g)++_sUpɑ:Y[`!o<22:Qʪ5h5X+^,%^!NCnzi}tAk1le_
Tx(|VO=a + +oձ +zme=I$U3/.>sHz<VMO +MrTi\fQKÚw*ΎT pV/0[M~?
*$wM +٪nHz?"yj1cRfKku]Gk}ȞM,V'%I"_֧D0aDHYBKcBqZjfGe`:I +('-BI}چyc\YYBڬG_+*eHtY"`O4 tq +%Z7F;R +[T? + +fXFh1<HI>Vڭ+:PW![rFUז1<jTqJOU I1l +Si0%8O!Gaۊy?P{au`X
CܚŮ[9cݜMaוwڷ7({
ߨwbqaR(@l?iӦ| )y'E,syxD못rԲlecXRv<KPg4|ɷ<ViY7R?eh0_4]O|F*Tă0I':HqֿŦ˒THd,L랑츬lQ +pa" jigH3KNK/CpYv*`NEӓ^qLVupR46_03ɪOTcUgM|7bN>hQN]tXO5nXh?VOٷAzOs]mUV`q>|8(Y6HR˽Ya6G(CrJ1`+N.fZQRyqdri$Hՠasr*B~(r២-qk1Ć !U6nyM%XC?0Bh}8CI3LB$ PVrNm=X]k^lelR'ezy>k5:k/+ꙁŵ%tu%zey3j0φ5DT{InDLIzldC sn*u6L1NDߛ5r+eެϟ橩/W0¢Kb9[M,7ЊJT7ſ8P]DKnÔȃjAYz ia(_@J!(Td)clCYF{
c$4ͧ`2ݴ+;BTO72n+yֹ0^3Qn#!Yuַ{<q>+1*/xQ;u8ET=o[gJt{Gb1Wg-b`=a,=E2mCʴ*{J({ +kY[Teui=`hk?zc +ᰱd,ŲD[ҊBmeu6^lmї7yL7oH۪>#pza<iQ0ʿ㍠vࣶ
{SαaɪTfVrzܯ9*hWh*HJxp,"Yni;W#u˨|)*kf+ܨU&>'ʻJVj.K3R8]Ty7$"IzTR8<FxgT7M$"wY[uOw&FalρӞa2+sG[*̱YBDVMJ߳.F/ԃh*J B5pdhb}P÷jV3M^DɾD,ij&D2ʊ5zXwV݊H"JnͷglW4!4f,٪CDOùPRcoOfaFm1\ӣ_ +zhX(/.O
6YC,_^+83YЁ+a^rv yB@xZ5d"/#ڟ/Kr,Xrtr>fJT@B>a=7(zYvDaH@8uM-{"L?q[
yZk{Rp2)KmζU}Q2L]ZJTis*kyc@ƌ䊮@rsR$h2r␜!^XDSv< /:Ak +Eԝ&^V+(m@d~ġ[ݼ dJX$tl6 +zk*Li,"M2)XjiqO`jNFԢ-| 7ynxd6nEgN z)]Ț1i:TџPoZ%sVIC!g`wLUDɿwp+5"X¾ڟ(oVXB`qA栚Ī^ҧ)XD)o{0r1Jj:d:QS-FifÑ-89;}* &25(7ҾL\D)LJ\ǛDc:3D8M*X,G\5UٙAt*M|)3! +h"PjCryBM,"<êӟTKM|mlzFf&L%i+᷍8ؼJh&ۍW&Fb%T_$ rD& +U^,74{LL1K6jb͠&\uXvjF)ZNh""Ǡ&nƤ,.'+&6y8mzݮ(D](o淞օ"^
UnSUHjb%R7sQ5B^9BjD)Ws5ؗe͓*-R=5;AMD$5- +6?Ħ &Jʼn.Y(lx̿Mlbp&Gղrbb"c
piMl&>ӫ +Q
ՌŇߢ[i/pتQUKpet Nljsĕ\`-˜X{}KzS[B+(_e&8؈CN\;(u]x"/
XXt"{o٭ixQ!ȉ[bOINl7ȉ(W5TNUDɿRMtNDiu~ЉU
&vNlXeXDX<I"K@'n'+EQ[s5=e@ϽeayS6t:5˝Ībvg'JwNDi~ ՚燝(~G!$:jHz
N7RD'nʎNL9ЉoǍƉZMWnjxu2ɎN6MSeɪ%NtF*VFvbBkzTuu6 +/q|b.W.7JvnnxlkG%;܋{("A)ODjw٭ozIvb.g'=漊_;<Q/o$<QU{$<8K(e'bu=D6?DZ7|cǫk&;d'<'Ҏ6ޞDm9d'?O,`sX_1ىȗ3|F-ڷskYDdٶNҌZ6=Tp{+LD!Fh챾(<)eNʇh;7k@"N]qRUk؉%/v"nBD"6\TrMn
QDP]9Љ):Qв"B5u}rہ>ye5NDv"eya؉N(<?
h
\ +dŎIC+}^?s +KY E~R)֒ E)&HQJ :} +&HnH1ro
|y
o.o52U#9==-\jp .S9@)HQFA)J~R{d{]HQ*RDygO" ŪTB0@ b*Hq7iY:hmʞvYURLe).qΔm[j5RFo) +1')r7I>X)gDq]#A("r@&F +<AP +rQ)ğ|hi@C!q C{+Pt ",-EBB;[B(߶/!`B1#B(v"H"j;_EYx&".2lo"?NP!8QBѮPD=PPPD@b(vB(v^EY[@g(bM1 %,N
ZPE7:0PlF +wd("82P \H", +HKEp5-AQ]90)8Ƽc`@ڭb; +ES%_DQ1E9ObG("dAQd"i$EoI!(v")ŎF'E'Փ6/ž":Ez ",sQL;nKEgNiMkS`QNHW&0h +TUN<8yb(6m[*EE`Z3#k`Y2)o9a]P{E(v|ui_R;aqXx9E㋻ZTѣd@27PKa[< EwC;% - +D{ju[vooVB,/--EiY HqMZ)*~ " +o}&E:hT0Hܘ[r,bqA=PIX;-$N-a03<C$E$?(EnͧTPX@m2 q)2`y!`-LLMK}6#a"1L#"Ʒczۺ"n18uƾia5C)4`\ur{ܿ>U"$tETN`2<z9n3-@Ej3-8}Yk}R%%0)2z5!e}7։UY:t^V>Xyx譈6_ +۱8)YDE +>PQ#{-kD*EK9aBH~H3K>Hjs`,~*LE4@ErLEXeV`*6.6XA!0fWb`Uʅk߮rx7b*ve?e\9T`*ґS:䕩ةV:0+HEXyCQ#I² *-b!*9 *Dd\DEV_M0GR@E@EkW<MB<ElYSQxIrb/ 9agSdeETx+X@N1~ +PS>QX/Tq#u T4fO)v8EZ9Nr\uvn,Wa^c<j85)}kM3qDEa#2Vױ^nOf)2 +"ةBzK3>Wae)vJGa5?Ubtq]y8E6l)Dp'q6\|ö!8:=<EttbF>:惨8jbf!*BATd + ~I"*|3D,/aR +PVzSd0'xl + +M$JMGgh^0EEEHQ(EcP4}O",k'LqESd͵)ZzZIS֊_SDv+yL2Qx1p27b3T<wOZLPф!"x&P"oفpٯ*{/H(U +l^@E "۱q$P'LG +8]3s+DE$*19=3zQ_}$Qq{Hx
Hk,<*u%BXGQ*SE|/h? *5nA*2O͈`*Z4 +٧3j;c7
*"̔X*eENUľ<PE
h2\DqUnGp"8dPl90*R۴v8SLɌ@+2V7r\>Њp͇ ".\]xahE0@hEx*Y[Ƕ[9 +\'"'V.Hj|W"^+tĶ&S RdQdE}Ȋ4SdE_7dEF(TȊiI +2("jj"SsIVe;GSD*YfN.*EVd'p'ع(8$+<e0SȊkaٝH +p]LL"ͽ.1`PaL"K̅;W= civdZbnZ`?>ѯ)cyb;b "7ģVb/*2*BUD<s> |!zfI+UO#9_q"V3jk`E)`EXX;+WR"HeWqu
Fup/w{r9JepY>?L0Jr(-ßDEj3&z*y xO5"l"-l"",vȕg|"olY?~M TM=GR0;P瓓8 {;aBR* +UXUe#boqwYZ?Ls^IUKV8VrUd3N*U67Xn=l8(b=UG YDZ?i)\Ň\EAcqqU\l>9ԟ`EZVf>+BLJ+v<SσzVLK+>VcIS}Z~vMjP")N
u+RT}xLa/pE.G7چx8\1-ux9qh
D>&f +B\Vsd+ {hVCkNhlEd,狭hya -gX{Ffg+2=ኴn'[0d\VDC+bM]e ˢfNdZWf+E
=B[ +ኈo\?XO",+}}A<[pEjܘ"q%[qPyY@aa+ݎVƂӂ뼥3V&hEXt"akA+R%VD2B+iM&Zf; +D_hEX,`ZЊ4J"ZAɮr!Ě2B+IBϳ:<7خ&R^Zq q@Zl0bZ +Z1́V2! !юVDIm[+>,Obqm{GdG +_@mޖ|E,[EZ8^|E>+r^qn" +wEڮ^|ŴbhاX+*pl_Qv["BƢ "zKW48J[v_q:|E]=6xx:a!0|Et+R0WL˓+]MY\6QocC.ׁe90G8u±M v1zEai0mb>9C"WsUuo"iOdH)EX,U +a}'Va^g6MXIXLsuZ\,~»
]H< +++ëWD+hWd"k^د+z/"tEl&tf"*. +\X_YpŴb)}"Z 4xEu؛<4=#WtE|e=ՙ!"7>E$Yh+W8T H=|*rq"T~8DE8_@0<xYը%@hW¢ )2w/V)NKN6@+J䡓{ BpA:(ڏERƑEgUӽ!)!*< +@kHOܝd.|ք'B[J$"K0ҸcӂP&4FًHn<D[}16pq.`"T lUy +Bd<z^8Z<+ +9a]):1dA q$3W5xAs/bAlpׯ>Al=AlV۾H9΄ 6{ 6zDX:AelxA0As ޓ2jAΚiqH +b[ " Aed S0NjK",f
)2k(w ~|_SG˃ +湵Dnx3!tWK"T%m?V2oz!Ob.aCm#@B2iMBgCJ^3JS+=C,\.
W2Ձ=Dv:z%ƽC(rU)@6A;?LB<LcU +,IX-8m4ry!6*
<*琴aE!D؈hE6q +qvA8\.w#a|CYoE-P-Dw
1sZ +lVb,I¤+aAޖk7^'h*2R
Ya<,afZB6DuPH6bA^0[kph.@6j[&NDCZ8ѐF3 +Ұ1k2-Xw㘘${
oOa'Dk$V}4Ԙ#HC +gc4а^%bMݮm&kx!k,s h_)A֍Bw@C؏aBXp
Y[+vhiAhH> hHØ%ᆵm֘!!^欅ؚ{t4Lk +i&Ұ"sC +!d +hsr
'0A4"Gohs@CXv:4rBghѓgpgȝi=yvN3L#Rxiag(
\fg +(#مٖ)f˰ėw!GkgFC8 RwְFbЦu&+d;!S=MZ1<X83JG+Cf"CPC/kn_oCD^xrrWo.<]YO1TR4<Ja>]x9m"̹
+i[<q=;0 0pj
n!.`sffU +[+N5hP<|v/a@r/j!.zP+yx ++3*=(l}Y\
}I'5XױhO2 ++0tya= '0'lV'4:O>!XO|BXHŨ|B9OH,j<0h<DCsnyJ|´>aOhX!l|ƹ}<7Du\nV!Aju>!o Ri6>aC
}'D(:0#6#m^S0|BZJ>!z'lu)#>aZ +Pf ;ؚV>Mwe MQPȤ|cS6q`ϕL3C iٝUڶE&ydBX+?u2aCJp3ɄT2aLp "ۏ`BMɭh7XBD
Z/*!1u(y`B;ig$<2T@B,˷3HoO! +P"LA/!wjD7 +Af܌V<L٭u~=4qV<^Ucx13ġ?D3\9AFH 7D\AX TɃ
YMNĚxKA<<H !F̝YA> ) +=@ ȃթ`( + +DZ^0H`#ݻkBtN2qgaaOAZ/آ}V|AZ~_8N[quxA)xA)& (`CSVxAÒx*\/z^:}&]/ƤԄClA0тXdAv,yP[̹ 30XAD6*?>τ +^*~8_L+̇Q<A& +sR<#,AC(hӫd+Goa0 ˺Pa%; Xf@u DV߽M[U@r`@VUx+Qܥ&-B(rt +BD}t_EX p\ +Rl'3k@HY;:o/` >,D9=ӟ@dR^ &!}10~eaEDNƚȔΊBx)۲|2-d+'c > J Q@4 t@"1xz- +f H2 +xf(C7cg|![''\XrǶW ? +Ȣ9_4@pU0@dM["`H0cB,@V#QUJ ++ p?D jf/eĂ +T +c;r)j3@,q +3,e-EcVak_/\K``]Fp?&'ݯE`Ξt?X<di,.q^/_x~`:|Ͷ+~(`Œ~ZCrXݯ/_T8GbJ1@p0GdPye~a!&_c2mO̬_"0gy#)!~VGlBa5[b!sI(P-lp퇟ZFbb~K:,ۯ&<GS?ۯֈ(h6=-.jkS}`.Yƞt?/tnˎ~-~Xs5h}gBӍx;ixp#ݏ,s{Nzy[aXp0Y|/~(ɠak{Z4 רw/#NÇ{2o?Pr +'Ss%.Ҍ*E!>G4WgTJ3\bܯ7`TKUI *p\p?Zl +v$d +ܯQ1ŎUb\4εEG
Z,>>~8&9t+~tk+l?Y!XA=XV~gI{p}ۯ0~Vhl?61U~=ot + +Ez(0뇗V뇯5/4%~wt{Ynd`P!LX?\?F17!Jy!/֯QBZOh2
_ GAriTsp/1<L?"#b-O h[cD?7t%я
+b~N_>}v臦7P_fGaVяJ϶9&˼vs$2AYJC_%n+*H?L"Ơetm'WAX~(3CqҙFcAr{ÓGI&9я$qgz@Q8UZ4:BQ
/HnE UKK=ޘE~G\0|oYbJ!(MǪH?ZL --H?*#_G=u
, d9~VV|>~\iZB=,H?1tK4txXN#|8ѲG̾|ڞT?E,ry2,V$|vO'L?m|1h^#;`}[3ˣMa!JM1Z<~M/TV̌ҏa[ +/& +ҏ1NU!PkEAn=ʽү+Y~4SG5~LgY`k4wa!?Yߠ~HkOSH3ࢦccNZP>>ӯ]yRЖnXQ!~aT?ȭp,P~qi"_CMv<X}@ =?Lsze3|,d>~4S,~0(_)%O٭T?I&_T?(xTd"rAÒj`ٓרKDpR.~˓`Q!@qвU퇵\Yi3|fC2~vhf9~R?~LY+ Qh4ܞtozͤ!`<NAIwO?fl;6_ +av3گk[/~R'ܯ=ÚsC\36J'!Mǿ>וd?/o +#}hg(̀|GD^о+܆u1_7/$xď8H>?->AumROXCӊàB|Ys$2 Tq] _NdX?҇r,JVJHWKDABUAC
(7]/>6Zc +
$ +㺞]2)N;[1_$>Xٞ*owY> +/$a4z +3(`(_҅vO=L]]/ОGƾ0h9rdڣe&f`=]=nJ|wrPyB=:=ϙѓbˢdpfWQ_ +pfaU;f]Pv=,nh[hayuZL] cɒQ̐DnkP&i`>'Q{:XK=Y=ut7ai!QՆhB~ţ"$y%5Vwp".^>JڣsH{)i!݇@@AОh?NCiqk3 zpAk70DKK!9H{X1,##Z.w;ٓ5({eW2Z+ +c֖6={X@e`=M-}ao:{8QD˱[ۭ䂰BKs +c+f0Ykp8`ņ|6O68_vB_ܼi$zx%q5z`S`g3UBcC$B˒{ +_a$0L7C'_/ sR'7kGd׃09e܃j
8=l0 +,}*|=|=639|4Sъc$nt.5- +^0OJ"i3%ӊg><ԯ& 쁣Ŭ!(w=47!C.`:P\].|ȱ^q>H߯9suO,E%;"b{W2s y -?6kK10?4)Aޕɥ!?ȗAY$~RRyg +HzI<+^Mj^D~M2LBgUˁ%,~L8TXojȧtOv|z][^˿Q˟G͏??Oo$ӡ7M|He79^dۧ/h`0\V߆/qf!5/Er}q?;DZ~m@x?s +'5O7;{S8@eۧ/OO__~$ʛM_v'l鏾?OQ/-{~]O/=|}#n57[RGnù+Ȑ#6 3-_uLUPd}͚R%q~F6u +9-`nfCnv.W>X}mp{zAe'qgu!ˀxVdE_k TN\LQly.: 8Vp1CE4G?Ng܄(/&-16qEjP. +uU>6n&T͖A;\+0q_ +6ezQ^aLX|Rk0~9IDW4"fw1΄9$+PLp +XXu!
=Y!ğWfشnE*24Yv"-MK- + +!>2ar/F>Pv#A5aGgCc"}KRVcnAG(߹Fotp?bD`0>t&6@!`{VS}dd?V<)2W>m&\L;*lytCyuie(1oca@\Z躕g{ )3>=0?7ZY^uDQQ'6UOj?1|RZ߆ ag +^G)3m8v!=\^RփҲHrgX(X92<ӤY7- >^fE^2՜^%7ֱV6{j(+6ix=;~ǣI|N䙴FM3ڿ"uS]xn7~]O&* +vћJm}3a#Q?
?RpvHpFK up4i?9τ0 +u@9 +QH7-BKF)GêS|ӻ%Zl}T
WO_Ǣ>*E)YPE,)gPӿ}Q!c1|d5[P1հn@JN#[:+̭ 1[(\YUGwR` kSNJ +hU@uBVB3RR܀4ʡTC9)J|txfw<QuO
#$-sSŵVoETʗTJMRoOU :V ʹVPx4\z%*W>.E2ZgDGg%2Z(a[@ZI_-!~GLf-+=$Tv;VJenPӵTUi7s@dYI/;ypJe9`ex@J}Mc#p`q),u/p:G ֊U:}zSÆ+Z"JrпPRyR0o".M3<_דhP^E,?k}WTy#/=SY>},!Ȧnq߬ndX24YG3_DYȱ52,vv0|싫dQV^j<bXIũiC>FFr2'9d<Q!cDN>n,fؑ,~į\Zb,͝Ծ.00N?wί?܊; y =n*eRyBo~/i`K9x-Sӆ:x?\gމx9WX:w2 y7"7G;g8_T cju}&6ю^ח|L2O@~љQmz|C3+iwԥ%?WrmG>!Hu|Qm2Xwɠ٢0 +-/LGߛiO +оݿE/3.BWSȯ8]!{W㐳yp@@i/Xy/ y/'bFr|: +Ȉ\bjur11e 9-ڊ$ਙ35x)orlq'Aպ $̐ǻbyxfYd~X?mqW3}j?7-JgՓgJ7v7NQz[,MlG{J/r+VrO9y)Mvnd7jG^gjbuC:PJky4-2KC$ +ݳ`7}/уO\6S5o9;bjm}~R +6h=,P`гCς +`rTEYPBQ +D\ѳ`Dҳ ZmEϢSԳp
)hOE+=bh(%h4Eo1Eg|(Z SJ,!p0*PxHOE>džI.Ibe
h!E#,Z +خ%bSWDdVySDV" %`j~2<ahD.ZDLj"XG;Dȃ[Y_H!<p/g)\k߾=Ňï;y
Ov=G"(&څ:A։Ď"E¡{6atI&>'mPѬn5nآUw1oi4A3q̖2:Ʃ*McWaag4aE2-=jF+Tf7oRKO1{p[m: +RtqVb-mⓏN>JcG Q?ASj/Hmt{`F3E=b]/Jע.-8fbb-8/{r'<. VsPB{9͍U݊41cO
88gv\{,ât4BYQ>7ۃT{kO% +7#x2O +psiCvwe#g&#ڶA1S1P271#3]p>Ksp
\et(zWJEFf3i&]ה|P>I|?Dۊ + +X틍40&"m +wWu=bcۛyWB`Bw [Q}{K6)x:Wx|i
's;#ZHX*bذ0jfGtQ~͘P$ݎSV95Ó5/EaWsT:9"Qj% +^ u4ǹ(Hyܡ?(>*R*N/B&y Zy_kS= MB>zʇS S>C+q-rʗ2d7izkӗ\`ӇD3XFN`,Ðb1X)ZLQ:)Q?ǭì½Zq:+_!" +hkwP|<kS΄kT>'fH4iP'1D:xy Dsڤ9|H C0r=))TݸHۋR:zyJ32Z*UUOɫU +-=_ϴUY 99J^qɕ,axJ&EVPZR /CiNN FӺQb5CyՂI Ji\LẊSX=%c=t]6vl֟JzuZf=i/Kc̽ipRe6aލ1r!=Qu +PK=Fs\*;G>_O*sQWA@6Z+}]q +z)Yo0~?Pl:&jʇz<߇2\/I!W]{6Y?q3):e?-z?/LyGBQ Yh-y*b)@"|& +
mՠ2>5~k}*xtbWXwB_z[5vk|q>IWzZ4QR/ڇ5wpfmo[_;c۵vubUkkqM}nFe1&b|6&=ṵEv_#[:]4xA]9=̰!݈Ծն:|sk.j"tn;*_w/f'8e1AɧKysO,ynDy#9oH.-k{[!Dt>hZv5z_6G]z~mӷR>*=a?\'Sjd_ +betjGt4p}@q(`=nfQ9)r7sAɃ-;q틊Ȏb>d +DM +@A+wy?ќ%upg{QjIRUq;I{i%V:n%bW!9>'iSU[Ks|7Uz(@A;SX} +F0J8N{vK!t4C+(e`͉ =qކ +{mP
&cC6d<Q$05ʌ+~X+10,?7bg;'6Pg^81jCqP;]-A3( +{d( dt(#)DZk9d-Z:3,SjEA˂VH2#_?
̉\9Q3}#Apn}!C,]xE
]:lzLc)f'V,B +6wul"i\qA? +6a[}un;.A"X>'\BPrBsBbb +::E/0DB!dfVT$&&@D)DfTa$57Yg)cR3}ZKD̔!fC#*?J +VEߊ@@eT'g|u +>MEP +ʪ\:(nC]
. +QHb
W(kHS蹎}Qk0Q9{LzM +kV5(Qy"5E!<&Ԩ$P%:4*#R'deN4s+=-
HsґKO=#<g,y1RJ\R>,7<LRpr +eDRg{y%fw9[z +鑕@j#Uhqݽ; +{/w}HBp=`ޞܑ^y['"?N}Эdqd90l{X.nR_v
?iB˶\ rHz'.J;{]fBF@,|8)>r%g}ņTv}M7A٪1idRq.H8Ӈ
>Kc¼D)x0=UdZ1.2<;b0>7rRTv)HСϹ/8L^պ
+E%)(Lnb_Dʰ?1 ˩ee=dVxP*d&C1Z]{PoI ǰN,L_vT 5aҀT|mHۙĴ+?7]FҖT:LB)jBdOex4)ውáHxQlpYuBա;/1t\(@lg<[;~}߽?y|sܵS?DSW,coH1rOIGu$,]L=ƤcPoJ `JJ1(Y@Tz_O2bP:= Bv2(aíǠY,JrPs#|
J6) 1&1H2&Mr6&p1&}|p!{\Ϻv=.xPyux\z2.FǷ~cX}juƏ/!H+(!7ê82(نBede: +DSjn,Xg, +JHAlmc^Pmio(Pk=N +~SW?{VRChFްxBB +!Xl#X:pȍ; +P4>dO
9X4=q+Py*8|FmgĚXA>MnW(اבzCW')%&~^ݱ,YψNfkc6j?;H]*n59hXSyw7k3n +x7c`٪e8H +w?𥀪F\j=IKC +*[aP<D<<7KA [ùFljNn$_d~6{pyv]$rpuq%=%Hdvӕp$! +=Ўau$5:>ؼ*TP>ґ|#Y:Wp9qp}{A2]_&]\m +!t."%M]Fa>Ժ!su25F&BUQH2dP*ˮ6זQތFBYFI^ϕe8G(ԔevI_+xX)<Ӗ\[ +l0&E\pZ"MXcB((WH$!noS\\"Q<dU$z3$+:! 6!"eK"Z[EAr\T^y2l=ok/RMP\iAeJ*ʆ2A76kY(EJ-em*)" pm̵kj]Z +ցOIG1c#xu-HCU;JPR!7$]SLx7g +6ʠUCH8 +fRFͅ/eәYy`'s%3+GAT3')(P(MAZ%HŔRW}W+'Qcz. LFH]ApHH9Ň+E2RԯA̗1`'2c@FJ Dʈp\OS>MN߷Q!#9a!(' +Ub&4# #z9Uvjj+()?idѡL:OG|g@H,V87Z $&5u"aiA)sU; +k))r2d)']ŷhJT*嚜MQب#e86 as}TqȤ{T4 +8F (djSP m@355LvNPt'u"+fPf(jሺF8jP!ysŏPW
ñM piT/Us%Ȱ,6L,i̔K;KUkȠU
H縜`k2YyvaG *nJF:?ꮀK'*(+|/Ul|Һkd(X^RlbD= 0 h0߀Sq$o&!?mtpMGdِjzXdvd=aJutWKeWfiNSٗTrtCcond)x_RضkWY)|[72%:9~$?jd=AQErjvdq,ٙ&x"v3^Lؙ
Ȭ`-8zk6k"p|ajer`EJ{+bޯscR3l9}cl!\lWbV +\k.!`5;A)*@L(&&!PƷJR$%4g<&T&!TB'XqATfTh&~Mɩ8t1<;>MP}W'2^G#}em|K Pil;S"7Gdv4PH'LBi $3M#|͌`j2دZQ"Tw*3%-yEeE)â3PōLLã!USUrh8&J%yGg~}b]RHPAR0!we'5Uiyb3,*6;I:S8Q1#wj+BBD6U#وqjHꀍidiq2bs; VPYN ^V,@L0#SK=%ގSKmGjdZ2dK
[j%뫤洅T[_w0|϶m;pkh۬v +sҝI˔3!|,ѵ jCZ33͜ksϼp* vOݣ7*ϤOj^ę ݍCy" +r.2wwNSUy]U7\90Gi5Mü3ļǦYuuDUV];U՝䪡#ݔ8ϵ=s͛V9v?L.ڃT_3Ŷ!CTK1
3u:hT7'6L1S9kMCRh- +cv
S%/P>5e'8~|fv}76U5̻V!Z؟kU3S5ELV}նA_}fjQ닯V +^$`ɻГNWG7Og'H\ +LY +;iоJHu +6N_z!Bwucھ+L(~}K}T)f2\xR\Պ8ށWF +'9ذ<Iذ<Iذ<yƆ%J
M)9DIذDIذDyƆ%JZƆ&Jjh<}I͜K(]\"Fz.Z9bK]eȩEPV%rp!ډbDf6%<K䊄.c&o2."h:2JeL4!
&"
)> bDN9)nہ4d"ȫ +MsGN&7A76*6ZDN-oΆ:/g",@mԮs Ha 0~jpg +`;'W6(`Sb)I+hjѱozb.P. +:h]ljSfr9sIM!R$~:Ged/)ֲ8NHJA<!t(|&-3q>&+? <MD|0P.1_8@w0 +;=ks$Ѥi5O$JCYv<5m?!c1D(=pyU0B"f09c?¨ǦExQ$+!o|xƟ}|(Hɞ*1ǡ~tTgTu5AÃ +f"Ar b &4E2k:Q}[D),D0\(8'S* +ioVwXޜhNalIAPk$pd.`N-];A +PFi:$~uV@sr0>|2RV12 Av++MЖf;L36KXh#Ћt9]v(j"8nB-ut
CbNhh,0 +P4I6e̚C +P8M^n>U 0%Ƀ b1CDlCjVQe$Z)7030T"'71wFR3 v:}LaѨZ34zjQ[ێ@ 4}U%`Zll@Z*%I +hM p>P6X6}4&6 ُ;aYsR%LR*ՠ>;'kcF=p]ʌ!pnƪ&I
ͭp#z]ݲu|lglam4VXJȘn9$f@u[ӏFN}7,qS$N"TCb +|)W +?pY FDAU;r, uhP⭈(2ٶ0V7bwhf!n[[(&%a`M@Zs^I&2sl`wd&R\eڃ#tNLIjȜމDX1"ˑ8]Fc."xҫw(]̅EcWA2Ϋ +EU.CmA*$_1t>|锪? PŖW!*5N*! +1Ƽfeh]"N,ie`VDJ=Ū&89TE
TV +&jBv8t*qBJ](i3*\!MÆ^ph|c{&сw(xH1M8WMDVfr&@n%0ɕRƶqL +B#&l;>(^% +\dm$/-TKg՚q➺ȺHQn]4ǜ K3֜]\H6 4q{*gby4M~7G5\TKv{V4 +c^cbSԣlJyʕM3'jir7}>ETWiҟk7 f +2lK5iLl'ɘB;8nZ{3x +@TPt-^gR1w2/ÿ́-FFAX1uP9X5s.t8sjVMžYԎF@]φZ,L}~ek`*[I,_}6jB]Ń=N6uvr+\A9 {Swcsyu5EP8?\D)T,vݯ<`H2jtH?V<W97eʿLFf@^#( +̍jO!EOTpfRZR!1K|FqZs`ӻ. 1K9oRH"\yLj`I99c)A]?-A +w@I;mŒUNTޘdWaCb$E8L(䯳9'q#G)NR₎ZYYiTo
|k+=fCrچ$!b1āY\3ֺGpK,86ާHI\aѰ%d@;'-)CW?9U]z2% 6\l&(ymG"W\uB4ޘW\iY.Ԏ͈w55b`LVJ|Eg[a"cݖ"ӥq~Axxbv:>vr#p5K2)jiTxRVF +d188;Hndhd<I]d/UՑ% \tlΩ +N^p CgPyX͚M5$P+snLӻMtRWi/d&%lU Kơ-H۱452VWaLW 摪@1l|&斥a!P\Xs,X +m#5`5k
p'#W@Ci`Y#A@@3i&<~Г + +2%h)X$[`7 +j{ళ +0n{gmx;{st͓'?MO??;ox~g˘6g'3y㿽zI
*??=~xu]u`Sޜa=Noݗ?k.JD <g7mmoY[?=mϹ=[^'G_o?i}?7>o +endstream
endobj
43 0 obj
<</Length 65536>>stream
+j砡&Z3Bh
Ƥq)a~XylJ=f:|!:hz| +O$KggeJ_,ay4r{;FQ?Ocǰ[vڧ_ɠ>"-$muoxYZ9u><OW^E}x +& +aAF>o=&
Ӱ֟矠dqӝ>r]$/,(/~>aL6O;>&`&m/|z}4*%23 Qfŧ;}UD>'J/ +s\ٳI#o݀ +⁄/1F/虯~xIKI7|*2N-+6GAZ\AhY
=y+a6t=pFtTt0-EEzCH8` +=(ĒgTDUġ;ӈR&
މ"jY\Qc~|*rKG7~EH@HҨEYiC8ѹɟh](}ʒcU:BnNUóǜu$
d.NVWse汯.%I"@/RCmŜ\TV_U~bVmFQ.L6mBLRc;٪P<iqx.tm+]zW}T[2VۧD0 m6K>O[C^zxT® +\2Of"m\>N}*# +L*_1ȄP6V):!0SsK_<cU&MQk"χ*bnig%1FjSv!b Ge _"gRsbY@vxI_9᭺ +`Clu)4?ΏG(p:78c9Bzй(8<dw74Y]P:uh/F@jp.kIQ.z6,T|0$.Q^uJ)"U%D7(FX0_2N#~h*Qfh܋E,.3vK1UV0WI*j5JuVvp2C)63:Cȶ.8P&+QUvi)a1.G;v͝AjNl8<1]M'!{xzc=y-&qQdDe'z}0;㔞")jzge?uލ9DgSbB-&B#
7$z.CTYt!(Z0"V<L<DLcd +'=h3eIT9SIyWvcD}`D2beGQ\Sá"\5(TwW]ŗI '_S +!R`OVP +)ؾJN_-asZޤn295]IvL]Py`t݂,1+g +M
cʜp+O^TãXz>kf'|X!Z,(le+^ pe}lo\@$f9?J]"F3uBׯuU
.Hd:tk(ц'2i:?kmSuX.DQ1y&W)^靋lrk5tMszGJ ~wz|BS7Mvm#1ӑJyT1{IG9vδHA=~lpVQH\3rCV7c0[>Օц.IA(";(8ʆmސ@)6ʂŦYڒ&n6QҪ>-GȎnԪz6*e829;hIjƏzv}U&d;YVvz"4YgFWaLUecIY,j'#'01X aD5 lgzv+\irM1ӎ>H#yH-A.e(n`>3)NQR:=Ӣ+߷+o"sJJ i0gü㲙L!87m̘"RQZabx!^vDQwcRe3&9@X*]Bq%;Ǥ>t@S:d. +PD)ɃW<]{،Vzjy62'qY2^=p#ȡHg%%T j(Usl
P6<5C}0Du_TO[у3[rLĨjJe3U
n_ʼn,x}at2Ij +$Dv b}] SaԇiJϺ:G1k31;*86O7F]d}ʂt*hq#M:P(q F`c*ۀAUbթKYnR(;uU=wCT:9iLB6w]GIdh^1)bdG0rdЃ[ݯ&9L[A + 0f3Y˙F[JH" +O^k!AH2d#1
N(Ac.GJd89ws:lslˢZGv|c-xwC60 +k}0^400xfmaS̊1ZQrKs>Bxb"!eپ鞯FI7.g1k8bkm,WOB8t5|
U
JT 1;bU+ J|`2kSZP-2(^ +fM& c$]ZEU`
ڂ +
ac-%ӓyQAIQ`aDʄwxdW*A4]+I9M GJW=΅~B#[ +IPOȒ抪N좓SLco6+Yd.H4P4ї@ +9Uǜrﲷ#K!Ll +)mVb{4`4k "U((aJ~o!jJtk>ҷ3U-s)hxw!066R'b!dg*CgJ{m3>KPkL\ǩHMC$h'1QiIbFj溽3PeVUF4St^D5hYHh|WK9\.3(31}Q1
^ts18 +9bbN9B2VO;&8)@.̋1^ʮ">T$'ِɒ23ѷdlIQT?cNv&kP +g8ͨ"vS^.7~Ҕ'NS[XQJj JH`"!jJ,2d!eM*N[д5 h茗"l)Rٮ4P5%Bl92zЮ·y9P&)=CxQ$bPcɺ#'01h1j' +_%ij48 +h0Z-1nWO\P[,0u٥Vă{]`ׯǞ7RY8J+}~&B,L XDt9CaU\}liꛗN9BLt¾!,
&]#i0J"`4Q* +l
6gsl;Z^YgJ
rIV +YH`;yZ=#E`07Rĵ,kXx +|-0pàqH537́s!WDLKCURD@Ж)09B,㠼Cs9*Q{k*
^vC d\n ~5kL[pB%6)8IDTZ*Ǟ',Hz ds(ԁ#s)xRP0JA`MGhYe"PP&T&Y=j W,yreRal;UIEUswt&` +^ U'b ' 7KQTRT"1ZcFŃԍ|hSBj6y@HؚqnTYGcӝ0QũCL ]|1FG5A*nx79:{=Y(9Dͯk1y&نzC H&KV_XYuP@$1qX<*AVa8zQ<s2&o1_6_ +YFђ))NT(vLCS~SIDd4ᶑ+GI*Bʖu/Kh
{ݮ b.̫C ֩,
Uς4W{%H
}h5[N!܇9AyT:8rB;SRN%gerbȦղa'UN0ixl0Ǒ5n-rѫSJDPsjf:,ֶ2k~KJr9!*LDґ#S@\=6 $a"(N`Դ$nx,x;r/!v)ޅė9ndyW&
$$- ~Dsr@ +PB᥅lb?'(P<]VgLzT39:9L:h)7YR+;naqFa~)RxIΪ5RJ~z +K<
U-;ͺ!hBɓğ-v4aKa,UCtRa(!i!_R]-y^C4]za84蹘L +Yv1^|A' VL +ʵBm:xk +lCFE.S'Qʵ;Rzz%II +T.@bN"S4ɘV +G~We-)
B$/U5Zإ4{|21SЖ#z%Cf'ץRRSӓFU2J^ͼTM@ dP=sU櫝x.*Ó&Wogx[ +}rc̺ȍqJkHk ;β-{84_3kY. +kFwO~$H/L&6e% +QUL]. 6X,Z1jz3z=|v
$Ax1XKoXr!)gTf{KeJ `chP`"Q1!_3RKUO,8
,7B@R|Ih,4/J{ +%kMxX,WvA.?.1aMS:upS26[ r.!] ++LT#2YbR_Œ3a1Ol͗ iR/OP<)RDM!HNd0m +ZpF@폂f/BH3ƐoEsuQ5{Nu_蹪ct5b.LBHOe~UvɥR +竧Vbf8a\/Xp
"p0UQs)edPPtŸTP<ŶE4q%ÄfSj&3R_RBt +%sH5wpY5 [ѼkS\ykbLUlv(Iu#Y6Fs[Gy%%7E + \(]QYS[Ś-"enktY5$uP^f+ԙKDE<x?7ޅdfj+L6YO5TVŐ\qf"tgf\D&'8-a?+V5pѫlұfmgn %ӘJA,Hp\$x
b)E>q8B6>]5 +kNuyJ(CU ]4w_WT:AY7E[ RwOW\v&,}@էÁkΛytpov6y8Y:/xy V&3FFIf'[??#YO'TԬA듹"zhay8nN4s31o&s[8t ;͑[cΙ.ֺFowQ17dB{fƌ"U\jJ|-3c/'4baom=F kdc=/ɽf"NIEWptY"t U"^qyM)nBR=]wT&¼cǷH:xbQtl蹬|l:4Lml4:LOFG{2U}J؈>g7g_~g77?_5[?~G\==zvrsvuy|zg~q~:x}?q{gN>;;z}zIO)˿_˿z{տ}|~]g?lt/U@&GZЏCgӟ~~vnZsn{PV/d +γX$v-쓨߾Ly,v7yR߉/?ićM"@uo'x~zxuČ
Xҋ5/OϯX{i\oZ|/-3
`[rˁyw.v쬊~쬊;}qkgZ:bܙwŇcZdOOO/i;(}zgW7\>}0巴Yjʽβ&=~zFVD]#J7äXcczNO.y=ܓ}(ӱw7D,Q;KD,Q;Kjigz(;%JRdYMY-KSklo +;$y5
FI~Hvw$yw' + + +<;m,J^{Eڍ7ڻulny +`/ܑs&^gً
q};*.nw~էJqŴ2gbw\qWt1_ޤ+F0/oWr\DS}Wlebӳ
ѷWz!7If.דw<qtm '^v'nIK-xӢ[ږ{Ql-avEÊzuzsmn{~h tuE7w[o7
6ַa +wՑi_vK^=yKm9ȥ5~ߛl-aî݊b|]`h[v=إE]dmM#OoC.bvKإ]ro\&u[.жLx$K,ж]v.m[46bm3\*h˳InyNAlnu<<Fw/@o +NBkZ.J]jm1MKowa۰
+Hyv;*]Cft(!-i>PDBrىBq"Ɋ,'T?fLyV`
?s{wZy +H,H*APYJt}XZ)GrҜw=0<$&Boqac̫T(!V aZciM$Y8(\m Zp2y + REhL8/*="Yy9< +<*3'vNcj0 X`N +dxN>R HTS ,[B +4NMPRp%UfmvKSpe2O;eȨB8gHļfj P~-\@xC9~(@ +0{g +&\8 +hY +H:,2%e!t8(-(ZQU3Õ?\ +fy,/KQ'*J+E2CmZyZ0q҃o4O~욓!=^PJ̱¤Eٸe#^
Fbsy + +$X0jFP28V8W/0lůxZS +u\e^,ZZSL2XHz`(Z~(㹙c1 +(+iiB +FarrfPNgQ(++DBߑB 'Y9z"8; +(WBxPV#gGs߯Ҧ8&'!!lLi0K%@GB^=>WtQQZMy%{Wj1s=,^D4a.U"T)ibݕ5k,GB*|Dzh85)T)[l.l)=a##jdd^94rBBwd +#LlIAc (&ԗ"ָeUTUC
@d_;Q83c4[\bQÑ1.&]M ,AIRNՅ_?O?dAQBpNJ
ȵ?SE~A`@,TW0HPh}*I&hC\(9w[\b4(06>VʙbuK>T@+$ w +R^X/H,ZNeq/,4'DQIG
A?5VV@*8/^'R -L)3kODSuu<
h +:H-+-|fĚ^dž%Q@tUCxɼ'Zo?WVzv.3Nѱ{E_op| :D3A[hvxd9ӣ姀I58D.CXqEZe#xNTP1V:iS_sRI +{D%9Lx[ٶB={i<z:8>RZ;"!Bp!3ޛȂ(2ܟљ|G-^b6s`D@4:91I<3ID +l)g gbb0UcC*Ȑ ?g
< +I R¥7( +T $ [1`3f@<Q<:Ըa/^eFr e6 +3(TR;~N{*:---)j.6X&c89XXRF]EfQ,b9TƆ%g.wqG}pr<8۸˧OXV1C^!zv%Yk,_N{6#mk_n!ݮk^DejƎ=^'=-ؼH 랍bsM⒰1`bg'am$bxܤcޅ`l-?%3!ΫaNX41@:PR#-SuyN>YDUt>w1 !_`7*Lh8أ'`m#[1'8wϸQ{ӌy)Ѓ4WRf@n,gYVac,Qah\4) +3 +frE8:(B"amB>;PHQ=+s
6Ȝt$
q39dK3LRpZL2s z8u6idɇŞviD}ƕcȹd^>zj8R[W:A39,T3b+xR8d29c⡣dp5TeR;eoyg:'P/)pĹza]4EIeO(;@)V+2zO#`!P"\61 U瑵Aw' ML7[Ш^r-qkpe"~O:%Tv,]<!¨
+橞a3J202Hh-Yj",_+3@U6uo\+M|zPV":'H:&GF
Rł+҂ChG:A/?l
hH> +ݓ6uWf/EE2Bq9fm2yfIrDI0b^͕|{44{QrUi_9東@"3K3b]9vm<l.pS挿TZ5{71/RM`G<P~w7jI/YկzC!OowAZyVwXd`Xt&2ϬTT(J&uƞo q$x+{A:RA[³ԁʢܲg(WJ9L_G1(YlYsRIժkSB 6!!qiX8W`RON"_Sj
-LLAK>Z(jaE +ۧ#.)$}>
++^N j[ْWJ'T.Ǵ=Jꃁ|
Q;[?U\ȓ82Ydgme},,+4A!WtJ:^1j9LUJltqY"xF˘]R!SYc>)t7]%ṝq=pp'U +S]Bܾ~§Ϻ$Q,H7AE|A\$h[a
wH+aSxRnfyґ'D!%v1t{zOOap=}9% +vl(k +sY8LC*! +g-S.SMJ=Q?$KVeS^Υx@<#)g( +%_W+?AT+֘DQ `6!bvE-Ev|HfJ<[c6e3{&v
v)C
Ɍ +\)>d1ou6\1lsEE}t'uya!`h1DQ+ ET4L^Mgd"nKWBBzm?FwWbcDIClxjbU)0Q8G6T. ++UZ(q;(JK U/3#ܶpT.8
ð24u5YFW/U4[&&Snʙ LXh?-_K(OL3`$X<bɊf_̬*L`e' +.=kD%yRA8n0T>SfD\˦y A/tP@O)R? R49C.)B?Rd䦦㴛lV9TBaM hAߴmih[K0%
ɉK,kfo)^]Q&D+`
,g +KY7#Y1K'Ge]Q)sg@0a!F贳߆ +:i +]$S +,e_'E}ۿ +Дι~hjFWBBfK74B! +N + PVi6%D[_ +vhgCRbUZknvX Vͩۗv24?M) EJ?y.)AD(Q^UMmϵA.ύ聅ϩ+ +TU2Z`%~ُC- +uJq)3ݔ]r,NH.lAǘRS +nRn?v?wSJERr٦nc8MO<T:Uûus7"QdRKVJcen'lȹ>?v)FIQ|)&빤م3lh#m4r1`(Htcmhvn(D~SxI6 0эmfŎ@zpi>1K\U]sm3[yН~O3wk"g]I[K(/gڸH lk@7 +
TLflx<ګT8aoꩴ.{((&{#.B] +*@kKh6A@n^yG +߿ @]kDYiuC,/mI~ CP$Lkɱ0wVEbx{)W"dvPxmXw%$<@+9В=\e~\#HE<lVP7;l t?]$8qpW+HWpxycE^:h6U琊DMh]۫E3P Xl +p ^Un yP p3 uk 4^mBY T ^+*Fp05[Y
2>"<g@`6$X@ +wW+ԮWkɯuh䗎5P&<Q_mjX[DsSgYA +Q[w>^Me=3lc,cܮP5MlΫ$KC]j3z,y +m SϨ>Mb =gY+S +X +M]JԭS]1"@Um~VRC +w<S>BUc~U?{'F"\⦡~I=h]=df« +BVݫӋV~-,
;2rLNGPoLD\(SWM:q0;9Ay',bZXHN99e19uUrs~q:'^)Be\f^X2OXetZjz֯Maidn .90/UE +j$<Cgb)n]`M&^rP7h_ýAjZh䭽5(^ZQuhx{s츨9oPϊ{(R-Qr.uGݹcT]/, -juc]Eb +cR)w,AW1V( +A[Y +A +9ϫHzJmyzUiiۍWX:p<Zм*`<1s0 tp߫V
bIohWeRWh[?moꭰ*_Pyuѥl|c_/T + d]\ +7Vf{8Hjjl-Qp=h8ϩK\ThJ6?eqBUr[ijPxFu":@4ۥVQE[RIJ52ՃzjЍbrg[@; +9Uy.TCUX-wH+>}lJ
" 5/0ԛ7(R=+9Ë}oP\0gUlCuOTmdzFPԈV*hX7*TU^gUG
HMwXZӮGCz 7Pr7h;-<&&x>JBnټ]z,PnpF#^f(sd ZuO| ,YQ`MbZ@M@t5ZBIW Clh<j6J9
ZclaDH5ZCy6.t
[dgR@}@ٴynBV{aT)ML,DiwɒgڴdE5u5pfo|X@]\ɣqC缠[v[\lːۦyq.acPňTmI֬[;rζםE=Bgڎc?{=Zi])eOژ͌+2\6;~Yb<Iiځ,lِHeǺYJ.pЪ;jKvAK6`dJOJB'AP`:&{;.o&QL4oҩmbKIŤ +;oM Idic0oiɮp+6ilPRy{trU7Zm0j?GלVϜ5{3yrTs4Tz1yrK'gk2]cnWasƸ7a +ח `
Ym-)
Zs*n]3vvܼ)߀teVjLZm',T(AJ#ltd,ěZRA=,O +k=؊IGC`{Ψ~N,RPA0>?
p5dm額tɲt6nI0Okwթp403'uȲXs49VL/F2̑&E
pl +dU:K?m)X=y)1RW
&7h2RE78icyKQ̧z;Afzhckݨ~tӫU(^_:B +W +ګT4Mp^4P]B$So
+:hLUK3^}TkPUNvn5_a ]UDžWP].4_: +Xй +/4_Ah;_z +O'4WAbM[6WAn 4QӅ*( ea@1Z[^= +?XTh\n +)E -5sQtem@)1݃U5_A%$p\J +WWA\ +1ñy +0lU
)zml1Sn0cD57Ab?;)ӽ +%Z\; n"jİDhY0V]sHդT +٭\I
ԧoVsnN:jb97/&ڼ 蜲+!q9>: =Y{</X6ܼ +;LK3;7EMW|dh+t+:g9EfW_mwLGϘdNnWבahjܮFnW
I&Y2tKTcxK6Lt~FR#v<KgssUtjC{Saځ6{0ۆnSMU=הm\M-UOp21n_`,31=gdW|\U7NFPO`
Qb;zba$9;s#vNY0.ٞni4Lvg>5*b2,hȰܠU'(2,IQ-ZS(fOܝ2$F.r\:mRpf[\$ERH)*[?t9hH/lЖc4$p'@1gbXN2y7yPPS+@Lo2
^%#!oEh[Wr(~@⍂kmQݢ(k[
- +z5E>^~ ǜԉ9
K4זj2*C[܆va +E`
aAQ`J(C#z'*gN5w +N-Y{<s3dW*Kу`@z Uh;\IǻMd(7˔Eתh^
8h-F4DRkȋOѲ-C}4@Y@-jRd
wZ[0k^ej^v
z.H4`AF濶nG~(6RXS +5r+625<;Tz!kުHogte! +Xv_pHq_n:ȹ휾[=y$&ִUz5k]iGzRO2k]5ٞo?էSn_Hm,yI9ɿT+m"ȩN--)OI
45AO#4H4ZO#'lDj{ۑ_qǭc$\1HQm~ϙk~虰;0m;g-F0sh~6RMk_Fr!oqGUtp\[6@}*Y9iP(Vj +~h3$6`i2 +ӁkaЏ==>YgΙ-a~4M7#)x2 FGd&{qQ<Αw$Ё2܈HT{;@_t^_,Q39Xϰsշ__uֽ7 +w| + \E-slm"_N/~M{ޑ%Ekw +|l@'.Y%Kt;H^sҴ<bak]+A>]Xh6>˦%+͝ +F=SXn╬> 駩|5_aW-Vt7A%{1?*`(%$G@rde +i +2`&e6PnPd-+T 1Xs543 +~D;ej8݅UJ-pAJ]HQ[z,Ky0"Q!<{JKڀ8 +WO{K,L,ƪ + ylv}; +U;Oe CEB_ӵ{{ƠcWr~cmuAEO0>w"n
U"?a$ +b4+WxiU7Ֆ[`bE/)JTPOj/ VMU(0ɝ{\ +,Z +yNK?Vʠw3W t]8:=Q%11n`j,xNytW-'Cyz-V͡w#^OuKEb95)rƌȋw* +n@k&`Ϊ>[a:e}-X|1$Hx\㉜K9[h4LӞ7BPe_cNB~yL, +*ZRUh@Sa1ݷ5<ԪǬ +r<l*[UH `^|2Dхzk3P;H,
6
qW0m`ˀ̲z;Vtκ% +3gWB +ë3Q4{U +:(+e.nɆSmT>;)Pme$K[*hZXjo\lO*U7~t-C|u7O4qJ*#@N1NΩ ++Ei1med`LHyM+^ +m
V +C +IlC:3fa_ybd/zMsp 7:~*nTUQZ@GpP*w)^.+ymjjI3U%q9eǒ(,^(j5
i`TYEBIfil +P;Zz9T*#(+_zh^ƺi{]he@1Vtx5r3XyQ*Y6QZsW_D-@pF䆝_e2UWY<dfU +4<"Tu!{TQ*Cϵ88gr 7 +ѼXYK(0X!@w!@2Y^u, +R`1¥e*H&a
S^`%-S9*L`4_ۥ
qrqT.+gUV8 +RScڳߌ%r,$/wt+m'Ž/
"oIS"M
Pgx#uoћ2<Tqp([ŀޱ*dF;e6qE|F>B(VP2{UzP):|2P(FV~ʂ:^afL=<!lg\ Nd`S;tLʔ`;:#U=%gtjesTjg`&*Rf[p3"fV6~xVDWP;Miβ^' +\.T/GjǢl:oe)9EZcxb2T/<3jA. +fJ37uB
2h +쬻0$e"pPDLh>]Y;\I0&u|a"?
:Gü]8+^çgDSSV92aVkNW&ߟ̈M +IB@X1C,zA*\+W_~/6gb\]I8!'KBy(7 2qX(EϺg]B +9J x^*-N-<xC>HZږK
Y^UΠbW>lA re1i9˾cQ +3QA+?I.-ռRO&UAZ_T;wUmXƑc +魷$z:JvD + 1~UOm;P@̍~Jw_赬C6wb*Y$"_tzP)âta(/ӗ +Y!\H>x~n$cü]:qOEaV57l9mmVfAG0Ǎ39zE"UkDnEXT{\H,>ԡz|KFa@pd$EfD14YBmzD9"7D@̐A}]<.;iQ<&t2Q[VTD2%(n#&}DIp"|
"3٧3/G3Rn1"yb4U1b6`or.LMrm5ݔ7}!\&o]T/B [5~BYbs3+eUq
F`'ذQOhU@\/b/p +FdYx%b:~
.v}EY:8~Keơ$ds3kJڣEysc]P_٭c%ݔf8 +9X- +O{wݷO~;7Ow}sLg ѧޙh.MCFoy=wJ_>U/<.^ٻ{l(Cl}ErqbpFÊPε]>[2[K@/ֹ)۞/C?JzL.CPUa(g0Y_<TzL3J&@HN$ֹSxPU +xeȳՌ}VkC Ōq{ MHiaX,<zf3vkos{W^WWڽvV_y{Mov93_<Ta_W{G`IE8bsG7&?\H,BG"^a6U]gFXbXbfj*wXޠ_@RVhv9:]E
Ԧ"~,-fD芆UYtXܔqE_q0SY>+lL"&)+pmNmsRZXC<\(aу6Qß=N` 5`<`lPD֎Z-@B&Y3zcح5ںb:k6+J~lQ"uU] vOhf~ +8upW +%lckb(hcWU xmw9,p]GE&g8iq"Leqgh[ۋSX3i_I^cv%CH6.'.ZCj1@ +=z uL.H"5A2NeAk.3!9<s'N4Vk3b?@tt`Ҽ- +ۑ|r3`"!iN#L0e`狱5Fϯ<Ї7kS>IN`%oK$Fg5GQ +c<8WM_[>Djgղ&GBU"^4(D!M\
"bGI Ů0&9Io0n}ZSyю#e#>N՛q>:{eU["G0m+ E +$ rF8/R)슊dWC/$υ*\Wp7 ӅL5ϻsݜ:\:L,3xS+T[@\nrVzZD~e2DӋ4w +J,,#MU &y +SK[yVA9H愷h*]c[U־2I҆(e,Z +aBxQ9P !u)SPЄƃW8 +CBsmly!BH EK=b0jʹLQhW7i ZG6+FfGʡI
ܝ~ꕕ,BHE
tĄ^AF2&2ȼ4Uw-UmM&5HR)Bnv&2f7Jb>Rj+ȍZZ.Xg~^tt5RyJz +5/RdPLJ{v)$`ꬆf"DՂ@Eٟ +QS,f +2ЏP:R%EpWE%2O"/iEaXYE,f65^SC5':]#ٮMUdj
HT(mL҉2"ܘ.Wւkٶk%j}\9lQM@2S2rցLZw9A2,)]g +(xTBX8Or`[W`VBH! O3Hq ALTrhKV<Y,
i3YR6.l
h&D3pB&fSFpa#|(DyDPt(ʘ>ʔ߄oL"[\2}<r٘#,̈́ˬj-sָ\ɡ&!ޚt_0#.K4nN3t w'W82T)SpW]Ի1KոގdCIVzZ2>TUNl+ϲ5_R'DκX<9V*\BX\!KE7Ҡ&XNqVwxZf$/$5,l"!A ǫTW&&y.J5!q.X`eM0s`)2XgJm`օ#JCiD ,䅑*e$֫ʿ-4z^]xАz]s5+n^(`g^OHYTz0W3+D2BAu
^ܘS3wVY;3u5W6T +vb'F LMHG*Lb.!D>N'sCsEίI" }LAյkg?ko?ߺy{o{ܸ{{7z킝I~[ߺGۯo-'X*[~_;ꩬG֏Sx'x]<Gʋ}XRwAVzf/h "& +c>?fm2)7qg͢@0m_}[^o?OayBmx
r\Vp~<7远I:`U =C2""e,4/}p&py;/|{6Vd?__.8MMZgwmHeP<я/nX9ӿat+q{kڌZ9'?v|Ly^;2KRr:e3L-siUo՚q{s[O?>яpy[&i?حٳMN~_;Su>aڪCsU+Y~߹ߠ@lQ)ltPtov2~*r7n=Uz5{@|[m}G|p?G^~ޭOveOq* +߾qf_o⫽\0zn^+J4Ğ,o)syŶV]b&g1Y'ye9_~TJ7tO#1gHwr)+_}=fhqeƯhnV-:/h3/|@"_|}w߽f}ֻܾCc Cئlg[A}7&? +ކB)|-uWPr/W~[ăa6}CZ3R\>-$*H3n>4d?ht1wWa2#?q^TsgKUViY-m\Wæ_5ltR {b#7^POC}綳~1)=G5峭~D{ɗj}7==G٣O~Wuۛzo9xn<gNʊo}{#aT? +~.Li0<K{o}wY9[nDjF[-}>ކp6{|oWS!ByAuջ'{+:>vݫG;7n omH0wn\
o?#s-hwQsH/X?u5S.~:Y!'/gD,|j^`3v'~ȟc
(\U_yG?+7g%B9}yո +r{D"Goeo}qBܾu]#ϖ>Kq{=4 +o7_JψY}9aoyZScvϓ0 uEZܿθ݅W!cpħ[_zND٣?o$II7?._߽wγSIŶH?hzPgc βݹ~[VՍ7/ofq{?v!w֏nOǥћ߆x/վ*oʍ 7{ՃoiڽWDNQ[w߸wˀi}ߴ?97~Οg>dI`<SV"}(0B <W/>R(>[)3B/%|\jEG^-_/(~Q^E +cD
9lk/맃,?8KA,A8If/x]sKCh2I-f,:%7^^tp8F
+I
RVz)h ?>Ź/Ip
xՕW5\+lsyO쓏,?;Cv*V
X,},L>gv^Wf'NSyfӤ/7$꓿+7yNZ~ܦ8yt]_R\5G I_hrB~Nk@c2C.[.hm*9O'N-;g_^^JPD#/-7 -EI+_ +- +*'Np^yRG4|ULqjvka\[torߺb\ɞN_i"1889؎O>M1?JLl\>"-Y,iǐ_}9>CȚrN9PK9"fNdI|m}ow. +9ϯw,aAؗBa%bXI$ + H$iNBɃ> +z(9# 7G2 =BnIZpXW>"H*@a +h*i1/Ǔnx_K'csc8ED(*H`n1 gRE/!G"h` +hCu>n*f':og&t̵ߢCX_ +-?M码٬ƽCY֮1VCFwA1^wo]wk0ύmf-_<Pd8E&G1x> +*A;@ﶺ"ֹ+>z=^$Ͷx4} }owuU )j׆?ۜM77Sm|:T}Ė ǃb[
L)*]|/r; + WzGS|/;"&7?_WZcASGx+]b}"96`J(؆LV?xqye[f dKϞ+tmw4</-/2L2\nk<;k9hwilU+oS<p^g 3c*MPXN +}P~_izX*|I@Nq\x`}s:ڊzP%og!F3ŶhEd<4#>V7]~ JXU
6TebҊGh*XßnnӝQ_fQPDč;/wZ8:U`$R+A71wIqCeyF8*p{4 +y!0t<Q?.Z̳xB.E`=Mz;r<dl$eyQnWmw5j0\uK|!`@IՐuP E0PG2K 5'36tDCUɎ^Auy}bKژn]J@ov@Ynn9I|D+>N/uLq ҟY&?+K'Y|7\-a:OdfSQe]ȓuYHpKrga1콊x̒EmzZox+I%zk:R$ɔS+0 y,ZV@ZB +>7)f\R?_W{>e\$w;ЗW*ǠNnKP3@;z<1zJM,U%r4{ͬ/;5 JMn
6}N)櫁xʄ@-߁X~.>e-h*c #:3426C'r4~1u9K*[FN +S6.um6h6q
03Pj[eԞbXx&g-PЈ*`KQXjZoR_+^'uWj
6XQzCV:_XiIw|]M֒h7TC:2\U4vm_[ +*fꍡ +;Eņ@G5M>k1<)X]`ը[
701!O/~t74!4[{rRNSĂrs+4dM:T +wb_XbW>ZmQ5v_%j(m[K4b.L#6A/us>DwF4}7,;?O0Ahx3`F +AN)&g߫XCEgԝSI\^o䢏&19_l8-
Wþ҅zå'䒸8HY;;[s +ï8[5Zyg+zo+@=C[K:ٷHjE: +m='#{Vv6a̾&kTk95] +nw݀+bFrm5;5k[6-Sw#s#.x +ta[NQrκ(oت_sf9*~
*o%SG/>_0-ov.'[r$ +с÷?pɧoNDGſ% + +bt)"iHgr .m-v,冱aŵol
jb|'].hWaU$?[qu,W|#>6/S@|'=Yh%\XvV4<IkfO:iKf zHit?63)ț)'_:NGdJ9CU9ӉFwh{?-s]yLMpk<~w?$]j|\_B`^?< +m5hEF_t~J1jR9
Tvtz _>h6xq/.. +l5o^C<A=>ZӞ|w}cd'}7eQ1i/
S-O%yEڞS*MZs| +^15Dk~CU#E*fJmJ
,-oOc`"<6V3ֿ,?%Gn|4VuD<3&]֙Vfd3kU|'\p^:ultŲ'v}Uʔ 3BqaI^~7ກ?KSex +drT +uioF7Oè |ze-WĞnkh ^&3vc +Oz383Tr0ڝ&ےe7M9O+ + +x0C:I?}6%W;oXӠ:m4No48|6$GN-쉼`N|؛ 8rbKL*7J4!\) +w^nybL|BH躋Ov`1ޅl;>n"BٔZ7>PZ[KW~0?bjf fEE놠0 7-^<XoL@4.Q[:K]X4N/߷Xm|)~z@wN^V3>"
&Żߐ|;~ԗ1tعJƖ1務=zX\lO`]ɣ4N'ϔ=lvApegਚ&]3:0`Ak1NQc~$]$oXqtsⳇWdNmwL wi +DCO_lo
5W(.GaGӗE1}Έza~Ц:9\>3h#JՊ~Py+^KyT4J(64 +c)>S]9X34n?pO7n70[^[|LѯPx + Xf2os+л:mb{c:Y*VWKș(?¢d
3IJ_5&bhʣL{R]ehK{-(SH)MXΦ2j!NYk0Hs~ +Ks +u- +F9@pzS]JurDBѩl7t
T 4ajȬ*p*wϕ6m8g8뎚, N]iMM2oFT Ǧ2RS3][3nYwe@sʶЙvC^2a^{W0[}M430.%OT/D-{@9sA#mk(/G&KU Q߶\i +Q3EM̭3jvh97C6I㓤3=ˀ\0{*antf^w5 &3ydEHk|ưM$ulw|L!mB)g^OvsVU72Vr:/ 3,[6@wahW/
܈oġp ioU`y\L>]qIC3s2<sw/.}qz=@2-Ws +D㰃exp=`2rN*Y!d/m,qVbށ +tnɘg5`FCPxA4 :`Vt>}9K@=%V,lg]|B61\7^4҉zTvѿmۏW;TLmO<[5[HxV V
fc,B+Ex^)yz_`HdRI;˕3>?͎wޥ͘!llK+ +za¤u +=15. +XE:W^:U9p4͛˶$b)ֶ﹤5kXÆYBX!I[1GBVu+`~.oVf
"M؊DH|^+=%Wj,BNns]k5z,_?>kl4hj=כ +Ѱ5^ɲV"Ì!)L#q5q?VVRumt8 +7 VפZa^'i褟#D!@6NtJfCEbc
&Uyw;kZ-zq!C +X_hZn7B/@OFqC kxl#XAp+`hR\gM[}n=~6p["Ihj+`}%-`ey
H{YoEEmsc
HKؾx"/\ܒG$h`g+K5l֒=ؤ V +]Kǚ_,ͭ]"kN\vz&ˊ2JkW.^C Rxv>1Xo;'E/j#+<BJm*CX=d
hnV);hK3I̽+?sNŧ^L&נdZ)NcBfy*tW~Z4LGO.җ.O`PG*mAxP|}qP4wee=}!(4%eRբ)T~bW~ښX-t#LQ{:PSU#yz+ҴgF4+t dZ.:O
&{=O/ FT("9^g.%>8558gP촷-<doj OI0_Mګ`ڂORԽ1'ҍ@ +X}F䰲nԫXXK2V)b~2S|aJW/1°L&#FaX=Vƍ~@`
W^.E7G2\)`mvTf.}rX97 +rE
֊ +vdO%|?9Y+vr֓ t4%lvм]fD ! f |K m<0Xđr`N[Ȁ\aazb}t|J +fW' +}+Rt,7֙hᜈ]
)uO +1jm!mϴR&w8rȩ)$dv/JڙTjˆb>
/TgI{RO2r.q<|Wsqfna4@s8Lf18ȓ:)<G,9vQ,g
o1P"+/6MYsrOwfa2MpSJ bud-F0[s*E/PeSɮyY4 x{,y<%qqЭ%%m `,ܞ>Bi.:(`ub3.<Bc8Bк sCm
fĻY +;XHD4]x~*YJ'kass\nsX,/^2dTl W>Ylծ{pEG *X,/B +)3)-@շ~"~,[$TU}PZH
un6KF[ptdP ;vإB +\#ҐK7 +%t!4}Wvq.X8LAw6 -_ +=9@GgU (;nF6 fXZy+A;.-ՏìV%/'p +@4cqFNя29z4v"Ze$Q50sDt/j
Yv: +G8:Չg%Q퉧f0UNMvy6 +ʱdb-'l-2f1<dnO>Q&^H7I-@sr>Z>4||L khNRgWc||gt|XZ><O9r>Y|-or[Χ^ܞO}2sQӔvf9$Zf)ĵ3Ό>AQ$RO +4,/ +ƭ">#֍nk&}:S/tI,v4{;J|PL +(պ47480`zI:Vݡ8C4Cu/6ʎ^^ +S'4qmwǧ\3BL]+3FsMսU*HR_
K1.
@8H'8|tTj̱j +5ruo [tȉS*9*'A(L9\jeMKH2XVM(hh5ձI0fh-s$$a#0t;hx&%݉7k2}f&=9BG!fA?<TwrpPOȪ +u\19.h֨;pCo"kv#<`)M?Ot=p>*qT,Ll۔S=ɎйbA_Jd@)) ŲTa6W:J똅IheRRHqͧ Ur#|O|_n4*+z'vUVk*9r[{EoD}9esћi7 +Uw|)yTQ!EyHQrEy4} ɻBL|hCOz @ 6`,K#<)͓a)HIkquT]K-$!Uu!I*=Yf灹f}dp㱝28cuc +Z r +X}ʌj+5Uִ2Q+5CBւYj]䰞b?m9k9;`]{;2_TA>VªטDܨ7mQ:{w[]=zJz^wdt)wKl Sq(rqOs[F'Ew^]R&F
)Φ݅;f=*:O4BtW^k䰲]v~Z +ZI=LU!y¾b6I}rU};6}rQDusDa0[ d}ra|$O['CL?d>LȓHwqOP'W'rda\Hll\0儅}r& >Qa\U\ꑅ}rʉ,S̳9maaOnS.u}}V +&a}JD;qa1DۣO;Vz_'WuW}O>z> +\#v7V.쓫7ɑc'~|a\UF}rRk +*hj{.V nbn34$n5}^8-~J&ljo49tʼOŊ)
|^nI[(HvTWOX얖UTU%lHU_O\]e]Z +13)ߞsOCذ98rQPO;B;{,+ռt!Ol(^J?Lبӝ4p]hո`mݯ WG2ǕytΪK'QӧᨏKwlq;T% +#Pܚ+-&hC62(0Lý'?\wzWy8ͽglрKt5٫ν;1)w^{|;,8H,?.j'u
}ڿWվSRz/^/(Nա[)¥ ZO-EP_FDjw=ߎݽ(#2
tR,ZSLݟqGi9 +Y|'=l>u=XCU[tYTt +#Fq[(!cϺۦ0EgޫUM&=;>ѾtT3Hwklz/ĶWU.<2LtI*W8Q-Xg;تu5sykMKkU4ʰjpt5O%%܍UD7 N4.%.߲U15j:7SUT6_.]Mg]V&J1Z\V.D$q߃VrH")U +32LfJ_+ȩTQEqzZ2%-d[*]TXt+[#;M=%]~j=^jc͙$c`ju.O=ہvơ\;F^<t\m;8s[EQ/?TdԟZvf4ChD;JfbK\Փ3_(wK3^sQ/$n>&Dnu +b%^wt1Mɬ;Mdo }rnqյJݰNonV\F0>-F-
.+]M\*ش`ri4#Ӝ|:UETy!7=R+q؛%q!PN](+I3"- +K>]FݚW)銧IJywϻK6ABzйưo7+ۊ@4}7#a<7RS.>MAi7)qvH^W>d`mP^Ii>IsK*R65Q*#kq}s4vJ~ՠ5E4j=:֑^|4+#PQZT1F1.Ik61Z'G$GKePQoIzD<}@IT)#**
bD;$QJR% r&[饻$Q=n^qדf{;c.+2Jbe^Vf
ˏ/+eoBaC7zYRė(³}<BquKVLw4*n;<;թVζӺpSpwpm\xxP&vۡJrCHgjZsJgˋmwjCG]x=PoAىn@PoP}pQ.<+ +G_xhWLr +Ui(nܬ݇H/=D:V+Kd[!I'^Ds'ꟽhx\/^h=L :9xUpۺZ;ͫu[jڼ;@[t(E|fCyX2>>";mν +\}.#gk/>Ӱ +S۞t\mӖl.kc}%#&xot2~S_ݬf'SY<\OAYg`&\.ſ涧أW2w$Жt*NY:n`XvA8m=`"l2qH6H2̍M%kq&t/7܃ٿ_^Vt<xV!Wmݰ%ܥ +AyBy9r@Zr0I
+endstream
endobj
44 0 obj
<</Length 4181>>stream
+GZ27`h$/5J}:SW4ҐӸwMރRH럃u7A +9+B2kS`zO@6 Ndu
7>a3xm,Ϸ;Y6x_!>
S! Y~ٴ&3dtIӀQ#|;n6k(ߊWA=O3v/O
b +Lb5{0
Ds-U@V2u/rK$!:^d֙&! +_ϐqYX-B^^HnӆjaXS xFosWtttI3S\h[j`I_LTugpPGG @c +8:nPC*:''BCrAOlt>ZD`iAD8CW̯Utȶ\umSaҁڒ8E%f`fzN +IƟ|yn`>%-<ƙr↽<1Ry; fAgOI{eu-m|2myLʄ%Ȇ= 3vAifY"f5"h-8v焂ۃ8
xM/XQ +x$\\i,6u&8&NZ" DH|38zDSO$CL#Ch_w1]^F|xA !`{:${ + +KLzKCe#K +(f}V(N"nWYz㪇1q*7y%N,Ie֒t~/Σآq Eja]2ſfr=| %.#T䭽HnB<CRS1UN:ԁOfs;s!&lzlqeMGʆw8ڽ.3d2[X/gY{`xeҐn'@1Shn eb?$(= +-zh~:2
$q`ds>XV$nuXQ\0C!(YCY\vVW~<Zsk~ˠFX^ϰ+q{fa Ddp#llx7aŪqyP{S5R`cmG}uXgϳqIOnn<tbH.p#œD3 +adٚꈷ +xLH|bj-}(iqDhu~{l&5N\t%mg6xM<0dC6hDfhD~g+FGs$If' +TnKۉGy$8^-~bA9 +~}>?~&I(M@S8R.g +<AwN"8 `t}:N)|hD@AwH$;v<I\A/M2?$q7~c0 i,d1V *̣_"|mu%6cgYp6b7' +//u +endstream
endobj
5 0 obj
<</Intent 15 0 R/Name(Layer)/Type/OCG/Usage 16 0 R>>
endobj
15 0 obj
[/View/Design]
endobj
16 0 obj
<</CreatorInfo<</Creator(Adobe Illustrator 16.0)/Subtype/Artwork>>>>
endobj
27 0 obj
[26 0 R]
endobj
45 0 obj
<</CreationDate(D:20160711024325+09'00')/Creator(Adobe Illustrator CS6 \(Windows\))/ModDate(D:20160711024345+09'00')/Producer(Adobe PDF library 10.01)/Title(icon)>>
endobj
xref
+0 46
+0000000004 65535 f
+0000000016 00000 n
+0000000159 00000 n
+0000043683 00000 n
+0000000000 00000 f
+0000261414 00000 n
+0000000000 00000 f
+0000043734 00000 n
+0000000000 00000 f
+0000000000 00000 f
+0000000000 00000 f
+0000000000 00000 f
+0000000000 00000 f
+0000000000 00000 f
+0000000000 00000 f
+0000261482 00000 n
+0000261513 00000 n
+0000000000 00000 f
+0000000000 00000 f
+0000000000 00000 f
+0000000000 00000 f
+0000000000 00000 f
+0000000000 00000 f
+0000000000 00000 f
+0000000000 00000 f
+0000000000 00000 f
+0000045251 00000 n
+0000261598 00000 n
+0000044087 00000 n
+0000045939 00000 n
+0000045549 00000 n
+0000045436 00000 n
+0000044399 00000 n
+0000044689 00000 n
+0000044737 00000 n
+0000045320 00000 n
+0000045351 00000 n
+0000045584 00000 n
+0000046013 00000 n
+0000046253 00000 n
+0000047324 00000 n
+0000060414 00000 n
+0000126003 00000 n
+0000191592 00000 n
+0000257181 00000 n
+0000261623 00000 n
+trailer
+<</Size 46/Root 1 0 R/Info 45 0 R/ID[<EAECEEA5FB8FE440A53408F062949E4C><C7F311C66FBA5D40893AF77A9162329A>]>>
+startxref
+261804
+%%EOF
diff --git a/resources/icon.png b/resources/icon.png Binary files differnew file mode 100644 index 0000000000..8e0077b4bf --- /dev/null +++ b/resources/icon.png diff --git a/resources/icon.svg b/resources/icon.svg new file mode 100644 index 0000000000..f661393ba0 --- /dev/null +++ b/resources/icon.svg @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ width="256px" height="256px" viewBox="0 0 256 256" enable-background="new 0 0 256 256" xml:space="preserve">
+<path fill="#EC6B43" d="M128,32c-44.183,0-80,35.817-80,80c0,31.234,16,56,44.002,71.462C111.037,193.973,112,224,128,224
+ s16.964-30.025,36-40.538C192,168,208,143.233,208,112C208,67.817,172.183,32,128,32z M128,132c-11.046,0-20-8.954-20-20
+ s8.954-20,20-20s20,8.954,20,20S139.046,132,128,132z"/>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>
diff --git a/resources/logo.svg b/resources/logo.svg new file mode 100644 index 0000000000..474e96691d --- /dev/null +++ b/resources/logo.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ width="1024px" height="1024px" viewBox="0 0 1024 1024" enable-background="new 0 0 1024 1024" xml:space="preserve">
+<polyline fill="none" stroke="#000" stroke-width="34" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" points="
+ 896.5,608.5 800.5,416.5 704.5,608.5 608.5,416.5 512.5,608.5 416.5,416.5 320.5,608.5 224.5,416.5 128.5,608.5 "/>
+</svg>
diff --git a/src/api/api-handler.ts b/src/api/api-handler.ts new file mode 100644 index 0000000000..c0714ad69a --- /dev/null +++ b/src/api/api-handler.ts @@ -0,0 +1,55 @@ +import * as express from 'express'; + +import { IEndpoint } from './endpoints'; +import authenticate from './authenticate'; +import { IAuthContext } from './authenticate'; +import _reply from './reply'; +import limitter from './limitter'; + +export default async (endpoint: IEndpoint, req: express.Request, res: express.Response) => { + const reply = _reply.bind(null, res); + let ctx: IAuthContext; + + // Authetication + try { + ctx = await authenticate(req); + } catch (e) { + return reply(403, 'AUTHENTICATION_FAILED'); + } + + if (endpoint.secure && !ctx.isSecure) { + return reply(403, 'ACCESS_DENIED'); + } + + if (endpoint.shouldBeSignin && ctx.user == null) { + return reply(401, 'PLZ_SIGNIN'); + } + + if (ctx.app && endpoint.kind) { + if (!ctx.app.permission.some((p: any) => p === endpoint.kind)) { + return reply(403, 'ACCESS_DENIED'); + } + } + + if (endpoint.shouldBeSignin) { + try { + await limitter(endpoint, ctx); // Rate limit + } catch (e) { + return reply(429); + } + } + + let exec = require(`${__dirname}/endpoints/${endpoint.name}`); + + if (endpoint.withFile) { + exec = exec.bind(null, req.file); + } + + // API invoking + try { + const res = await exec(req.body, ctx.user, ctx.app, ctx.isSecure); + reply(res); + } catch (e) { + reply(400, e); + } +}; diff --git a/src/api/authenticate.ts b/src/api/authenticate.ts new file mode 100644 index 0000000000..5798adb83d --- /dev/null +++ b/src/api/authenticate.ts @@ -0,0 +1,61 @@ +import * as express from 'express'; +import App from './models/app'; +import User from './models/user'; +import Userkey from './models/userkey'; + +export interface IAuthContext { + /** + * App which requested + */ + app: any; + + /** + * Authenticated user + */ + user: any; + + /** + * Weather if the request is via the (Misskey Web Client or user direct) or not + */ + isSecure: boolean; +} + +export default (req: express.Request) => + new Promise<IAuthContext>(async (resolve, reject) => { + const token = req.body['i']; + if (token) { + const user = await User + .findOne({ token: token }); + + if (user === null) { + return reject('user not found'); + } + + return resolve({ + app: null, + user: user, + isSecure: true + }); + } + + const userkey = req.headers['userkey'] || req.body['_userkey']; + if (userkey) { + const userkeyDoc = await Userkey.findOne({ + key: userkey + }); + + if (userkeyDoc === null) { + return reject('invalid userkey'); + } + + const app = await App + .findOne({ _id: userkeyDoc.app_id }); + + const user = await User + .findOne({ _id: userkeyDoc.user_id }); + + return resolve({ app: app, user: user, isSecure: false }); + } + + return resolve({ app: null, user: null, isSecure: false }); +}); diff --git a/src/api/common/add-file-to-drive.ts b/src/api/common/add-file-to-drive.ts new file mode 100644 index 0000000000..0bd9f34825 --- /dev/null +++ b/src/api/common/add-file-to-drive.ts @@ -0,0 +1,149 @@ +import * as mongodb from 'mongodb'; +import * as crypto from 'crypto'; +import * as gm from 'gm'; +const fileType = require('file-type'); +const prominence = require('prominence'); +import DriveFile from '../models/drive-file'; +import DriveFolder from '../models/drive-folder'; +import serialize from '../serializers/drive-file'; +import event from '../event'; + +/** + * Add file to drive + * + * @param user User who wish to add file + * @param fileName File name + * @param data Contents + * @param comment Comment + * @param type File type + * @param folderId Folder ID + * @param force If set to true, forcibly upload the file even if there is a file with the same hash. + * @return Object that represents added file + */ +export default ( + user: any, + data: Buffer, + name: string = null, + comment: string = null, + folderId: mongodb.ObjectID = null, + force: boolean = false +) => new Promise<any>(async (resolve, reject) => { + // File size + const size = data.byteLength; + + // File type + let mime = 'application/octet-stream'; + const type = fileType(data); + if (type !== null) { + mime = type.mime; + + if (name === null) { + name = `untitled.${type.ext}`; + } + } else { + if (name === null) { + name = 'untitled'; + } + } + + // Generate hash + const hash = crypto + .createHash('sha256') + .update(data) + .digest('hex') as string; + + if (!force) { + // Check if there is a file with the same hash and same data size (to be safe) + const much = await DriveFile.findOne({ + user_id: user._id, + hash: hash, + datasize: size + }); + + if (much !== null) { + resolve(much); + return; + } + } + + // Fetch all files to calculate drive usage + const files = await DriveFile + .find({ user_id: user._id }, { + datasize: true, + _id: false + }) + .toArray(); + + // Calculate drive usage (in byte) + const usage = files.map(file => file.datasize).reduce((x, y) => x + y, 0); + + // If usage limit exceeded + if (usage + size > user.drive_capacity) { + return reject('no-free-space'); + } + + // If the folder is specified + let folder: any = null; + if (folderId !== null) { + folder = await DriveFolder + .findOne({ + _id: folderId, + user_id: user._id + }); + + if (folder === null) { + return reject('folder-not-found'); + } + } + + let properties: any = null; + + // If the file is an image + if (/^image\/.*$/.test(mime)) { + // Calculate width and height to save in property + const g = gm(data, name); + const size = await prominence(g).size(); + properties = { + width: size.width, + height: size.height + }; + } + + // Create DriveFile document + const res = await DriveFile.insert({ + created_at: new Date(), + user_id: user._id, + folder_id: folder !== null ? folder._id : null, + data: data, + datasize: size, + type: mime, + name: name, + comment: comment, + hash: hash, + properties: properties + }); + + const file = res.ops[0]; + + resolve(file); + + // Serialize + const fileObj = await serialize(file); + + // Publish drive_file_created event + event(user._id, 'drive_file_created', fileObj); + + // Register to search database + if (config.elasticsearch.enable) { + const es = require('../../db/elasticsearch'); + es.index({ + index: 'misskey', + type: 'drive_file', + id: file._id.toString(), + body: { + name: file.name, + user_id: user._id.toString() + } + }); + } +}); diff --git a/src/api/common/get-friends.ts b/src/api/common/get-friends.ts new file mode 100644 index 0000000000..5d50bcdb13 --- /dev/null +++ b/src/api/common/get-friends.ts @@ -0,0 +1,25 @@ +import * as mongodb from 'mongodb'; +import Following from '../models/following'; + +export default async (me: mongodb.ObjectID, includeMe: boolean = true) => { + // Fetch relation to other users who the I follows + // SELECT followee + const myfollowing = await Following + .find({ + follower_id: me, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + followee_id: true + }) + .toArray(); + + // ID list of other users who the I follows + const myfollowingIds = myfollowing.map(follow => follow.followee_id); + + if (includeMe) { + myfollowingIds.push(me); + } + + return myfollowingIds; +}; diff --git a/src/api/common/notify.ts b/src/api/common/notify.ts new file mode 100644 index 0000000000..c4c94ee704 --- /dev/null +++ b/src/api/common/notify.ts @@ -0,0 +1,32 @@ +import * as mongo from 'mongodb'; +import Notification from '../models/notification'; +import event from '../event'; +import serialize from '../serializers/notification'; + +export default ( + notifiee: mongo.ObjectID, + notifier: mongo.ObjectID, + type: string, + content: any +) => new Promise<any>(async (resolve, reject) => { + if (notifiee.equals(notifier)) { + return resolve(); + } + + // Create notification + const res = await Notification.insert(Object.assign({ + created_at: new Date(), + notifiee_id: notifiee, + notifier_id: notifier, + type: type, + is_read: false + }, content)); + + const notification = res.ops[0]; + + resolve(notification); + + // Publish notification event + event(notifiee, 'notification', + await serialize(notification)); +}); diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts new file mode 100644 index 0000000000..ad45f42bc7 --- /dev/null +++ b/src/api/endpoints.ts @@ -0,0 +1,101 @@ +const second = 1000; +const minute = 60 * second; +const hour = 60 * minute; +const day = 24 * hour; + +export interface IEndpoint { + name: string; + shouldBeSignin: boolean; + limitKey?: string; + limitDuration?: number; + limitMax?: number; + minInterval?: number; + withFile?: boolean; + secure?: boolean; + kind?: string; +} + +export default [ + { name: 'meta', shouldBeSignin: false }, + + { name: 'username/available', shouldBeSignin: false }, + + { name: 'my/apps', shouldBeSignin: true }, + + { name: 'app/create', shouldBeSignin: true, limitDuration: day, limitMax: 3 }, + { name: 'app/show', shouldBeSignin: false }, + { name: 'app/name_id/available', shouldBeSignin: false }, + + { name: 'auth/session/generate', shouldBeSignin: false }, + { name: 'auth/session/show', shouldBeSignin: false }, + { name: 'auth/session/userkey', shouldBeSignin: false }, + { name: 'auth/accept', shouldBeSignin: true, secure: true }, + { name: 'auth/deny', shouldBeSignin: true, secure: true }, + + { name: 'aggregation/users/post', shouldBeSignin: false }, + { name: 'aggregation/users/like', shouldBeSignin: false }, + { name: 'aggregation/users/followers', shouldBeSignin: false }, + { name: 'aggregation/users/following', shouldBeSignin: false }, + { name: 'aggregation/posts/like', shouldBeSignin: false }, + { name: 'aggregation/posts/likes', shouldBeSignin: false }, + { name: 'aggregation/posts/repost', shouldBeSignin: false }, + { name: 'aggregation/posts/reply', shouldBeSignin: false }, + + { name: 'i', shouldBeSignin: true }, + { name: 'i/update', shouldBeSignin: true, limitDuration: day, limitMax: 50, kind: 'account-write' }, + { name: 'i/appdata/get', shouldBeSignin: true }, + { name: 'i/appdata/set', shouldBeSignin: true }, + { name: 'i/signin_history', shouldBeSignin: true, kind: 'account-read' }, + + { name: 'i/notifications', shouldBeSignin: true, kind: 'notification-read' }, + { name: 'notifications/delete', shouldBeSignin: true, kind: 'notification-write' }, + { name: 'notifications/delete_all', shouldBeSignin: true, kind: 'notification-write' }, + { name: 'notifications/mark_as_read', shouldBeSignin: true, kind: 'notification-write' }, + { name: 'notifications/mark_as_read_all', shouldBeSignin: true, kind: 'notification-write' }, + + { name: 'drive', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/stream', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/files', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/files/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, withFile: true, kind: 'drive-write' }, + { name: 'drive/files/show', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/files/find', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/files/delete', shouldBeSignin: true, kind: 'drive-write' }, + { name: 'drive/files/update', shouldBeSignin: true, kind: 'drive-write' }, + { name: 'drive/folders', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/folders/create', shouldBeSignin: true, limitDuration: hour, limitMax: 50, kind: 'drive-write' }, + { name: 'drive/folders/show', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/folders/find', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/folders/update', shouldBeSignin: true, kind: 'drive-write' }, + + { name: 'users', shouldBeSignin: false }, + { name: 'users/show', shouldBeSignin: false }, + { name: 'users/search', shouldBeSignin: false }, + { name: 'users/search_by_username', shouldBeSignin: false }, + { name: 'users/posts', shouldBeSignin: false }, + { name: 'users/following', shouldBeSignin: false }, + { name: 'users/followers', shouldBeSignin: false }, + { name: 'users/recommendation', shouldBeSignin: true, kind: 'account-read' }, + + { name: 'following/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'following-write' }, + { name: 'following/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'following-write' }, + + { name: 'posts/show', shouldBeSignin: false }, + { name: 'posts/replies', shouldBeSignin: false }, + { name: 'posts/context', shouldBeSignin: false }, + { name: 'posts/create', shouldBeSignin: true, limitDuration: hour, limitMax: 120, minInterval: 1 * second, kind: 'post-write' }, + { name: 'posts/reposts', shouldBeSignin: false }, + { name: 'posts/search', shouldBeSignin: false }, + { name: 'posts/timeline', shouldBeSignin: true, limitDuration: 10 * minute, limitMax: 100 }, + { name: 'posts/mentions', shouldBeSignin: true, limitDuration: 10 * minute, limitMax: 100 }, + { name: 'posts/likes', shouldBeSignin: true }, + { name: 'posts/likes/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'like-write' }, + { name: 'posts/likes/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'like-write' }, + { name: 'posts/favorites/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'favorite-write' }, + { name: 'posts/favorites/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'favorite-write' }, + + { name: 'messaging/history', shouldBeSignin: true, kind: 'messaging-read' }, + { name: 'messaging/unread', shouldBeSignin: true, kind: 'messaging-read' }, + { name: 'messaging/messages', shouldBeSignin: true, kind: 'messaging-read' }, + { name: 'messaging/messages/create', shouldBeSignin: true, kind: 'messaging-write' } + +] as IEndpoint[]; diff --git a/src/api/endpoints/aggregation/posts/like.js b/src/api/endpoints/aggregation/posts/like.js new file mode 100644 index 0000000000..b82c494ff1 --- /dev/null +++ b/src/api/endpoints/aggregation/posts/like.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../../models/post'; +import Like from '../../../models/like'; + +/** + * Aggregate like of a post + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + const datas = await Like + .aggregate([ + { $match: { post_id: post._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data) + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }) + }; + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/posts/likes.js b/src/api/endpoints/aggregation/posts/likes.js new file mode 100644 index 0000000000..0317245159 --- /dev/null +++ b/src/api/endpoints/aggregation/posts/likes.js @@ -0,0 +1,76 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../../models/post'; +import Like from '../../../models/like'; + +/** + * Aggregate likes of a post + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1)); + + const likes = await Like + .find({ + post_id: post._id, + $or: [ + { deleted_at: { $exists: false } }, + { deleted_at: { $gt: startTime } } + ] + }, { + _id: false, + post_id: false + }, { + sort: { created_at: -1 } + }) + .toArray(); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + day = new Date(day.setMilliseconds(999)); + day = new Date(day.setSeconds(59)); + day = new Date(day.setMinutes(59)); + day = new Date(day.setHours(23)); + //day = day.getTime(); + + const count = likes.filter(l => + l.created_at < day && (l.deleted_at == null || l.deleted_at > day) + ).length; + + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: count + }); + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/posts/reply.js b/src/api/endpoints/aggregation/posts/reply.js new file mode 100644 index 0000000000..e578bc6d7d --- /dev/null +++ b/src/api/endpoints/aggregation/posts/reply.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../../models/post'; + +/** + * Aggregate reply of a post + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + const datas = await Post + .aggregate([ + { $match: { reply_to: post._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data) + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }) + }; + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/posts/repost.js b/src/api/endpoints/aggregation/posts/repost.js new file mode 100644 index 0000000000..38d63442a8 --- /dev/null +++ b/src/api/endpoints/aggregation/posts/repost.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../../models/post'; + +/** + * Aggregate repost of a post + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + const datas = await Post + .aggregate([ + { $match: { repost_id: post._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data) + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }) + }; + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/users/followers.js b/src/api/endpoints/aggregation/users/followers.js new file mode 100644 index 0000000000..16dda09675 --- /dev/null +++ b/src/api/endpoints/aggregation/users/followers.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../../models/user'; +import Following from '../../../models/following'; + +/** + * Aggregate followers of a user + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1)); + + const following = await Following + .find({ + followee_id: user._id, + $or: [ + { deleted_at: { $exists: false } }, + { deleted_at: { $gt: startTime } } + ] + }, { + _id: false, + follower_id: false, + followee_id: false + }, { + sort: { created_at: -1 } + }) + .toArray(); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + day = new Date(day.setMilliseconds(999)); + day = new Date(day.setSeconds(59)); + day = new Date(day.setMinutes(59)); + day = new Date(day.setHours(23)); + // day = day.getTime(); + + const count = following.filter(f => + f.created_at < day && (f.deleted_at == null || f.deleted_at > day) + ).length; + + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: count + }); + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/users/following.js b/src/api/endpoints/aggregation/users/following.js new file mode 100644 index 0000000000..7b7448d715 --- /dev/null +++ b/src/api/endpoints/aggregation/users/following.js @@ -0,0 +1,76 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../../models/user'; +import Following from '../../../models/following'; + +/** + * Aggregate following of a user + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1)); + + const following = await Following + .find({ + follower_id: user._id, + $or: [ + { deleted_at: { $exists: false } }, + { deleted_at: { $gt: startTime } } + ] + }, { + _id: false, + follower_id: false, + followee_id: false + }, { + sort: { created_at: -1 } + }) + .toArray(); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + day = new Date(day.setMilliseconds(999)); + day = new Date(day.setSeconds(59)); + day = new Date(day.setMinutes(59)); + day = new Date(day.setHours(23)); + + const count = following.filter(f => + f.created_at < day && (f.deleted_at == null || f.deleted_at > day) + ).length; + + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: count + }); + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/users/like.js b/src/api/endpoints/aggregation/users/like.js new file mode 100644 index 0000000000..830f1f1bba --- /dev/null +++ b/src/api/endpoints/aggregation/users/like.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../../models/user'; +import Like from '../../../models/like'; + +/** + * Aggregate like of a user + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Like + .aggregate([ + { $match: { user_id: user._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data) + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }) + }; + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/users/post.js b/src/api/endpoints/aggregation/users/post.js new file mode 100644 index 0000000000..d75df30f5d --- /dev/null +++ b/src/api/endpoints/aggregation/users/post.js @@ -0,0 +1,113 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../../models/user'; +import Post from '../../../models/post'; + +/** + * Aggregate post of a user + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Post + .aggregate([ + { $match: { user_id: user._id } }, + { $project: { + repost_id: '$repost_id', + reply_to_id: '$reply_to_id', + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + }, + type: { + $cond: { + if: { $ne: ['$repost_id', null] }, + then: 'repost', + else: { + $cond: { + if: { $ne: ['$reply_to_id', null] }, + then: 'reply', + else: 'post' + } + } + } + }} + }, + { $group: { _id: { + date: '$date', + type: '$type' + }, count: { $sum: 1 } } }, + { $group: { + _id: '$_id.date', + data: { $addToSet: { + type: '$_id.type', + count: '$count' + }} + } } + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + + data.posts = (data.data.filter(x => x.type == 'post')[0] || { count: 0 }).count; + data.reposts = (data.data.filter(x => x.type == 'repost')[0] || { count: 0 }).count; + data.replies = (data.data.filter(x => x.type == 'reply')[0] || { count: 0 }).count; + + delete data.data; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data) + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + posts: 0, + reposts: 0, + replies: 0 + }) + }; + } + + res(graph); +}); diff --git a/src/api/endpoints/app/create.js b/src/api/endpoints/app/create.js new file mode 100644 index 0000000000..d83062c8e9 --- /dev/null +++ b/src/api/endpoints/app/create.js @@ -0,0 +1,75 @@ +'use strict'; + +/** + * Module dependencies + */ +import rndstr from 'rndstr'; +import App from '../../models/app'; +import serialize from '../../serializers/app'; + +/** + * Create an app + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = async (params, user) => + new Promise(async (res, rej) => +{ + // Get 'name_id' parameter + const nameId = params.name_id; + if (nameId == null || nameId == '') { + return rej('name_id is required'); + } + + // Validate name_id + if (!/^[a-zA-Z0-9\-]{3,30}$/.test(nameId)) { + return rej('invalid name_id'); + } + + // Get 'name' parameter + const name = params.name; + if (name == null || name == '') { + return rej('name is required'); + } + + // Get 'description' parameter + const description = params.description; + if (description == null || description == '') { + return rej('description is required'); + } + + // Get 'permission' parameter + const permission = params.permission; + if (permission == null || permission == '') { + return rej('permission is required'); + } + + // Get 'callback_url' parameter + let callback = params.callback_url; + if (callback === '') { + callback = null; + } + + // Generate secret + const secret = rndstr('a-zA-Z0-9', 32); + + // Create account + const inserted = await App.insert({ + created_at: new Date(), + user_id: user._id, + name: name, + name_id: nameId, + name_id_lower: nameId.toLowerCase(), + description: description, + permission: permission.split(','), + callback_url: callback, + secret: secret + }); + + const app = inserted.ops[0]; + + // Response + res(await serialize(app)); +}); diff --git a/src/api/endpoints/app/name_id/available.js b/src/api/endpoints/app/name_id/available.js new file mode 100644 index 0000000000..179925dce4 --- /dev/null +++ b/src/api/endpoints/app/name_id/available.js @@ -0,0 +1,40 @@ +'use strict'; + +/** + * Module dependencies + */ +import App from '../../../models/app'; + +/** + * Check available name_id of app + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = async (params) => + new Promise(async (res, rej) => +{ + // Get 'name_id' parameter + const nameId = params.name_id; + if (nameId == null || nameId == '') { + return rej('name_id is required'); + } + + // Validate name_id + if (!/^[a-zA-Z0-9\-]{3,30}$/.test(nameId)) { + return rej('invalid name_id'); + } + + // Get exist + const exist = await App + .count({ + name_id_lower: nameId.toLowerCase() + }, { + limit: 1 + }); + + // Reply + res({ + available: exist === 0 + }); +}); diff --git a/src/api/endpoints/app/show.js b/src/api/endpoints/app/show.js new file mode 100644 index 0000000000..8d12f9aeb1 --- /dev/null +++ b/src/api/endpoints/app/show.js @@ -0,0 +1,51 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import App from '../../models/app'; +import serialize from '../../serializers/app'; + +/** + * Show an app + * + * @param {Object} params + * @param {Object} user + * @param {Object} _ + * @param {Object} isSecure + * @return {Promise<object>} + */ +module.exports = (params, user, _, isSecure) => + new Promise(async (res, rej) => +{ + // Get 'app_id' parameter + let appId = params.app_id; + if (appId == null || appId == '') { + appId = null; + } + + // Get 'name_id' parameter + let nameId = params.name_id; + if (nameId == null || nameId == '') { + nameId = null; + } + + if (appId === null && nameId === null) { + return rej('app_id or name_id is required'); + } + + // Lookup app + const app = appId !== null + ? await App.findOne({ _id: new mongo.ObjectID(appId) }) + : await App.findOne({ name_id_lower: nameId.toLowerCase() }); + + if (app === null) { + return rej('app not found'); + } + + // Send response + res(await serialize(app, user, { + includeSecret: isSecure && app.user_id.equals(user._id) + })); +}); diff --git a/src/api/endpoints/auth/accept.js b/src/api/endpoints/auth/accept.js new file mode 100644 index 0000000000..7c45650c6b --- /dev/null +++ b/src/api/endpoints/auth/accept.js @@ -0,0 +1,64 @@ +'use strict'; + +/** + * Module dependencies + */ +import rndstr from 'rndstr'; +import AuthSess from '../../models/auth-session'; +import Userkey from '../../models/userkey'; + +/** + * Accept + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'token' parameter + const token = params.token; + if (token == null) { + return rej('token is required'); + } + + // Fetch token + const session = await AuthSess + .findOne({ token: token }); + + if (session === null) { + return rej('session not found'); + } + + // Generate userkey + const key = rndstr('a-zA-Z0-9', 32); + + // Fetch exist userkey + const exist = await Userkey.findOne({ + app_id: session.app_id, + user_id: user._id, + }); + + if (exist === null) { + // Insert userkey doc + await Userkey.insert({ + created_at: new Date(), + app_id: session.app_id, + user_id: user._id, + key: key + }); + } + + // Update session + await AuthSess.updateOne({ + _id: session._id + }, { + $set: { + user_id: user._id + } + }); + + // Response + res(); +}); diff --git a/src/api/endpoints/auth/session/generate.js b/src/api/endpoints/auth/session/generate.js new file mode 100644 index 0000000000..bb49cf090d --- /dev/null +++ b/src/api/endpoints/auth/session/generate.js @@ -0,0 +1,51 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as uuid from 'uuid'; +import App from '../../../models/app'; +import AuthSess from '../../../models/auth-session'; + +/** + * Generate a session + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'app_secret' parameter + const appSecret = params.app_secret; + if (appSecret == null) { + return rej('app_secret is required'); + } + + // Lookup app + const app = await App.findOne({ + secret: appSecret + }); + + if (app == null) { + return rej('app not found'); + } + + // Generate token + const token = uuid.v4(); + + // Create session token document + const inserted = await AuthSess.insert({ + created_at: new Date(), + app_id: app._id, + token: token + }); + + const doc = inserted.ops[0]; + + // Response + res({ + token: doc.token, + url: `${config.auth_url}/${doc.token}` + }); +}); diff --git a/src/api/endpoints/auth/session/show.js b/src/api/endpoints/auth/session/show.js new file mode 100644 index 0000000000..67160c6993 --- /dev/null +++ b/src/api/endpoints/auth/session/show.js @@ -0,0 +1,36 @@ +'use strict'; + +/** + * Module dependencies + */ +import AuthSess from '../../../models/auth-session'; +import serialize from '../../../serializers/auth-session'; + +/** + * Show a session + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'token' parameter + const token = params.token; + if (token == null) { + return rej('token is required'); + } + + // Lookup session + const session = await AuthSess.findOne({ + token: token + }); + + if (session == null) { + return rej('session not found'); + } + + // Response + res(await serialize(session, user)); +}); diff --git a/src/api/endpoints/auth/session/userkey.js b/src/api/endpoints/auth/session/userkey.js new file mode 100644 index 0000000000..2626e4ce39 --- /dev/null +++ b/src/api/endpoints/auth/session/userkey.js @@ -0,0 +1,74 @@ +'use strict'; + +/** + * Module dependencies + */ +import App from '../../../models/app'; +import AuthSess from '../../../models/auth-session'; +import Userkey from '../../../models/userkey'; +import serialize from '../../../serializers/user'; + +/** + * Generate a session + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'app_secret' parameter + const appSecret = params.app_secret; + if (appSecret == null) { + return rej('app_secret is required'); + } + + // Lookup app + const app = await App.findOne({ + secret: appSecret + }); + + if (app == null) { + return rej('app not found'); + } + + // Get 'token' parameter + const token = params.token; + if (token == null) { + return rej('token is required'); + } + + // Fetch token + const session = await AuthSess + .findOne({ + token: token, + app_id: app._id + }); + + if (session === null) { + return rej('session not found'); + } + + if (session.user_id == null) { + return rej('this session is not allowed yet'); + } + + // Lookup userkey + const userkey = await Userkey.findOne({ + app_id: app._id, + user_id: session.user_id + }); + + // Delete session + AuthSess.deleteOne({ + _id: session._id + }); + + // Response + res({ + userkey: userkey.key, + user: await serialize(session.user_id, null, { + detail: true + }) + }); +}); diff --git a/src/api/endpoints/drive.js b/src/api/endpoints/drive.js new file mode 100644 index 0000000000..4df4ac33fa --- /dev/null +++ b/src/api/endpoints/drive.js @@ -0,0 +1,33 @@ +'use strict'; + +/** + * Module dependencies + */ +import DriveFile from './models/drive-file'; + +/** + * Get drive information + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Fetch all files to calculate drive usage + const files = await DriveFile + .find({ user_id: user._id }, { + datasize: true, + _id: false + }) + .toArray(); + + // Calculate drive usage (in byte) + const usage = files.map(file => file.datasize).reduce((x, y) => x + y, 0); + + res({ + capacity: user.drive_capacity, + usage: usage + }); +}); diff --git a/src/api/endpoints/drive/files.js b/src/api/endpoints/drive/files.js new file mode 100644 index 0000000000..7e8ff59f2a --- /dev/null +++ b/src/api/endpoints/drive/files.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../../models/drive-file'; +import serialize from '../../serializers/drive-file'; + +/** + * Get drive files + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @return {Promise<object>} + */ +module.exports = (params, user, app) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Get 'folder_id' parameter + let folder = params.folder_id; + if (folder === undefined || folder === null || folder === 'null') { + folder = null; + } else { + folder = new mongo.ObjectID(folder); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = { + user_id: user._id, + folder_id: folder + }; + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const files = await DriveFile + .find(query, { + data: false + }, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(files.map(async file => + await serialize(file)))); +}); diff --git a/src/api/endpoints/drive/files/create.js b/src/api/endpoints/drive/files/create.js new file mode 100644 index 0000000000..5966499c59 --- /dev/null +++ b/src/api/endpoints/drive/files/create.js @@ -0,0 +1,59 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as fs from 'fs'; +import * as mongo from 'mongodb'; +import File from '../../../models/drive-file'; +import { validateFileName } from '../../../models/drive-file'; +import User from '../../../models/user'; +import serialize from '../../../serializers/drive-file'; +import create from '../../../common/add-file-to-drive'; + +/** + * Create a file + * + * @param {Object} file + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (file, params, user) => + new Promise(async (res, rej) => +{ + const buffer = fs.readFileSync(file.path); + fs.unlink(file.path); + + // Get 'name' parameter + let name = file.originalname; + if (name !== undefined && name !== null) { + name = name.trim(); + if (name.length === 0) { + name = null; + } else if (name === 'blob') { + name = null; + } else if (!validateFileName(name)) { + return rej('invalid name'); + } + } else { + name = null; + } + + // Get 'folder_id' parameter + let folder = params.folder_id; + if (folder === undefined || folder === null || folder === 'null') { + folder = null; + } else { + folder = new mongo.ObjectID(folder); + } + + // Create file + const driveFile = await create(user, buffer, name, null, folder); + + // Serialize + const fileObj = await serialize(driveFile); + + // Response + res(fileObj); +}); diff --git a/src/api/endpoints/drive/files/find.js b/src/api/endpoints/drive/files/find.js new file mode 100644 index 0000000000..e4e4c230d2 --- /dev/null +++ b/src/api/endpoints/drive/files/find.js @@ -0,0 +1,48 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../../../models/drive-file'; +import serialize from '../../../serializers/drive-file'; + +/** + * Find a file(s) + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'name' parameter + const name = params.name; + if (name === undefined || name === null) { + return rej('name is required'); + } + + // Get 'folder_id' parameter + let folder = params.folder_id; + if (folder === undefined || folder === null || folder === 'null') { + folder = null; + } else { + folder = new mongo.ObjectID(folder); + } + + // Issue query + const files = await DriveFile + .find({ + name: name, + user_id: user._id, + folder_id: folder + }, { + data: false + }) + .toArray(); + + // Serialize + res(await Promise.all(files.map(async file => + await serialize(file)))); +}); diff --git a/src/api/endpoints/drive/files/show.js b/src/api/endpoints/drive/files/show.js new file mode 100644 index 0000000000..79b07dace2 --- /dev/null +++ b/src/api/endpoints/drive/files/show.js @@ -0,0 +1,40 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../../../models/drive-file'; +import serialize from '../../../serializers/drive-file'; + +/** + * Show a file + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'file_id' parameter + const fileId = params.file_id; + if (fileId === undefined || fileId === null) { + return rej('file_id is required'); + } + + const file = await DriveFile + .findOne({ + _id: new mongo.ObjectID(fileId), + user_id: user._id + }, { + data: false + }); + + if (file === null) { + return rej('file-not-found'); + } + + // Serialize + res(await serialize(file)); +}); diff --git a/src/api/endpoints/drive/files/update.js b/src/api/endpoints/drive/files/update.js new file mode 100644 index 0000000000..bbcb10b42d --- /dev/null +++ b/src/api/endpoints/drive/files/update.js @@ -0,0 +1,89 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import DriveFile from '../../../models/drive-file'; +import { validateFileName } from '../../../models/drive-file'; +import serialize from '../../../serializers/drive-file'; +import event from '../../../event'; + +/** + * Update a file + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'file_id' parameter + const fileId = params.file_id; + if (fileId === undefined || fileId === null) { + return rej('file_id is required'); + } + + const file = await DriveFile + .findOne({ + _id: new mongo.ObjectID(fileId), + user_id: user._id + }, { + data: false + }); + + if (file === null) { + return rej('file-not-found'); + } + + // Get 'name' parameter + let name = params.name; + if (name) { + name = name.trim(); + if (validateFileName(name)) { + file.name = name; + } else { + return rej('invalid file name'); + } + } + + // Get 'folder_id' parameter + let folderId = params.folder_id; + if (folderId !== undefined && folderId !== 'null') { + folderId = new mongo.ObjectID(folderId); + } + + let folder = null; + if (folderId !== undefined && folderId !== null) { + if (folderId === 'null') { + file.folder_id = null; + } else { + folder = await DriveFolder + .findOne({ + _id: folderId, + user_id: user._id + }); + + if (folder === null) { + return reject('folder-not-found'); + } + + file.folder_id = folder._id; + } + } + + DriveFile.updateOne({ _id: file._id }, { + $set: file + }); + + // Serialize + const fileObj = await serialize(file); + + // Response + res(fileObj); + + // Publish drive_file_updated event + event(user._id, 'drive_file_updated', fileObj); +}); diff --git a/src/api/endpoints/drive/folders.js b/src/api/endpoints/drive/folders.js new file mode 100644 index 0000000000..f95a60036f --- /dev/null +++ b/src/api/endpoints/drive/folders.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../models/drive-folder'; +import serialize from '../../serializers/drive-folder'; + +/** + * Get drive folders + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @return {Promise<object>} + */ +module.exports = (params, user, app) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Get 'folder_id' parameter + let folder = params.folder_id; + if (folder === undefined || folder === null || folder === 'null') { + folder = null; + } else { + folder = new mongo.ObjectID(folder); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = { + user_id: user._id, + parent_id: folder + }; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const folders = await DriveFolder + .find(query, { + data: false + }, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(folders.map(async folder => + await serialize(folder)))); +}); diff --git a/src/api/endpoints/drive/folders/create.js b/src/api/endpoints/drive/folders/create.js new file mode 100644 index 0000000000..ba40d1763e --- /dev/null +++ b/src/api/endpoints/drive/folders/create.js @@ -0,0 +1,79 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import { isValidFolderName } from '../../../models/drive-folder'; +import serialize from '../../../serializers/drive-folder'; +import event from '../../../event'; + +/** + * Create drive folder + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'name' parameter + let name = params.name; + if (name !== undefined && name !== null) { + name = name.trim(); + if (name.length === 0) { + name = null; + } else if (!isValidFolderName(name)) { + return rej('invalid name'); + } + } else { + name = null; + } + + if (name == null) { + name = '無題のフォルダー'; + } + + // Get 'folder_id' parameter + let parentId = params.folder_id; + if (parentId === undefined || parentId === null) { + parentId = null; + } else { + parentId = new mongo.ObjectID(parentId); + } + + // If the parent folder is specified + let parent = null; + if (parentId !== null) { + parent = await DriveFolder + .findOne({ + _id: parentId, + user_id: user._id + }); + + if (parent === null) { + return reject('parent-not-found'); + } + } + + // Create folder + const inserted = await DriveFolder.insert({ + created_at: new Date(), + name: name, + parent_id: parent !== null ? parent._id : null, + user_id: user._id + }); + + const folder = inserted.ops[0]; + + // Serialize + const folderObj = await serialize(folder); + + // Response + res(folderObj); + + // Publish drive_folder_created event + event(user._id, 'drive_folder_created', folderObj); +}); diff --git a/src/api/endpoints/drive/folders/find.js b/src/api/endpoints/drive/folders/find.js new file mode 100644 index 0000000000..01805dc910 --- /dev/null +++ b/src/api/endpoints/drive/folders/find.js @@ -0,0 +1,46 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import serialize from '../../../serializers/drive-folder'; + +/** + * Find a folder(s) + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'name' parameter + const name = params.name; + if (name === undefined || name === null) { + return rej('name is required'); + } + + // Get 'parent_id' parameter + let parentId = params.parent_id; + if (parentId === undefined || parentId === null || parentId === 'null') { + parentId = null; + } else { + parentId = new mongo.ObjectID(parentId); + } + + // Issue query + const folders = await DriveFolder + .find({ + name: name, + user_id: user._id, + parent_id: parentId + }) + .toArray(); + + // Serialize + res(await Promise.all(folders.map(async folder => + await serialize(folder)))); +}); diff --git a/src/api/endpoints/drive/folders/show.js b/src/api/endpoints/drive/folders/show.js new file mode 100644 index 0000000000..4424361a87 --- /dev/null +++ b/src/api/endpoints/drive/folders/show.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import serialize from '../../../serializers/drive-folder'; + +/** + * Show a folder + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'folder_id' parameter + const folderId = params.folder_id; + if (folderId === undefined || folderId === null) { + return rej('folder_id is required'); + } + + // Get folder + const folder = await DriveFolder + .findOne({ + _id: new mongo.ObjectID(folderId), + user_id: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + // Serialize + res(await serialize(folder, { + includeParent: true + })); +}); diff --git a/src/api/endpoints/drive/folders/update.js b/src/api/endpoints/drive/folders/update.js new file mode 100644 index 0000000000..ff26a09aae --- /dev/null +++ b/src/api/endpoints/drive/folders/update.js @@ -0,0 +1,114 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import { isValidFolderName } from '../../../models/drive-folder'; +import serialize from '../../../serializers/drive-file'; +import event from '../../../event'; + +/** + * Update a folder + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'folder_id' parameter + const folderId = params.folder_id; + if (folderId === undefined || folderId === null) { + return rej('folder_id is required'); + } + + // Fetch folder + const folder = await DriveFolder + .findOne({ + _id: new mongo.ObjectID(folderId), + user_id: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + // Get 'name' parameter + let name = params.name; + if (name) { + name = name.trim(); + if (isValidFolderName(name)) { + folder.name = name; + } else { + return rej('invalid folder name'); + } + } + + // Get 'parent_id' parameter + let parentId = params.parent_id; + if (parentId !== undefined && parentId !== 'null') { + parentId = new mongo.ObjectID(parentId); + } + + let parent = null; + if (parentId !== undefined && parentId !== null) { + if (parentId === 'null') { + folder.parent_id = null; + } else { + // Get parent folder + parent = await DriveFolder + .findOne({ + _id: parentId, + user_id: user._id + }); + + if (parent === null) { + return rej('parent-folder-not-found'); + } + + // Check if the circular reference will be occured + async function checkCircle(folderId) { + // Fetch folder + const folder2 = await DriveFolder.findOne({ + _id: folderId + }, { + _id: true, + parent_id: true + }); + + if (folder2._id.equals(folder._id)) { + return true; + } else if (folder2.parent_id) { + return await checkCircle(folder2.parent_id); + } else { + return false; + } + } + + if (parent.parent_id !== null) { + if (await checkCircle(parent.parent_id)) { + return rej('detected-circular-definition'); + } + } + + folder.parent_id = parent._id; + } + } + + // Update + DriveFolder.updateOne({ _id: folder._id }, { + $set: folder + }); + + // Serialize + const folderObj = await serialize(folder); + + // Response + res(folderObj); + + // Publish drive_folder_updated event + event(user._id, 'drive_folder_updated', folderObj); +}); diff --git a/src/api/endpoints/drive/stream.js b/src/api/endpoints/drive/stream.js new file mode 100644 index 0000000000..0f407f5591 --- /dev/null +++ b/src/api/endpoints/drive/stream.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../../models/drive-file'; +import serialize from '../../serializers/drive-file'; + +/** + * Get drive stream + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Get 'type' parameter + let type = params.type; + if (type === undefined || type === null) { + type = null; + } else if (!/^[a-zA-Z\/\-\*]+$/.test(type)) { + return rej('invalid type format'); + } else { + type = new RegExp(`^${type.replace(/\*/g, '.+?')}$`); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = { + user_id: user._id + }; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + if (type !== null) { + query.type = type; + } + + // Issue query + const files = await DriveFile + .find(query, { + data: false + }, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(files.map(async file => + await serialize(file)))); +}); diff --git a/src/api/endpoints/following/create.js b/src/api/endpoints/following/create.js new file mode 100644 index 0000000000..da714cb180 --- /dev/null +++ b/src/api/endpoints/following/create.js @@ -0,0 +1,86 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import Following from '../../models/following'; +import notify from '../../common/notify'; +import event from '../../event'; +import serializeUser from '../../serializers/user'; + +/** + * Follow a user + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + const follower = user; + + // Get 'user_id' parameter + let userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // 自分自身 + if (user._id.equals(userId)) { + return rej('followee is yourself'); + } + + // Get followee + const followee = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (followee === null) { + return rej('user not found'); + } + + // Check arleady following + const exist = await Following.findOne({ + follower_id: follower._id, + followee_id: followee._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already following'); + } + + // Create following + await Following.insert({ + created_at: new Date(), + follower_id: follower._id, + followee_id: followee._id + }); + + // Send response + res(); + + // Increment following count + User.updateOne({ _id: follower._id }, { + $inc: { + following_count: 1 + } + }); + + // Increment followers count + User.updateOne({ _id: followee._id }, { + $inc: { + followers_count: 1 + } + }); + + // Publish follow event + event(follower._id, 'follow', await serializeUser(followee, follower)); + event(followee._id, 'followed', await serializeUser(follower, followee)); + + // Notify + notify(followee._id, follower._id, 'follow'); +}); diff --git a/src/api/endpoints/following/delete.js b/src/api/endpoints/following/delete.js new file mode 100644 index 0000000000..f1096801b6 --- /dev/null +++ b/src/api/endpoints/following/delete.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import Following from '../../models/following'; +import event from '../../event'; +import serializeUser from '../../serializers/user'; + +/** + * Unfollow a user + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + const follower = user; + + // Get 'user_id' parameter + let userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Check if the followee is yourself + if (user._id.equals(userId)) { + return rej('followee is yourself'); + } + + // Get followee + const followee = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (followee === null) { + return rej('user not found'); + } + + // Check not following + const exist = await Following.findOne({ + follower_id: follower._id, + followee_id: followee._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('already not following'); + } + + // Delete following + await Following.updateOne({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); + + // Decrement following count + User.updateOne({ _id: follower._id }, { + $inc: { + following_count: -1 + } + }); + + // Decrement followers count + User.updateOne({ _id: followee._id }, { + $inc: { + followers_count: -1 + } + }); + + // Publish follow event + event(follower._id, 'unfollow', await serializeUser(followee, follower)); +}); diff --git a/src/api/endpoints/i.js b/src/api/endpoints/i.js new file mode 100644 index 0000000000..481ddbb9fa --- /dev/null +++ b/src/api/endpoints/i.js @@ -0,0 +1,25 @@ +'use strict'; + +/** + * Module dependencies + */ +import serialize from '../serializers/user'; + +/** + * Show myself + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @param {Boolean} isSecure + * @return {Promise<object>} + */ +module.exports = (params, user, _, isSecure) => + new Promise(async (res, rej) => +{ + // Serialize + res(await serialize(user, user, { + detail: true, + includeSecrets: isSecure + })); +}); diff --git a/src/api/endpoints/i/appdata/get.js b/src/api/endpoints/i/appdata/get.js new file mode 100644 index 0000000000..0a86697469 --- /dev/null +++ b/src/api/endpoints/i/appdata/get.js @@ -0,0 +1,53 @@ +'use strict'; + +/** + * Module dependencies + */ +import Appdata from '../../../models/appdata'; + +/** + * Get app data + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @param {Boolean} isSecure + * @return {Promise<object>} + */ +module.exports = (params, user, app, isSecure) => + new Promise(async (res, rej) => +{ + // Get 'key' parameter + let key = params.key; + if (key === undefined) { + key = null; + } + + if (isSecure) { + if (!user.data) { + return res(); + } + if (key !== null) { + const data = {}; + data[key] = user.data[key]; + res(data); + } else { + res(user.data); + } + } else { + const select = {}; + if (key !== null) { + select['data.' + key] = true; + } + const appdata = await Appdata.findOne({ + app_id: app._id, + user_id: user._id + }, select); + + if (appdata) { + res(appdata.data); + } else { + res(); + } + } +}); diff --git a/src/api/endpoints/i/appdata/set.js b/src/api/endpoints/i/appdata/set.js new file mode 100644 index 0000000000..e161a803d0 --- /dev/null +++ b/src/api/endpoints/i/appdata/set.js @@ -0,0 +1,55 @@ +'use strict'; + +/** + * Module dependencies + */ +import Appdata from '../../../models/appdata'; +import User from '../../../models/user'; + +/** + * Set app data + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @param {Boolean} isSecure + * @return {Promise<object>} + */ +module.exports = (params, user, app, isSecure) => + new Promise(async (res, rej) => +{ + const data = params.data; + if (data == null) { + return rej('data is required'); + } + + if (isSecure) { + const set = { + $set: { + data: Object.assign(user.data || {}, JSON.parse(data)) + } + }; + await User.updateOne({ _id: user._id }, set); + res(204); + } else { + const appdata = await Appdata.findOne({ + app_id: app._id, + user_id: user._id + }); + const set = { + $set: { + data: Object.assign((appdata || {}).data || {}, JSON.parse(data)) + } + }; + await Appdata.updateOne({ + app_id: app._id, + user_id: user._id + }, Object.assign({ + app_id: app._id, + user_id: user._id + }, set), { + upsert: true + }); + res(204); + } +}); diff --git a/src/api/endpoints/i/favorites.js b/src/api/endpoints/i/favorites.js new file mode 100644 index 0000000000..e30ea2867b --- /dev/null +++ b/src/api/endpoints/i/favorites.js @@ -0,0 +1,60 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Favorite from '../../models/favorite'; +import serialize from '../../serializers/post'; + +/** + * Get followers of a user + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'sort' parameter + let sort = params.sort || 'desc'; + + // Get favorites + const favorites = await Favorites + .find({ + user_id: user._id + }, {}, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(favorites.map(async favorite => + await serialize(favorite.post) + ))); +}); diff --git a/src/api/endpoints/i/notifications.js b/src/api/endpoints/i/notifications.js new file mode 100644 index 0000000000..a28ceb76a0 --- /dev/null +++ b/src/api/endpoints/i/notifications.js @@ -0,0 +1,120 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Notification from '../../models/notification'; +import serialize from '../../serializers/notification'; +import getFriends from '../../common/get-friends'; + +/** + * Get notifications + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'following' parameter + const following = params.following === 'true'; + + // Get 'mark_as_read' parameter + let markAsRead = params.mark_as_read; + if (markAsRead == null) { + markAsRead = true; + } else { + markAsRead = markAsRead === 'true'; + } + + // Get 'type' parameter + let type = params.type; + if (type !== undefined && type !== null) { + type = type.split(',').map(x => x.trim()); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + const query = { + notifiee_id: user._id + }; + + const sort = { + _id: -1 + }; + + if (following) { + // ID list of the user itself and other users who the user follows + const followingIds = await getFriends(user._id); + + query.notifier_id = { + $in: followingIds + }; + } + + if (type) { + query.type = { + $in: type + }; + } + + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const notifications = await Notification + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(notifications.map(async notification => + await serialize(notification)))); + + // Mark as read all + if (notifications.length > 0 && markAsRead) { + const ids = notifications + .filter(x => x.is_read == false) + .map(x => x._id); + + // Update documents + await Notification.update({ + _id: { $in: ids } + }, { + $set: { is_read: true } + }, { + multi: true + }); + } +}); diff --git a/src/api/endpoints/i/signin_history.js b/src/api/endpoints/i/signin_history.js new file mode 100644 index 0000000000..7def8a41e5 --- /dev/null +++ b/src/api/endpoints/i/signin_history.js @@ -0,0 +1,71 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Signin from '../../models/signin'; +import serialize from '../../serializers/signin'; + +/** + * Get signin history of my account + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + const query = { + user_id: user._id + }; + + const sort = { + _id: -1 + }; + + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const history = await Signin + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(history.map(async record => + await serialize(record)))); +}); diff --git a/src/api/endpoints/i/update.js b/src/api/endpoints/i/update.js new file mode 100644 index 0000000000..a6b68cf01e --- /dev/null +++ b/src/api/endpoints/i/update.js @@ -0,0 +1,95 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; +import event from '../../event'; + +/** + * Update myself + * + * @param {Object} params + * @param {Object} user + * @param {Object} _ + * @param {boolean} isSecure + * @return {Promise<object>} + */ +module.exports = async (params, user, _, isSecure) => + new Promise(async (res, rej) => +{ + // Get 'name' parameter + const name = params.name; + if (name !== undefined && name !== null) { + if (name.length > 50) { + return rej('too long name'); + } + + user.name = name; + } + + // Get 'location' parameter + const location = params.location; + if (location !== undefined && location !== null) { + if (location.length > 50) { + return rej('too long location'); + } + + user.location = location; + } + + // Get 'bio' parameter + const bio = params.bio; + if (bio !== undefined && bio !== null) { + if (bio.length > 500) { + return rej('too long bio'); + } + + user.bio = bio; + } + + // Get 'avatar_id' parameter + const avatar = params.avatar_id; + if (avatar !== undefined && avatar !== null) { + user.avatar_id = new mongo.ObjectID(avatar); + } + + // Get 'banner_id' parameter + const banner = params.banner_id; + if (banner !== undefined && banner !== null) { + user.banner_id = new mongo.ObjectID(banner); + } + + await User.updateOne({ _id: user._id }, { + $set: user + }); + + // Serialize + const iObj = await serialize(user, user, { + detail: true, + includeSecrets: isSecure + }) + + // Send response + res(iObj); + + // Publish i updated event + event(user._id, 'i_updated', iObj); + + // Update search index + if (config.elasticsearch.enable) { + const es = require('../../../db/elasticsearch'); + + es.index({ + index: 'misskey', + type: 'user', + id: user._id.toString(), + body: { + name: user.name, + bio: user.bio + } + }); + } +}); diff --git a/src/api/endpoints/messaging/history.js b/src/api/endpoints/messaging/history.js new file mode 100644 index 0000000000..dafb38fd1a --- /dev/null +++ b/src/api/endpoints/messaging/history.js @@ -0,0 +1,48 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import History from '../../models/messaging-history'; +import serialize from '../../serializers/messaging-message'; + +/** + * Show messaging history + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get history + const history = await History + .find({ + user_id: user._id + }, {}, { + limit: limit, + sort: { + updated_at: -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(history.map(async h => + await serialize(h.message, user)))); +}); diff --git a/src/api/endpoints/messaging/messages.js b/src/api/endpoints/messaging/messages.js new file mode 100644 index 0000000000..12bd13597a --- /dev/null +++ b/src/api/endpoints/messaging/messages.js @@ -0,0 +1,139 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Message from '../../models/messaging-message'; +import User from '../../models/user'; +import serialize from '../../serializers/messaging-message'; +import publishUserStream from '../../event'; +import { publishMessagingStream } from '../../event'; + +/** + * Get messages + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + let recipient = params.user_id; + if (recipient !== undefined && recipient !== null) { + recipient = await User.findOne({ + _id: new mongo.ObjectID(recipient) + }); + + if (recipient === null) { + return rej('user not found'); + } + } else { + return rej('user_id is required'); + } + + // Get 'mark_as_read' parameter + let markAsRead = params.mark_as_read; + if (markAsRead == null) { + markAsRead = true; + } else { + markAsRead = markAsRead === 'true'; + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + const query = { + $or: [{ + user_id: user._id, + recipient_id: recipient._id + }, { + user_id: recipient._id, + recipient_id: user._id + }] + }; + + const sort = { + created_at: -1 + }; + + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const messages = await Message + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(messages.map(async message => + await serialize(message, user, { + populateRecipient: false + })))); + + if (messages.length === 0) { + return; + } + + // Mark as read all + if (markAsRead) { + const ids = messages + .filter(m => m.is_read == false) + .filter(m => m.recipient_id.equals(user._id)) + .map(m => m._id); + + // Update documents + await Message.update({ + _id: { $in: ids } + }, { + $set: { is_read: true } + }, { + multi: true + }); + + // Publish event + publishMessagingStream(recipient._id, user._id, 'read', ids.map(id => id.toString())); + + const count = await Message + .count({ + recipient_id: user._id, + is_read: false + }); + + if (count == 0) { + // 全ての(いままで未読だった)メッセージを(これで)読みましたよというイベントを発行 + publishUserStream(user._id, 'read_all_messaging_messages'); + } + } +}); diff --git a/src/api/endpoints/messaging/messages/create.js b/src/api/endpoints/messaging/messages/create.js new file mode 100644 index 0000000000..33634a6140 --- /dev/null +++ b/src/api/endpoints/messaging/messages/create.js @@ -0,0 +1,152 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Message from '../../../models/messaging-message'; +import History from '../../../models/messaging-history'; +import User from '../../../models/user'; +import DriveFile from '../../../models/drive-file'; +import serialize from '../../../serializers/messaging-message'; +import publishUserStream from '../../../event'; +import { publishMessagingStream } from '../../../event'; + +/** + * 最大文字数 + */ +const maxTextLength = 500; + +/** + * Create a message + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + let recipient = params.user_id; + if (recipient !== undefined && recipient !== null) { + recipient = await User.findOne({ + _id: new mongo.ObjectID(recipient) + }); + + if (recipient === null) { + return rej('user not found'); + } + } else { + return rej('user_id is required'); + } + + // Get 'text' parameter + let text = params.text; + if (text !== undefined && text !== null) { + text = text.trim(); + if (text.length === 0) { + text = null; + } else if (text.length > maxTextLength) { + return rej('too long text'); + } + } else { + text = null; + } + + // Get 'file_id' parameter + let file = params.file_id; + if (file !== undefined && file !== null) { + file = await DriveFile.findOne({ + _id: new mongo.ObjectID(file), + user_id: user._id + }, { + data: false + }); + + if (file === null) { + return rej('file not found'); + } + } else { + file = null; + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (text === null && file === null) { + return rej('text or file is required'); + } + + // メッセージを作成 + const inserted = await Message.insert({ + created_at: new Date(), + file_id: file ? file._id : undefined, + recipient_id: recipient._id, + text: text ? text : undefined, + user_id: user._id, + is_read: false + }); + + const message = inserted.ops[0]; + + // Serialize + const messageObj = await serialize(message); + + // Reponse + res(messageObj); + + // 自分のストリーム + publishMessagingStream(message.user_id, message.recipient_id, 'message', messageObj); + publishUserStream(message.user_id, 'messaging_message', messageObj); + + // 相手のストリーム + publishMessagingStream(message.recipient_id, message.user_id, 'message', messageObj); + publishUserStream(message.recipient_id, 'messaging_message', messageObj); + + // 5秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する + setTimeout(async () => { + const freshMessage = await Message.findOne({ _id: message._id }, { is_read: true }); + if (!freshMessage.is_read) { + publishUserStream(message.recipient_id, 'unread_messaging_message', messageObj); + } + }, 5000); + + // Register to search database + if (message.text && config.elasticsearch.enable) { + const es = require('../../../db/elasticsearch'); + + es.index({ + index: 'misskey', + type: 'messaging_message', + id: message._id.toString(), + body: { + text: message.text + } + }); + } + + // 履歴作成(自分) + History.updateOne({ + user_id: user._id, + partner: recipient._id + }, { + updated_at: new Date(), + user_id: user._id, + partner: recipient._id, + message: message._id + }, { + upsert: true + }); + + // 履歴作成(相手) + History.updateOne({ + user_id: recipient._id, + partner: user._id + }, { + updated_at: new Date(), + user_id: recipient._id, + partner: user._id, + message: message._id + }, { + upsert: true + }); +}); diff --git a/src/api/endpoints/messaging/unread.js b/src/api/endpoints/messaging/unread.js new file mode 100644 index 0000000000..d2de0bc448 --- /dev/null +++ b/src/api/endpoints/messaging/unread.js @@ -0,0 +1,27 @@ +'use strict'; + +/** + * Module dependencies + */ +import Message from '../../models/messaging-message'; + +/** + * Get count of unread messages + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + const count = await Message + .count({ + recipient_id: user._id, + is_read: false + }); + + res({ + count: count + }); +}); diff --git a/src/api/endpoints/meta.js b/src/api/endpoints/meta.js new file mode 100644 index 0000000000..7938cb91b4 --- /dev/null +++ b/src/api/endpoints/meta.js @@ -0,0 +1,24 @@ +'use strict'; + +/** + * Module dependencies + */ +import Git from 'nodegit'; + +/** + * Show core info + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + const repository = await Git.Repository.open(__dirname + '/../../'); + + res({ + maintainer: config.maintainer, + commit: (await repository.getHeadCommit()).sha(), + secure: config.https.enable + }); +}); diff --git a/src/api/endpoints/my/apps.js b/src/api/endpoints/my/apps.js new file mode 100644 index 0000000000..d23bc38b1c --- /dev/null +++ b/src/api/endpoints/my/apps.js @@ -0,0 +1,59 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import App from '../../models/app'; +import serialize from '../../serializers/app'; + +/** + * Get my apps + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + const query = { + user_id: user._id + }; + + // Execute query + const apps = await App + .find(query, {}, { + limit: limit, + skip: offset, + sort: { + created_at: -1 + } + }) + .toArray(); + + // Reply + res(await Promise.all(apps.map(async app => + await serialize(app)))); +}); diff --git a/src/api/endpoints/notifications/mark_as_read.js b/src/api/endpoints/notifications/mark_as_read.js new file mode 100644 index 0000000000..16eb2009ac --- /dev/null +++ b/src/api/endpoints/notifications/mark_as_read.js @@ -0,0 +1,54 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Notification from '../../../models/notification'; +import serialize from '../../../serializers/notification'; +import event from '../../../event'; + +/** + * Mark as read a notification + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + const notificationId = params.notification; + + if (notificationId === undefined || notificationId === null) { + return rej('notification is required'); + } + + // Get notifcation + const notification = await Notification + .findOne({ + _id: new mongo.ObjectID(notificationId), + i: user._id + }); + + if (notification === null) { + return rej('notification-not-found'); + } + + // Update + notification.is_read = true; + Notification.updateOne({ _id: notification._id }, { + $set: { + is_read: true + } + }); + + // Response + res(); + + // Serialize + const notificationObj = await serialize(notification); + + // Publish read_notification event + event(user._id, 'read_notification', notificationObj); +}); diff --git a/src/api/endpoints/posts.js b/src/api/endpoints/posts.js new file mode 100644 index 0000000000..05fc871ec1 --- /dev/null +++ b/src/api/endpoints/posts.js @@ -0,0 +1,65 @@ +'use strict'; + +/** + * Module dependencies + */ +import Post from '../models/post'; +import serialize from '../serializers/post'; + +/** + * Lists all posts + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = {}; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const posts = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(posts.map(async post => await serialize(post)))); +}); diff --git a/src/api/endpoints/posts/context.js b/src/api/endpoints/posts/context.js new file mode 100644 index 0000000000..5f040b8505 --- /dev/null +++ b/src/api/endpoints/posts/context.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a context of a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found', 'POST_NOT_FOUND'); + } + + const context = []; + let i = 0; + + async function get(id) { + i++; + const p = await Post.findOne({ _id: id }); + + if (i > offset) { + context.push(p); + } + + if (context.length == limit) { + return; + } + + if (p.reply_to_id) { + await get(p.reply_to_id); + } + } + + if (post.reply_to_id) { + await get(post.reply_to_id); + } + + // Serialize + res(await Promise.all(context.map(async post => + await serialize(post, user)))); +}); diff --git a/src/api/endpoints/posts/create.js b/src/api/endpoints/posts/create.js new file mode 100644 index 0000000000..cdcbf4f966 --- /dev/null +++ b/src/api/endpoints/posts/create.js @@ -0,0 +1,345 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import parse from '../../../common/text'; +import Post from '../../models/post'; +import User from '../../models/user'; +import Following from '../../models/following'; +import DriveFile from '../../models/drive-file'; +import serialize from '../../serializers/post'; +import createFile from '../../common/add-file-to-drive'; +import notify from '../../common/notify'; +import event from '../../event'; + +/** + * 最大文字数 + */ +const maxTextLength = 300; + +/** + * 添付できるファイルの数 + */ +const maxMediaCount = 4; + +/** + * Create a post + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @return {Promise<object>} + */ +module.exports = (params, user, app) => + new Promise(async (res, rej) => +{ + // Get 'text' parameter + let text = params.text; + if (text !== undefined && text !== null) { + text = text.trim(); + if (text.length == 0) { + text = null; + } else if (text.length > maxTextLength) { + return rej('too long text'); + } + } else { + text = null; + } + + // Get 'media_ids' parameter + let media = params.media_ids; + let files = []; + if (media !== undefined && media !== null) { + media = media.split(','); + + if (media.length > maxMediaCount) { + return rej('too many media'); + } + + // Drop duplicates + media = media.filter((x, i, s) => s.indexOf(x) == i); + + // Fetch files + // forEach だと途中でエラーなどがあっても return できないので + // 敢えて for を使っています。 + for (let i = 0; i < media.length; i++) { + const image = media[i]; + + // Fetch file + // SELECT _id + const entity = await DriveFile.findOne({ + _id: new mongo.ObjectID(image), + user_id: user._id + }, { + _id: true + }); + + if (entity === null) { + return rej('file not found'); + } else { + files.push(entity); + } + } + } else { + files = null; + } + + // Get 'repost_id' parameter + let repost = params.repost_id; + if (repost !== undefined && repost !== null) { + // Fetch repost to post + repost = await Post.findOne({ + _id: new mongo.ObjectID(repost) + }); + + if (repost == null) { + return rej('repostee is not found'); + } else if (repost.repost_id && !repost.text && !repost.media_ids) { + return rej('cannot repost to repost'); + } + + // Fetch recently post + const latestPost = await Post.findOne({ + user_id: user._id + }, {}, { + sort: { + _id: -1 + } + }); + + // 直近と同じRepost対象かつ引用じゃなかったらエラー + if (latestPost && + latestPost.repost_id && + latestPost.repost_id.equals(repost._id) && + text === null && files === null) { + return rej('二重Repostです(NEED TRANSLATE)'); + } + + // 直近がRepost対象かつ引用じゃなかったらエラー + if (latestPost && + latestPost._id.equals(repost._id) && + text === null && files === null) { + return rej('二重Repostです(NEED TRANSLATE)'); + } + } else { + repost = null; + } + + // Get 'reply_to_id' parameter + let replyTo = params.reply_to_id; + if (replyTo !== undefined && replyTo !== null) { + replyTo = await Post.findOne({ + _id: new mongo.ObjectID(replyTo) + }); + + if (replyTo === null) { + return rej('reply to post is not found'); + } + + // 返信対象が引用でないRepostだったらエラー + if (replyTo.repost_id && !replyTo.text && !replyTo.media_ids) { + return rej('cannot reply to repost'); + } + } else { + replyTo = null; + } + + // テキストが無いかつ添付ファイルが無いかつRepostも無かったらエラー + if (text === null && files === null && repost === null) { + return rej('text, media_ids or repost_id is required'); + } + + // 投稿を作成 + const inserted = await Post.insert({ + created_at: new Date(), + media_ids: media ? files.map(file => file._id) : undefined, + reply_to_id: replyTo ? replyTo._id : undefined, + repost_id: repost ? repost._id : undefined, + text: text, + user_id: user._id, + app_id: app ? app._id : null + }); + + const post = inserted.ops[0]; + + // Serialize + const postObj = await serialize(post); + + // Reponse + res(postObj); + + //-------------------------------- + // Post processes + + let mentions = []; + + function addMention(mentionee, type) { + // Reject if already added + if (mentions.some(x => x.equals(mentionee))) return; + + // Add mention + mentions.push(mentionee); + + // Publish event + if (!user._id.equals(mentionee)) { + event(mentionee, type, postObj); + } + } + + // Publish event to myself's stream + event(user._id, 'post', postObj); + + // Fetch all followers + const followers = await Following + .find({ + followee_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + follower_id: true, + _id: false + }) + .toArray(); + + // Publish event to followers stream + followers.forEach(following => + event(following.follower_id, 'post', postObj)); + + // Increment my posts count + User.updateOne({ _id: user._id }, { + $inc: { + posts_count: 1 + } + }); + + // If has in reply to post + if (replyTo) { + // Increment replies count + Post.updateOne({ _id: replyTo._id }, { + $inc: { + replies_count: 1 + } + }); + + // 自分自身へのリプライでない限りは通知を作成 + notify(replyTo.user_id, user._id, 'reply', { + post_id: post._id + }); + + // Add mention + addMention(replyTo.user_id, 'reply'); + } + + // If it is repost + if (repost) { + // Notify + const type = text ? 'quote' : 'repost'; + notify(repost.user_id, user._id, type, { + post_id: post._id + }); + + // If it is quote repost + if (text) { + // Add mention + addMention(repost.user_id, 'quote'); + } else { + // Publish event + if (!user._id.equals(repost.user_id)) { + event(repost.user_id, 'repost', postObj); + } + } + + // 今までで同じ投稿をRepostしているか + const existRepost = await Post.findOne({ + user_id: user._id, + repost_id: repost._id, + _id: { + $ne: post._id + } + }); + + if (!existRepost) { + // Update repostee status + Post.updateOne({ _id: repost._id }, { + $inc: { + repost_count: 1 + } + }); + } + } + + // If has text content + if (text) { + // Analyze + const tokens = parse(text); + + // Extract a hashtags + const hashtags = tokens + .filter(t => t.type == 'hashtag') + .map(t => t.hashtag) + // Drop dupulicates + .filter((v, i, s) => s.indexOf(v) == i); + + // ハッシュタグをデータベースに登録 + //registerHashtags(user, hashtags); + + // Extract an '@' mentions + const atMentions = tokens + .filter(t => t.type == 'mention') + .map(m => m.username) + // Drop dupulicates + .filter((v, i, s) => s.indexOf(v) == i); + + // Resolve all mentions + await Promise.all(atMentions.map(async (mention) => { + // Fetch mentioned user + // SELECT _id + const mentionee = await User + .findOne({ + username_lower: mention.toLowerCase() + }, { _id: true }); + + // When mentioned user not found + if (mentionee == null) return; + + // 既に言及されたユーザーに対する返信や引用repostの場合も無視 + if (replyTo && replyTo.user_id.equals(mentionee._id)) return; + if (repost && repost.user_id.equals(mentionee._id)) return; + + // Add mention + addMention(mentionee._id, 'mention'); + + // Create notification + notify(mentionee._id, user._id, 'mention', { + post_id: post._id + }); + + return; + })); + } + + // Register to search database + if (text && config.elasticsearch.enable) { + const es = require('../../../db/elasticsearch'); + + es.index({ + index: 'misskey', + type: 'post', + id: post._id.toString(), + body: { + text: post.text + } + }); + } + + // Append mentions data + if (mentions.length > 0) { + Post.updateOne({ _id: post._id }, { + $set: { + mentions: mentions + } + }); + } +}); diff --git a/src/api/endpoints/posts/favorites/create.js b/src/api/endpoints/posts/favorites/create.js new file mode 100644 index 0000000000..d20a523d5d --- /dev/null +++ b/src/api/endpoints/posts/favorites/create.js @@ -0,0 +1,56 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Favorite from '../../models/favorite'; +import Post from '../../models/post'; + +/** + * Favorite a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + let postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get favoritee + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Check arleady favorited + const exist = await Favorite.findOne({ + post_id: post._id, + user_id: user._id + }); + + if (exist !== null) { + return rej('already favorited'); + } + + // Create favorite + const inserted = await Favorite.insert({ + created_at: new Date(), + post_id: post._id, + user_id: user._id + }); + + const favorite = inserted.ops[0]; + + // Send response + res(); +}); diff --git a/src/api/endpoints/posts/favorites/delete.js b/src/api/endpoints/posts/favorites/delete.js new file mode 100644 index 0000000000..e250d1772c --- /dev/null +++ b/src/api/endpoints/posts/favorites/delete.js @@ -0,0 +1,52 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Favorite from '../../models/favorite'; +import Post from '../../models/post'; + +/** + * Unfavorite a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + let postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get favoritee + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Check arleady favorited + const exist = await Favorite.findOne({ + post_id: post._id, + user_id: user._id + }); + + if (exist === null) { + return rej('already not favorited'); + } + + // Delete favorite + await Favorite.deleteOne({ + _id: exist._id + }); + + // Send response + res(); +}); diff --git a/src/api/endpoints/posts/likes.js b/src/api/endpoints/posts/likes.js new file mode 100644 index 0000000000..4778189fc6 --- /dev/null +++ b/src/api/endpoints/posts/likes.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import Like from '../../models/like'; +import serialize from '../../serializers/user'; + +/** + * Show a likes of a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'sort' parameter + let sort = params.sort || 'desc'; + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Issue query + const likes = await Like + .find({ + post_id: post._id, + deleted_at: { $exists: false } + }, {}, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(likes.map(async like => + await serialize(like.user_id, user)))); +}); diff --git a/src/api/endpoints/posts/likes/create.js b/src/api/endpoints/posts/likes/create.js new file mode 100644 index 0000000000..eb35c1e4b0 --- /dev/null +++ b/src/api/endpoints/posts/likes/create.js @@ -0,0 +1,93 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Like from '../../../models/like'; +import Post from '../../../models/post'; +import User from '../../../models/user'; +import notify from '../../../common/notify'; +import event from '../../../event'; +import serializeUser from '../../../serializers/user'; +import serializePost from '../../../serializers/post'; + +/** + * Like a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + let postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get likee + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Myself + if (post.user_id.equals(user._id)) { + return rej('-need-translate-'); + } + + // Check arleady liked + const exist = await Like.findOne({ + post_id: post._id, + user_id: user._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already liked'); + } + + // Create like + const inserted = await Like.insert({ + created_at: new Date(), + post_id: post._id, + user_id: user._id + }); + + const like = inserted.ops[0]; + + // Send response + res(); + + // Increment likes count + Post.updateOne({ _id: post._id }, { + $inc: { + likes_count: 1 + } + }); + + // Increment user likes count + User.updateOne({ _id: user._id }, { + $inc: { + likes_count: 1 + } + }); + + // Increment user liked count + User.updateOne({ _id: post.user_id }, { + $inc: { + liked_count: 1 + } + }); + + // Notify + notify(post.user_id, user._id, 'like', { + post_id: post._id + }); +}); diff --git a/src/api/endpoints/posts/likes/delete.js b/src/api/endpoints/posts/likes/delete.js new file mode 100644 index 0000000000..b60df63af5 --- /dev/null +++ b/src/api/endpoints/posts/likes/delete.js @@ -0,0 +1,80 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Like from '../../../models/like'; +import Post from '../../../models/post'; +import User from '../../../models/user'; +// import event from '../../../event'; + +/** + * Unlike a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + let postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get likee + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Check arleady liked + const exist = await Like.findOne({ + post_id: post._id, + user_id: user._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('already not liked'); + } + + // Delete like + await Like.updateOne({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); + + // Decrement likes count + Post.updateOne({ _id: post._id }, { + $inc: { + likes_count: -1 + } + }); + + // Decrement user likes count + User.updateOne({ _id: user._id }, { + $inc: { + likes_count: -1 + } + }); + + // Decrement user liked count + User.updateOne({ _id: post.user_id }, { + $inc: { + liked_count: -1 + } + }); +}); diff --git a/src/api/endpoints/posts/mentions.js b/src/api/endpoints/posts/mentions.js new file mode 100644 index 0000000000..6358e1f4a9 --- /dev/null +++ b/src/api/endpoints/posts/mentions.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import getFriends from '../../common/get-friends'; +import serialize from '../../serializers/post'; + +/** + * Get mentions of myself + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'following' parameter + const following = params.following === 'true'; + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Construct query + const query = { + mentions: user._id + }; + + const sort = { + _id: -1 + }; + + if (following) { + const followingIds = await getFriends(user._id); + + query.user_id = { + $in: followingIds + }; + } + + if (since) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const mentions = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(mentions.map(async mention => + await serialize(mention, user) + ))); +}); diff --git a/src/api/endpoints/posts/replies.js b/src/api/endpoints/posts/replies.js new file mode 100644 index 0000000000..5eab6f896f --- /dev/null +++ b/src/api/endpoints/posts/replies.js @@ -0,0 +1,73 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a replies of a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'sort' parameter + let sort = params.sort || 'desc'; + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found', 'POST_NOT_FOUND'); + } + + // Issue query + const replies = await Post + .find({ reply_to_id: post._id }, {}, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(replies.map(async post => + await serialize(post, user)))); +}); diff --git a/src/api/endpoints/posts/reposts.js b/src/api/endpoints/posts/reposts.js new file mode 100644 index 0000000000..8b418a682f --- /dev/null +++ b/src/api/endpoints/posts/reposts.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a reposts of a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found', 'POST_NOT_FOUND'); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = { + repost_id: post._id + }; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const reposts = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(reposts.map(async post => + await serialize(post, user)))); +}); diff --git a/src/api/endpoints/posts/search.js b/src/api/endpoints/posts/search.js new file mode 100644 index 0000000000..0f214ef7ae --- /dev/null +++ b/src/api/endpoints/posts/search.js @@ -0,0 +1,138 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; +const escapeRegexp = require('escape-regexp'); + +/** + * Search a post + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'query' parameter + let query = params.query; + if (query === undefined || query === null || query.trim() === '') { + return rej('query is required'); + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'max' parameter + let max = params.max; + if (max !== undefined && max !== null) { + max = parseInt(max, 10); + + // From 1 to 30 + if (!(1 <= max && max <= 30)) { + return rej('invalid max range'); + } + } else { + max = 10; + } + + // If Elasticsearch is available, search by it + // If not, search by MongoDB + (config.elasticsearch.enable ? byElasticsearch : byNative) + (res, rej, me, query, offset, max); +}); + +// Search by MongoDB +async function byNative(res, rej, me, query, offset, max) { + const escapedQuery = escapeRegexp(query); + + // Search posts + const posts = await Post + .find({ + text: new RegExp(escapedQuery) + }, { + sort: { + _id: -1 + }, + limit: max, + skip: offset + }) + .toArray(); + + // Serialize + res(await Promise.all(posts.map(async post => + await serialize(post, me)))); +} + +// Search by Elasticsearch +async function byElasticsearch(res, rej, me, query, offset, max) { + const es = require('../../db/elasticsearch'); + + es.search({ + index: 'misskey', + type: 'post', + body: { + size: max, + from: offset, + query: { + simple_query_string: { + fields: ['text'], + query: query, + default_operator: 'and' + } + }, + sort: [ + { _doc: 'desc' } + ], + highlight: { + pre_tags: ['<mark>'], + post_tags: ['</mark>'], + encoder: 'html', + fields: { + text: {} + } + } + } + }, async (error, response) => { + if (error) { + console.error(error); + return res(500); + } + + if (response.hits.total === 0) { + return res([]); + } + + const hits = response.hits.hits.map(hit => new mongo.ObjectID(hit._id)); + + // Fetxh found posts + const posts = await Post + .find({ + _id: { + $in: hits + } + }, {}, { + sort: { + _id: -1 + } + }) + .toArray(); + + posts.map(post => { + post._highlight = response.hits.hits.filter(hit => post._id.equals(hit._id))[0].highlight.text[0]; + }); + + // Serialize + res(await Promise.all(posts.map(async post => + await serialize(post, me)))); + }); +} diff --git a/src/api/endpoints/posts/show.js b/src/api/endpoints/posts/show.js new file mode 100644 index 0000000000..19cdb74251 --- /dev/null +++ b/src/api/endpoints/posts/show.js @@ -0,0 +1,40 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Serialize + res(await serialize(post, user, { + serializeReplyTo: true, + includeIsLiked: true + })); +}); diff --git a/src/api/endpoints/posts/timeline.js b/src/api/endpoints/posts/timeline.js new file mode 100644 index 0000000000..489542da71 --- /dev/null +++ b/src/api/endpoints/posts/timeline.js @@ -0,0 +1,78 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import getFriends from '../../common/get-friends'; +import serialize from '../../serializers/post'; + +/** + * Get timeline of myself + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @return {Promise<object>} + */ +module.exports = (params, user, app) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // ID list of the user itself and other users who the user follows + const followingIds = await getFriends(user._id); + + // Construct query + const sort = { + _id: -1 + }; + const query = { + user_id: { + $in: followingIds + } + }; + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const timeline = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(timeline.map(async post => + await serialize(post, user) + ))); +}); diff --git a/src/api/endpoints/username/available.js b/src/api/endpoints/username/available.js new file mode 100644 index 0000000000..a93637bc1f --- /dev/null +++ b/src/api/endpoints/username/available.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Module dependencies + */ +import User from '../../models/user'; +import { validateUsername } from '../../models/user'; + +/** + * Check available username + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = async (params) => + new Promise(async (res, rej) => +{ + // Get 'username' parameter + const username = params.username; + if (username == null || username == '') { + return rej('username-is-required'); + } + + // Validate username + if (!validateUsername(username)) { + return rej('invalid-username'); + } + + // Get exist + const exist = await User + .count({ + username_lower: username.toLowerCase() + }, { + limit: 1 + }); + + // Reply + res({ + available: exist === 0 + }); +}); diff --git a/src/api/endpoints/users.js b/src/api/endpoints/users.js new file mode 100644 index 0000000000..cd40cdf4e1 --- /dev/null +++ b/src/api/endpoints/users.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Module dependencies + */ +import User from '../models/user'; +import serialize from '../serializers/user'; + +/** + * Lists all users + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = {}; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const users = await User + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me)))); +}); diff --git a/src/api/endpoints/users/followers.js b/src/api/endpoints/users/followers.js new file mode 100644 index 0000000000..303f55e450 --- /dev/null +++ b/src/api/endpoints/users/followers.js @@ -0,0 +1,102 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import Following from '../../models/following'; +import serialize from '../../serializers/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get followers of a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Get 'iknow' parameter + const iknow = params.iknow === 'true'; + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'cursor' parameter + const cursor = params.cursor || null; + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const query = { + followee_id: user._id, + deleted_at: { $exists: false } + }; + + // ログインしていてかつ iknow フラグがあるとき + if (me && iknow) { + // Get my friends + const myFriends = await getFriends(me._id); + + query.follower_id = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: new mongo.ObjectID(cursor) + }; + } + + // Get followers + const following = await Following + .find(query, {}, { + limit: limit + 1, + sort: { _id: -1 } + }) + .toArray(); + + // 「次のページ」があるかどうか + const inStock = following.length === limit + 1; + if (inStock) { + following.pop(); + } + + // Serialize + const users = await Promise.all(following.map(async f => + await serialize(f.follower_id, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? following[following.length - 1]._id : null, + }); +}); diff --git a/src/api/endpoints/users/following.js b/src/api/endpoints/users/following.js new file mode 100644 index 0000000000..ec3954563a --- /dev/null +++ b/src/api/endpoints/users/following.js @@ -0,0 +1,102 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import Following from '../../models/following'; +import serialize from '../../serializers/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get following users of a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Get 'iknow' parameter + const iknow = params.iknow === 'true'; + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'cursor' parameter + const cursor = params.cursor || null; + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const query = { + follower_id: user._id, + deleted_at: { $exists: false } + }; + + // ログインしていてかつ iknow フラグがあるとき + if (me && iknow) { + // Get my friends + const myFriends = await getFriends(me._id); + + query.followee_id = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: new mongo.ObjectID(cursor) + }; + } + + // Get followers + const following = await Following + .find(query, {}, { + limit: limit + 1, + sort: { _id: -1 } + }) + .toArray(); + + // 「次のページ」があるかどうか + const inStock = following.length === limit + 1; + if (inStock) { + following.pop(); + } + + // Serialize + const users = await Promise.all(following.map(async f => + await serialize(f.followee_id, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? following[following.length - 1]._id : null, + }); +}); diff --git a/src/api/endpoints/users/posts.js b/src/api/endpoints/users/posts.js new file mode 100644 index 0000000000..6d6f8a6904 --- /dev/null +++ b/src/api/endpoints/users/posts.js @@ -0,0 +1,114 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import User from '../../models/user'; +import serialize from '../../serializers/post'; + +/** + * Get posts of a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Get 'with_replies' parameter + let withReplies = params.with_replies; + if (withReplies !== undefined && withReplies !== null && withReplies === 'true') { + withReplies = true; + } else { + withReplies = false; + } + + // Get 'with_media' parameter + let withMedia = params.with_media; + if (withMedia !== undefined && withMedia !== null && withMedia === 'true') { + withMedia = true; + } else { + withMedia = false; + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = { + user_id: user._id + }; + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + if (!withReplies) { + query.reply_to_id = null; + } + + if (withMedia) { + query.media_ids = { + $exists: true, + $ne: null + }; + } + + // Issue query + const posts = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(posts.map(async (post) => + await serialize(post, me) + ))); +}); diff --git a/src/api/endpoints/users/recommendation.js b/src/api/endpoints/users/recommendation.js new file mode 100644 index 0000000000..9daab0ec57 --- /dev/null +++ b/src/api/endpoints/users/recommendation.js @@ -0,0 +1,61 @@ +'use strict'; + +/** + * Module dependencies + */ +import User from '../../models/user'; +import serialize from '../../serializers/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get recommended users + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // ID list of the user itself and other users who the user follows + const followingIds = await getFriends(me._id); + + const users = await User + .find({ + _id: { + $nin: followingIds + } + }, {}, { + limit: limit, + skip: offset, + sort: { + followers_count: -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me, { detail: true })))); +}); diff --git a/src/api/endpoints/users/search.js b/src/api/endpoints/users/search.js new file mode 100644 index 0000000000..3a3fe677db --- /dev/null +++ b/src/api/endpoints/users/search.js @@ -0,0 +1,116 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; +const escapeRegexp = require('escape-regexp'); + +/** + * Search a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'query' parameter + let query = params.query; + if (query === undefined || query === null || query.trim() === '') { + return rej('query is required'); + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'max' parameter + let max = params.max; + if (max !== undefined && max !== null) { + max = parseInt(max, 10); + + // From 1 to 30 + if (!(1 <= max && max <= 30)) { + return rej('invalid max range'); + } + } else { + max = 10; + } + + // If Elasticsearch is available, search by it + // If not, search by MongoDB + (config.elasticsearch.enable ? byElasticsearch : byNative) + (res, rej, me, query, offset, max); +}); + +// Search by MongoDB +async function byNative(res, rej, me, query, offset, max) { + const escapedQuery = escapeRegexp(query); + + // Search users + const users = await User + .find({ + $or: [{ + username_lower: new RegExp(escapedQuery.toLowerCase()) + }, { + name: new RegExp(escapedQuery) + }] + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me, { detail: true })))); +} + +// Search by Elasticsearch +async function byElasticsearch(res, rej, me, query, offset, max) { + const es = require('../../db/elasticsearch'); + + es.search({ + index: 'misskey', + type: 'user', + body: { + size: max, + from: offset, + query: { + simple_query_string: { + fields: ['username', 'name', 'bio'], + query: query, + default_operator: 'and' + } + } + } + }, async (error, response) => { + if (error) { + console.error(error); + return res(500); + } + + if (response.hits.total === 0) { + return res([]); + } + + const hits = response.hits.hits.map(hit => new mongo.ObjectID(hit._id)); + + const users = await User + .find({ + _id: { + $in: hits + } + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me, { detail: true })))); + }); +} diff --git a/src/api/endpoints/users/search_by_username.js b/src/api/endpoints/users/search_by_username.js new file mode 100644 index 0000000000..9e3efbd85c --- /dev/null +++ b/src/api/endpoints/users/search_by_username.js @@ -0,0 +1,65 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; + +/** + * Search a user by username + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'query' parameter + let query = params.query; + if (query === undefined || query === null || query.trim() === '') { + return rej('query is required'); + } + + query = query.trim(); + + if (!/^[a-zA-Z0-9-]+$/.test(query)) { + return rej('invalid query'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + const users = await User + .find({ + username_lower: new RegExp(query.toLowerCase()) + }, { + limit: limit, + skip: offset + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me, { detail: true })))); +}); diff --git a/src/api/endpoints/users/show.js b/src/api/endpoints/users/show.js new file mode 100644 index 0000000000..af475c6cb9 --- /dev/null +++ b/src/api/endpoints/users/show.js @@ -0,0 +1,49 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; + +/** + * Show a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + let userId = params.user_id; + if (userId === undefined || userId === null || userId === '') { + userId = null; + } + + // Get 'username' parameter + let username = params.username; + if (username === undefined || username === null || username === '') { + username = null; + } + + if (userId === null && username === null) { + return rej('user_id or username is required'); + } + + // Lookup user + const user = userId !== null + ? await User.findOne({ _id: new mongo.ObjectID(userId) }) + : await User.findOne({ username_lower: username.toLowerCase() }); + + if (user === null) { + return rej('user not found'); + } + + // Send response + res(await serialize(user, me, { + detail: true + })); +}); diff --git a/src/api/event.ts b/src/api/event.ts new file mode 100644 index 0000000000..584fc8e86c --- /dev/null +++ b/src/api/event.ts @@ -0,0 +1,36 @@ +import * as mongo from 'mongodb'; +import * as redis from 'redis'; + +type ID = string | mongo.ObjectID; + +class MisskeyEvent { + private redisClient: redis.RedisClient; + + constructor() { + // Connect to Redis + this.redisClient = redis.createClient( + config.redis.port, config.redis.host); + } + + private publish(channel: string, type: string, value?: Object): void { + const message = value == null ? + { type: type } : + { type: type, body: value }; + + this.redisClient.publish(`misskey:${channel}`, JSON.stringify(message)); + } + + public publishUserStream(userId: ID, type: string, value?: Object): void { + this.publish(`user-stream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishMessagingStream(userId: ID, otherpartyId: ID, type: string, value?: Object): void { + this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); + } +} + +const ev = new MisskeyEvent(); + +export default ev.publishUserStream.bind(ev); + +export const publishMessagingStream = ev.publishMessagingStream.bind(ev); diff --git a/src/api/limitter.ts b/src/api/limitter.ts new file mode 100644 index 0000000000..9cc25675d8 --- /dev/null +++ b/src/api/limitter.ts @@ -0,0 +1,69 @@ +import * as Limiter from 'ratelimiter'; +import limiterDB from '../db/redis'; +import { IEndpoint } from './endpoints'; +import { IAuthContext } from './authenticate'; + +export default (endpoint: IEndpoint, ctx: IAuthContext) => new Promise((ok, reject) => { + const limitKey = endpoint.hasOwnProperty('limitKey') + ? endpoint.limitKey + : endpoint.name; + + const hasMinInterval = + endpoint.hasOwnProperty('minInterval'); + + const hasRateLimit = + endpoint.hasOwnProperty('limitDuration') && + endpoint.hasOwnProperty('limitMax'); + + if (hasMinInterval) { + min(); + } else if (hasRateLimit) { + max(); + } else { + ok(); + } + + // Short-term limit + function min(): void { + const minIntervalLimiter = new Limiter({ + id: `${ctx.user._id}:${limitKey}:min`, + duration: endpoint.minInterval, + max: 1, + db: limiterDB + }); + + minIntervalLimiter.get((limitErr, limit) => { + if (limitErr) { + reject('ERR'); + } else if (limit.remaining === 0) { + reject('BRIEF_REQUEST_INTERVAL'); + } else { + if (hasRateLimit) { + max(); + } else { + ok(); + } + } + }); + } + + // Long term limit + function max(): void { + const limiter = new Limiter({ + id: `${ctx.user._id}:${limitKey}`, + duration: endpoint.limitDuration, + max: endpoint.limitMax, + db: limiterDB + }); + + limiter.get((limitErr, limit) => { + if (limitErr) { + reject('ERR'); + } else if (limit.remaining === 0) { + reject('RATE_LIMIT_EXCEEDED'); + } else { + ok(); + } + }); + } +}); diff --git a/src/api/models/app.ts b/src/api/models/app.ts new file mode 100644 index 0000000000..221a53906a --- /dev/null +++ b/src/api/models/app.ts @@ -0,0 +1,7 @@ +const collection = global.db.collection('apps'); + +collection.createIndex('name_id'); +collection.createIndex('name_id_lower'); +collection.createIndex('secret'); + +export default collection; diff --git a/src/api/models/appdata.ts b/src/api/models/appdata.ts new file mode 100644 index 0000000000..2d471c4347 --- /dev/null +++ b/src/api/models/appdata.ts @@ -0,0 +1 @@ +export default global.db.collection('appdata'); diff --git a/src/api/models/auth-session.ts b/src/api/models/auth-session.ts new file mode 100644 index 0000000000..6dbe2fa70e --- /dev/null +++ b/src/api/models/auth-session.ts @@ -0,0 +1 @@ +export default global.db.collection('auth_sessions'); diff --git a/src/api/models/drive-file.ts b/src/api/models/drive-file.ts new file mode 100644 index 0000000000..06ebf02005 --- /dev/null +++ b/src/api/models/drive-file.ts @@ -0,0 +1,11 @@ +export default global.db.collection('drive_files'); + +export function validateFileName(name: string): boolean { + return ( + (name.trim().length > 0) && + (name.length <= 200) && + (name.indexOf('\\') === -1) && + (name.indexOf('/') === -1) && + (name.indexOf('..') === -1) + ); +} diff --git a/src/api/models/drive-folder.ts b/src/api/models/drive-folder.ts new file mode 100644 index 0000000000..f345b3c340 --- /dev/null +++ b/src/api/models/drive-folder.ts @@ -0,0 +1,8 @@ +export default global.db.collection('drive_folders'); + +export function isValidFolderName(name: string): boolean { + return ( + (name.trim().length > 0) && + (name.length <= 200) + ); +} diff --git a/src/api/models/drive-tag.ts b/src/api/models/drive-tag.ts new file mode 100644 index 0000000000..83c0a8f681 --- /dev/null +++ b/src/api/models/drive-tag.ts @@ -0,0 +1 @@ +export default global.db.collection('drive_tags'); diff --git a/src/api/models/favorite.ts b/src/api/models/favorite.ts new file mode 100644 index 0000000000..6d9e7c72b3 --- /dev/null +++ b/src/api/models/favorite.ts @@ -0,0 +1 @@ +export default global.db.collection('favorites'); diff --git a/src/api/models/following.ts b/src/api/models/following.ts new file mode 100644 index 0000000000..f9d8a41c5e --- /dev/null +++ b/src/api/models/following.ts @@ -0,0 +1 @@ +export default global.db.collection('following'); diff --git a/src/api/models/like.ts b/src/api/models/like.ts new file mode 100644 index 0000000000..aa3bd75c1c --- /dev/null +++ b/src/api/models/like.ts @@ -0,0 +1 @@ +export default global.db.collection('likes'); diff --git a/src/api/models/messaging-history.ts b/src/api/models/messaging-history.ts new file mode 100644 index 0000000000..3505e94b57 --- /dev/null +++ b/src/api/models/messaging-history.ts @@ -0,0 +1 @@ +export default global.db.collection('messaging_histories'); diff --git a/src/api/models/messaging-message.ts b/src/api/models/messaging-message.ts new file mode 100644 index 0000000000..0e900bda58 --- /dev/null +++ b/src/api/models/messaging-message.ts @@ -0,0 +1 @@ +export default global.db.collection('messaging_messages'); diff --git a/src/api/models/notification.ts b/src/api/models/notification.ts new file mode 100644 index 0000000000..1cb7b80838 --- /dev/null +++ b/src/api/models/notification.ts @@ -0,0 +1 @@ +export default global.db.collection('notifications'); diff --git a/src/api/models/post.ts b/src/api/models/post.ts new file mode 100644 index 0000000000..bea92a5f61 --- /dev/null +++ b/src/api/models/post.ts @@ -0,0 +1 @@ +export default global.db.collection('posts'); diff --git a/src/api/models/signin.ts b/src/api/models/signin.ts new file mode 100644 index 0000000000..896afaaf84 --- /dev/null +++ b/src/api/models/signin.ts @@ -0,0 +1 @@ +export default global.db.collection('signin'); diff --git a/src/api/models/user.ts b/src/api/models/user.ts new file mode 100644 index 0000000000..1742f5cafb --- /dev/null +++ b/src/api/models/user.ts @@ -0,0 +1,10 @@ +const collection = global.db.collection('users'); + +collection.createIndex('username'); +collection.createIndex('token'); + +export default collection; + +export function validateUsername(username: string): boolean { + return /^[a-zA-Z0-9\-]{3,20}$/.test(username); +} diff --git a/src/api/models/userkey.ts b/src/api/models/userkey.ts new file mode 100644 index 0000000000..204f283a28 --- /dev/null +++ b/src/api/models/userkey.ts @@ -0,0 +1,5 @@ +const collection = global.db.collection('userkeys'); + +collection.createIndex('key'); + +export default collection; diff --git a/src/api/private/signin.ts b/src/api/private/signin.ts new file mode 100644 index 0000000000..b68fc89aa0 --- /dev/null +++ b/src/api/private/signin.ts @@ -0,0 +1,57 @@ +import * as express from 'express'; +import * as bcrypt from 'bcrypt'; +import User from '../models/user'; +import Signin from '../models/signin'; +import serialize from '../serializers/signin'; +import event from '../event'; + + +export default async (req: express.Request, res: express.Response) => { + res.header('Access-Control-Allow-Credentials', 'true'); + + const username = req.body['username']; + const password = req.body['password']; + + // Fetch user + const user = await User.findOne({ + username_lower: username.toLowerCase() + }); + + if (user === null) { + res.status(404).send('user not found'); + return; + } + + // Compare password + const same = await bcrypt.compare(password, user.password); + + if (same) { + const expires = 1000 * 60 * 60 * 24 * 365; // One Year + res.cookie('i', user.token, { + path: '/', + domain: `.${config.host}`, + secure: config.url.substr(0, 5) === 'https', + httpOnly: false, + expires: new Date(Date.now() + expires), + maxAge: expires + }); + + res.sendStatus(204); + } else { + res.status(400).send('incorrect password'); + } + + // Append signin history + const inserted = await Signin.insert({ + created_at: new Date(), + user_id: user._id, + ip: req.ip, + headers: req.headers, + success: same + }); + + const record = inserted.ops[0]; + + // Publish signin event + event(user._id, 'signin', await serialize(record)); +}; diff --git a/src/api/private/signup.ts b/src/api/private/signup.ts new file mode 100644 index 0000000000..7df1f25b37 --- /dev/null +++ b/src/api/private/signup.ts @@ -0,0 +1,94 @@ +import * as express from 'express'; +import * as bcrypt from 'bcrypt'; +import rndstr from 'rndstr'; +const recaptcha = require('recaptcha-promise'); +import User from '../models/user'; +import { validateUsername } from '../models/user'; +import serialize from '../serializers/user'; + + +recaptcha.init({ + secret_key: config.recaptcha.secretKey +}); + +export default async (req: express.Request, res: express.Response) => { + // Verify recaptcha + const success = await recaptcha(req.body['g-recaptcha-response']); + + if (!success) { + res.status(400).send('recaptcha-failed'); + return; + } + + const username = req.body['username']; + const password = req.body['password']; + const name = '名無し'; + + // Validate username + if (!validateUsername(username)) { + res.sendStatus(400); + return; + } + + // Fetch exist user that same username + const usernameExist = await User + .count({ + username_lower: username.toLowerCase() + }, { + limit: 1 + }); + + // Check username already used + if (usernameExist !== 0) { + res.sendStatus(400); + return; + } + + // Generate hash of password + const salt = bcrypt.genSaltSync(14); + const hash = bcrypt.hashSync(password, salt); + + // Generate secret + const secret = rndstr('a-zA-Z0-9', 32); + + // Create account + const inserted = await User.insert({ + token: secret, + avatar_id: null, + banner_id: null, + birthday: null, + created_at: new Date(), + bio: null, + email: null, + followers_count: 0, + following_count: 0, + links: null, + location: null, + name: name, + password: hash, + posts_count: 0, + likes_count: 0, + liked_count: 0, + drive_capacity: 1073741824, // 1GB + username: username, + username_lower: username.toLowerCase() + }); + + const account = inserted.ops[0]; + + // Response + res.send(await serialize(account)); + + // Create search index + if (config.elasticsearch.enable) { + const es = require('../../db/elasticsearch'); + es.index({ + index: 'misskey', + type: 'user', + id: account._id.toString(), + body: { + username: username + } + }); + } +}; diff --git a/src/api/reply.ts b/src/api/reply.ts new file mode 100644 index 0000000000..e47fc85b9b --- /dev/null +++ b/src/api/reply.ts @@ -0,0 +1,13 @@ +import * as express from 'express'; + +export default (res: express.Response, x?: any, y?: any) => { + if (x === undefined) { + res.sendStatus(204); + } else if (typeof x === 'number') { + res.status(x).send({ + error: x === 500 ? 'INTERNAL_ERROR' : y + }); + } else { + res.send(x); + } +}; diff --git a/src/api/serializers/app.ts b/src/api/serializers/app.ts new file mode 100644 index 0000000000..23a12c977d --- /dev/null +++ b/src/api/serializers/app.ts @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +const deepcopy = require('deepcopy'); +import App from '../models/app'; +import User from '../models/user'; +import Userkey from '../models/userkey'; + +/** + * Serialize an app + * + * @param {Object} app + * @param {Object} me? + * @param {Object} options? + * @return {Promise<Object>} + */ +export default ( + app: any, + me?: any, + options?: { + includeSecret: boolean, + includeProfileImageIds: boolean + } +) => new Promise<any>(async (resolve, reject) => { + const opts = options || { + includeSecret: false, + includeProfileImageIds: false + }; + + let _app: any; + + // Populate the app if 'app' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(app)) { + _app = await App.findOne({ + _id: app + }); + } else if (typeof app === 'string') { + _app = await User.findOne({ + _id: new mongo.ObjectID(app) + }); + } else { + _app = deepcopy(app); + } + + // Me + if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (typeof me === 'string') { + me = new mongo.ObjectID(me); + } else { + me = me._id; + } + } + + // Rename _id to id + _app.id = _app._id; + delete _app._id; + + delete _app.name_id_lower; + + // Visible by only owner + if (!opts.includeSecret) { + delete _app.secret; + } + + _app.icon_url = _app.icon != null + ? `${config.drive_url}/${_app.icon}` + : `${config.drive_url}/app-default.jpg`; + + if (me) { + // 既に連携しているか + const exist = await Userkey.count({ + app_id: _app.id, + user_id: me, + }, { + limit: 1 + }); + + _app.is_authorized = exist === 1; + } + + resolve(_app); +}); diff --git a/src/api/serializers/auth-session.ts b/src/api/serializers/auth-session.ts new file mode 100644 index 0000000000..786684b4e0 --- /dev/null +++ b/src/api/serializers/auth-session.ts @@ -0,0 +1,42 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +const deepcopy = require('deepcopy'); +import serializeApp from './app'; + +/** + * Serialize an auth session + * + * @param {Object} session + * @param {Object} me? + * @return {Promise<Object>} + */ +export default ( + session: any, + me?: any +) => new Promise<any>(async (resolve, reject) => { + let _session: any; + + // TODO: Populate session if it ID + + _session = deepcopy(session); + + // Me + if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (typeof me === 'string') { + me = new mongo.ObjectID(me); + } else { + me = me._id; + } + } + + delete _session._id; + + // Populate app + _session.app = await serializeApp(_session.app_id, me); + + resolve(_session); +}); diff --git a/src/api/serializers/drive-file.ts b/src/api/serializers/drive-file.ts new file mode 100644 index 0000000000..635cf13867 --- /dev/null +++ b/src/api/serializers/drive-file.ts @@ -0,0 +1,63 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../models/drive-file'; +import serializeDriveTag from './drive-tag'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a drive file + * + * @param {Object} file + * @param {Object} options? + * @return {Promise<Object>} + */ +export default ( + file: any, + options?: { + includeTags: boolean + } +) => new Promise<Object>(async (resolve, reject) => { + const opts = options || { + includeTags: true + }; + + let _file: any; + + // Populate the file if 'file' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(file)) { + _file = await DriveFile.findOne({ + _id: file + }, { + data: false + }); + } else if (typeof file === 'string') { + _file = await DriveFile.findOne({ + _id: new mongo.ObjectID(file) + }, { + data: false + }); + } else { + _file = deepcopy(file); + } + + // Rename _id to id + _file.id = _file._id; + delete _file._id; + + delete _file.data; + + _file.url = `${config.drive_url}/${_file.id}/${encodeURIComponent(_file.name)}`; + + if (opts.includeTags && _file.tags) { + // Populate tags + _file.tags = await _file.tags.map(async (tag: any) => + await serializeDriveTag(tag) + ); + } + + resolve(_file); +}); diff --git a/src/api/serializers/drive-folder.ts b/src/api/serializers/drive-folder.ts new file mode 100644 index 0000000000..ee5a973e14 --- /dev/null +++ b/src/api/serializers/drive-folder.ts @@ -0,0 +1,52 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../models/drive-folder'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a drive folder + * + * @param {Object} folder + * @param {Object} options? + * @return {Promise<Object>} + */ +const self = ( + folder: any, + options?: { + includeParent: boolean + } +) => new Promise<Object>(async (resolve, reject) => { + const opts = options || { + includeParent: false + }; + + let _folder: any; + + // Populate the folder if 'folder' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(folder)) { + _folder = await DriveFolder.findOne({_id: folder}); + } else if (typeof folder === 'string') { + _folder = await DriveFolder.findOne({_id: new mongo.ObjectID(folder)}); + } else { + _folder = deepcopy(folder); + } + + // Rename _id to id + _folder.id = _folder._id; + delete _folder._id; + + if (opts.includeParent && _folder.parent_id) { + // Populate parent folder + _folder.parent = await self(_folder.parent_id, { + includeParent: true + }); + } + + resolve(_folder); +}); + +export default self; diff --git a/src/api/serializers/drive-tag.ts b/src/api/serializers/drive-tag.ts new file mode 100644 index 0000000000..182e9a66d4 --- /dev/null +++ b/src/api/serializers/drive-tag.ts @@ -0,0 +1,37 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveTag from '../models/drive-tag'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a drive tag + * + * @param {Object} tag + * @return {Promise<Object>} + */ +const self = ( + tag: any +) => new Promise<Object>(async (resolve, reject) => { + let _tag: any; + + // Populate the tag if 'tag' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(tag)) { + _tag = await DriveTag.findOne({_id: tag}); + } else if (typeof tag === 'string') { + _tag = await DriveTag.findOne({_id: new mongo.ObjectID(tag)}); + } else { + _tag = deepcopy(tag); + } + + // Rename _id to id + _tag.id = _tag._id; + delete _tag._id; + + resolve(_tag); +}); + +export default self; diff --git a/src/api/serializers/messaging-message.ts b/src/api/serializers/messaging-message.ts new file mode 100644 index 0000000000..0855b25d16 --- /dev/null +++ b/src/api/serializers/messaging-message.ts @@ -0,0 +1,64 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Message from '../models/messaging-message'; +import serializeUser from './user'; +import serializeDriveFile from './drive-file'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a message + * + * @param {Object} message + * @param {Object} me? + * @param {Object} options? + * @return {Promise<Object>} + */ +export default ( + message: any, + me: any, + options?: { + populateRecipient: boolean + } +) => new Promise<Object>(async (resolve, reject) => { + const opts = options || { + populateRecipient: true + }; + + let _message: any; + + // Populate the message if 'message' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(message)) { + _message = await Message.findOne({ + _id: message + }); + } else if (typeof message === 'string') { + _message = await Message.findOne({ + _id: new mongo.ObjectID(message) + }); + } else { + _message = deepcopy(message); + } + + // Rename _id to id + _message.id = _message._id; + delete _message._id; + + // Populate user + _message.user = await serializeUser(_message.user_id, me); + + if (_message.file) { + // Populate file + _message.file = await serializeDriveFile(_message.file_id); + } + + if (opts.populateRecipient) { + // Populate recipient + _message.recipient = await serializeUser(_message.recipient_id, me); + } + + resolve(_message); +}); diff --git a/src/api/serializers/notification.ts b/src/api/serializers/notification.ts new file mode 100644 index 0000000000..56769f50d0 --- /dev/null +++ b/src/api/serializers/notification.ts @@ -0,0 +1,66 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Notification from '../models/notification'; +import serializeUser from './user'; +import serializePost from './post'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a notification + * + * @param {Object} notification + * @return {Promise<Object>} + */ +export default (notification: any) => new Promise<Object>(async (resolve, reject) => { + let _notification: any; + + // Populate the notification if 'notification' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(notification)) { + _notification = await Notification.findOne({ + _id: notification + }); + } else if (typeof notification === 'string') { + _notification = await Notification.findOne({ + _id: new mongo.ObjectID(notification) + }); + } else { + _notification = deepcopy(notification); + } + + // Rename _id to id + _notification.id = _notification._id; + delete _notification._id; + + // Rename notifier_id to user_id + _notification.user_id = _notification.notifier_id; + delete _notification.notifier_id; + + const me = _notification.notifiee_id; + delete _notification.notifiee_id; + + // Populate notifier + _notification.user = await serializeUser(_notification.user_id, me); + + switch (_notification.type) { + case 'follow': + // nope + break; + case 'mention': + case 'reply': + case 'repost': + case 'quote': + case 'like': + // Populate post + _notification.post = await serializePost(_notification.post_id, me); + break; + default: + console.error(`Unknown type: ${_notification.type}`); + break; + } + + resolve(_notification); +}); diff --git a/src/api/serializers/post.ts b/src/api/serializers/post.ts new file mode 100644 index 0000000000..a17aa9035b --- /dev/null +++ b/src/api/serializers/post.ts @@ -0,0 +1,103 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../models/post'; +import Like from '../models/like'; +import serializeUser from './user'; +import serializeDriveFile from './drive-file'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a post + * + * @param {Object} post + * @param {Object} me? + * @param {Object} options? + * @return {Promise<Object>} + */ +const self = ( + post: any, + me?: any, + options?: { + serializeReplyTo: boolean, + serializeRepost: boolean, + includeIsLiked: boolean + } +) => new Promise<Object>(async (resolve, reject) => { + const opts = options || { + serializeReplyTo: true, + serializeRepost: true, + includeIsLiked: true + }; + + let _post: any; + + // Populate the post if 'post' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(post)) { + _post = await Post.findOne({ + _id: post + }); + } else if (typeof post === 'string') { + _post = await Post.findOne({ + _id: new mongo.ObjectID(post) + }); + } else { + _post = deepcopy(post); + } + + const id = _post._id; + + // Rename _id to id + _post.id = _post._id; + delete _post._id; + + delete _post.mentions; + + // Populate user + _post.user = await serializeUser(_post.user_id, me); + + if (_post.media_ids) { + // Populate media + _post.media = await Promise.all(_post.media_ids.map(async fileId => + await serializeDriveFile(fileId) + )); + } + + if (_post.reply_to_id && opts.serializeReplyTo) { + // Populate reply to post + _post.reply_to = await self(_post.reply_to_id, me, { + serializeReplyTo: false, + serializeRepost: false, + includeIsLiked: false + }); + } + + if (_post.repost_id && opts.serializeRepost) { + // Populate repost + _post.repost = await self(_post.repost_id, me, { + serializeReplyTo: _post.text == null, + serializeRepost: _post.text == null, + includeIsLiked: _post.text == null + }); + } + + // Check if it is liked + if (me && opts.includeIsLiked) { + const liked = await Like + .count({ + user_id: me._id, + post_id: id + }, { + limit: 1 + }); + + _post.is_liked = liked === 1; + } + + resolve(_post); +}); + +export default self; diff --git a/src/api/serializers/signin.ts b/src/api/serializers/signin.ts new file mode 100644 index 0000000000..d6d7a39471 --- /dev/null +++ b/src/api/serializers/signin.ts @@ -0,0 +1,25 @@ +'use strict'; + +/** + * Module dependencies + */ +const deepcopy = require('deepcopy'); + +/** + * Serialize a signin record + * + * @param {Object} record + * @return {Promise<Object>} + */ +export default ( + record: any +) => new Promise<Object>(async (resolve, reject) => { + + const _record = deepcopy(record); + + // Rename _id to id + _record.id = _record._id; + delete _record._id; + + resolve(_record); +}); diff --git a/src/api/serializers/user.ts b/src/api/serializers/user.ts new file mode 100644 index 0000000000..0585863950 --- /dev/null +++ b/src/api/serializers/user.ts @@ -0,0 +1,138 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +const deepcopy = require('deepcopy'); +import User from '../models/user'; +import Following from '../models/following'; +import getFriends from '../common/get-friends'; + +/** + * Serialize a user + * + * @param {Object} user + * @param {Object} me? + * @param {Object} options? + * @return {Promise<Object>} + */ +export default ( + user: any, + me?: any, + options?: { + detail: boolean, + includeSecrets: boolean + } +) => new Promise<any>(async (resolve, reject) => { + + const opts = Object.assign({ + detail: false, + includeSecrets: false + }, options); + + let _user: any; + + // Populate the user if 'user' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(user)) { + _user = await User.findOne({ + _id: user + }); + } else if (typeof user === 'string') { + _user = await User.findOne({ + _id: new mongo.ObjectID(user) + }); + } else { + _user = deepcopy(user); + } + + // Me + if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (typeof me === 'string') { + me = new mongo.ObjectID(me); + } else { + me = me._id; + } + } + + // Rename _id to id + _user.id = _user._id; + delete _user._id; + + // Remove private properties + delete _user.password; + delete _user.token; + delete _user.username_lower; + + // Visible via only the official client + if (!opts.includeSecrets) { + delete _user.data; + delete _user.email; + } + + _user.avatar_url = _user.avatar_id != null + ? `${config.drive_url}/${_user.avatar_id}` + : `${config.drive_url}/default-avatar.jpg`; + + _user.banner_url = _user.banner_id != null + ? `${config.drive_url}/${_user.banner_id}` + : null; + + if (!me || !me.equals(_user.id) || !opts.detail) { + delete _user.avatar_id; + delete _user.banner_id; + + delete _user.drive_capacity; + } + + if (me && !me.equals(_user.id)) { + // If the user is following + const follow = await Following.findOne({ + follower_id: me, + followee_id: _user.id, + deleted_at: { $exists: false } + }); + _user.is_following = follow !== null; + + // If the user is followed + const follow2 = await Following.findOne({ + follower_id: _user.id, + followee_id: me, + deleted_at: { $exists: false } + }); + _user.is_followed = follow2 !== null; + } + + if (me && !me.equals(_user.id) && opts.detail) { + const myFollowingIds = await getFriends(me); + + // Get following you know count + const followingYouKnowCount = await Following.count({ + followee_id: { $in: myFollowingIds }, + follower_id: _user.id, + deleted_at: { $exists: false } + }); + _user.following_you_know_count = followingYouKnowCount; + + // Get followers you know count + const followersYouKnowCount = await Following.count({ + followee_id: _user.id, + follower_id: { $in: myFollowingIds }, + deleted_at: { $exists: false } + }); + _user.followers_you_know_count = followersYouKnowCount; + } + + resolve(_user); +}); +/* +function img(url) { + return { + thumbnail: { + large: `${url}`, + medium: '', + small: '' + } + }; +} +*/ diff --git a/src/api/server.ts b/src/api/server.ts new file mode 100644 index 0000000000..78b0d0aea8 --- /dev/null +++ b/src/api/server.ts @@ -0,0 +1,52 @@ +/** + * API Server + */ + +import * as express from 'express'; +import * as bodyParser from 'body-parser'; +import * as cors from 'cors'; +import * as multer from 'multer'; + +import authenticate from './authenticate'; +import endpoints from './endpoints'; + +/** + * Init app + */ +const app = express(); + +app.disable('x-powered-by'); +app.set('etag', false); +app.use(bodyParser.urlencoded({ extended: true })); +app.use(cors({ + origin: true +})); + +/** + * Authetication + */ +/*app.post('*', async (req, res, next) => { + try { + ctx = await authenticate(req); + next(); + } catch (e) { + res.status(403).send('AUTHENTICATION_FAILED'); + } +}); +*/ +/** + * Register endpoint handlers + */ +endpoints.forEach(endpoint => + endpoint.withFile ? + app.post('/' + endpoint.name, + endpoint.withFile ? multer({ dest: 'uploads/' }).single('file') : null, + require('./api-handler').default.bind(null, endpoint)) : + app.post('/' + endpoint.name, + require('./api-handler').default.bind(null, endpoint)) +); + +app.post('/signup', require('./private/signup').default); +app.post('/signin', require('./private/signin').default); + +module.exports = app; diff --git a/src/api/stream/home.ts b/src/api/stream/home.ts new file mode 100644 index 0000000000..975bea4c60 --- /dev/null +++ b/src/api/stream/home.ts @@ -0,0 +1,10 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; + +export default function homeStream(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + // Subscribe Home stream channel + subscriber.subscribe(`misskey:user-stream:${user._id}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/api/stream/messaging.ts b/src/api/stream/messaging.ts new file mode 100644 index 0000000000..4ec139b82b --- /dev/null +++ b/src/api/stream/messaging.ts @@ -0,0 +1,60 @@ +import * as mongodb from 'mongodb'; +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import Message from '../models/messaging-message'; +import { publishMessagingStream } from '../event'; + +export default function messagingStream(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + const otherparty = request.resourceURL.query.otherparty; + + // Subscribe messaging stream + subscriber.subscribe(`misskey:messaging-stream:${user._id}-${otherparty}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); + + connection.on('message', async (data) => { + const msg = JSON.parse(data.utf8Data); + + switch (msg.type) { + case 'read': + if (!msg.id) { + return; + } + + const id = new mongodb.ObjectID(msg.id); + + // Fetch message + // SELECT _id, user_id, is_read + const message = await Message.findOne({ + _id: id, + recipient_id: user._id + }, { + fields: { + _id: true, + user_id: true, + is_read: true + } + }); + + if (message == null) { + return; + } + + if (message.is_read) { + return; + } + + // Update documents + await Message.update({ + _id: id + }, { + $set: { is_read: true } + }); + + // Publish event + publishMessagingStream(message.user_id, user._id, 'read', id.toString()); + break; + } + }); +} diff --git a/src/api/streaming.ts b/src/api/streaming.ts new file mode 100644 index 0000000000..38068d1e3d --- /dev/null +++ b/src/api/streaming.ts @@ -0,0 +1,69 @@ +import * as http from 'http'; +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import User from './models/user'; + +import homeStream from './stream/home'; +import messagingStream from './stream/messaging'; + +module.exports = (server: http.Server) => { + /** + * Init websocket server + */ + const ws = new websocket.server({ + httpServer: server + }); + + ws.on('request', async (request) => { + const connection = request.accept(); + + const user = await authenticate(connection); + + // Connect to Redis + const subscriber = redis.createClient( + config.redis.port, config.redis.host); + + connection.on('close', () => { + subscriber.unsubscribe(); + subscriber.quit(); + }); + + const channel = + request.resourceURL.pathname === '/' ? homeStream : + request.resourceURL.pathname === '/messaging' ? messagingStream : + null; + + if (channel !== null) { + channel(request, connection, subscriber, user); + } else { + connection.close(); + } + }); +}; + +function authenticate(connection: websocket.connection): Promise<any> { + return new Promise((resolve, reject) => { + // Listen first message + connection.once('message', async (data) => { + const msg = JSON.parse(data.utf8Data); + + // Fetch user + // SELECT _id + const user = await User + .findOne({ + token: msg.i + }, { + _id: true + }); + + if (user === null) { + connection.close(); + return; + } + + connection.send('authenticated'); + + resolve(user); + }); + }); +} diff --git a/src/common/text/elements/bold.js b/src/common/text/elements/bold.js new file mode 100644 index 0000000000..41a01399dd --- /dev/null +++ b/src/common/text/elements/bold.js @@ -0,0 +1,17 @@ +/** + * Bold + */ + +const regexp = /\*\*(.+?)\*\*/; + +module.exports = { + test: x => new RegExp('^' + regexp.source).test(x), + parse: text => { + const bold = text.match(new RegExp('^' + regexp.source))[0]; + return { + type: 'bold', + content: bold, + bold: bold.substr(2, bold.length - 4) + }; + } +}; diff --git a/src/common/text/elements/hashtag.js b/src/common/text/elements/hashtag.js new file mode 100644 index 0000000000..f04b782007 --- /dev/null +++ b/src/common/text/elements/hashtag.js @@ -0,0 +1,23 @@ +/** + * Hashtag + */ + +module.exports = { + test: (x, i) => + /^\s#[^\s]+/.test(x) || (i == 0 && /^#[^\s]+/.test(x)) + , + parse: text => { + const isHead = text[0] == '#'; + const hashtag = text.match(/^\s?#[^\s]+/)[0]; + const res = !isHead ? [{ + type: 'text', + content: text[0] + }] : []; + res.push({ + type: 'hashtag', + content: isHead ? hashtag : hashtag.substr(1), + hashtag: isHead ? hashtag.substr(1) : hashtag.substr(2) + }); + return res; + } +}; diff --git a/src/common/text/elements/mention.js b/src/common/text/elements/mention.js new file mode 100644 index 0000000000..b58786fd1e --- /dev/null +++ b/src/common/text/elements/mention.js @@ -0,0 +1,17 @@ +/** + * Mention + */ + +const regexp = /@[a-zA-Z0-9\-]+/; + +module.exports = { + test: x => new RegExp('^' + regexp.source).test(x), + parse: text => { + const mention = text.match(new RegExp('^' + regexp.source))[0]; + return { + type: 'mention', + content: mention, + username: mention.substr(1) + }; + } +}; diff --git a/src/common/text/elements/url.js b/src/common/text/elements/url.js new file mode 100644 index 0000000000..d02aef0800 --- /dev/null +++ b/src/common/text/elements/url.js @@ -0,0 +1,16 @@ +/** + * URL + */ + +const regexp = /https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.=\+\-]+/; + +module.exports = { + test: x => new RegExp('^' + regexp.source).test(x), + parse: text => { + const link = text.match(new RegExp('^' + regexp.source))[0]; + return { + type: 'link', + content: link + }; + } +}; diff --git a/src/common/text/index.js b/src/common/text/index.js new file mode 100644 index 0000000000..973e7c5236 --- /dev/null +++ b/src/common/text/index.js @@ -0,0 +1,67 @@ +/** + * Misskey Text Analyzer + */ + +const elements = [ + require('./elements/bold'), + require('./elements/url'), + require('./elements/mention'), + require('./elements/hashtag') +]; + +function analyze(source) { + + if (source == '') { + return null; + } + + const tokens = []; + + function push(token) { + if (token != null) { + tokens.push(token); + source = source.substr(token.content.length); + } + } + + let i = 0; + + // パース + while (source != '') { + const parsed = elements.some(el => { + if (el.test(source, i)) { + let tokens = el.parse(source); + if (!Array.isArray(tokens)) { + tokens = [tokens]; + } + tokens.forEach(push); + return true; + } + }); + + if (!parsed) { + push({ + type: 'text', + content: source[0] + }); + } + + i++; + } + + // テキストを纏める + tokens[0] = [tokens[0]]; + return tokens.reduce((a, b) => { + if (a[a.length - 1].type == 'text' && b.type == 'text') { + const tail = a.pop(); + return a.concat({ + type: 'text', + content: tail.content + b.content + }); + } else { + return a.concat(b); + } + }); +} + +module.exports = analyze; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000000..2369b709fc --- /dev/null +++ b/src/config.ts @@ -0,0 +1,95 @@ +/** + * Config loader + */ + +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; + +/** + * ユーザーが設定する必要のある情報 + */ +interface ISource { + maintainer: string; + url: string; + secondary_url: string; + port: number; + https: { + enable: boolean; + key: string; + cert: string; + ca: string; + }; + mongodb: { + host: string; + port: number; + db: string; + user_id: string; + pass: string; + }; + redis: { + host: string; + port: number; + pass: string; + }; + elasticsearch: { + enable: boolean; + host: string; + port: number; + pass: string; + }; + recaptcha: { + siteKey: string; + secretKey: string; + }; +} + +/** + * Misskeyが自動的に(ユーザーが設定した情報から推論して)設定する情報 + */ +interface Mixin { + themeColor: string; + themeColorForeground: string; + host: string; + scheme: string; + secondary_host: string; + secondary_scheme: string; + api_url: string; + auth_url: string; + dev_url: string; + drive_url: string; + proxy_url: string; +} + +export type IConfig = ISource & Mixin; + +/** + * 設定を取得します + * @param {string} path 設定ファイルのパス + * @return {IConfig} 設定 + */ +export default (path: string) => { + const config = yaml.safeLoad(fs.readFileSync(path, 'utf8')) as ISource; + + const mixin: Mixin = {} as Mixin; + + config.url = normalizeUrl(config.url); + config.secondary_url = normalizeUrl(config.secondary_url); + + mixin.themeColor = '#f76d6c'; + mixin.themeColorForeground = '#fff'; + mixin.host = config.url.substr(config.url.indexOf('://') + 3); + mixin.scheme = config.url.substr(0, config.url.indexOf('://')); + mixin.secondary_host = config.secondary_url.substr(config.secondary_url.indexOf('://') + 3); + mixin.secondary_scheme = config.secondary_url.substr(0, config.secondary_url.indexOf('://')); + mixin.api_url = `${mixin.scheme}://api.${mixin.host}`; + mixin.auth_url = `${mixin.scheme}://auth.${mixin.host}`; + mixin.dev_url = `${mixin.scheme}://dev.${mixin.host}`; + mixin.drive_url = `${mixin.secondary_scheme}://file.${mixin.secondary_host}`; + mixin.proxy_url = `${mixin.secondary_scheme}://proxy.${mixin.secondary_host}`; + + return Object.assign(config || {}, mixin) as IConfig; +}; + +function normalizeUrl(url: string): string { + return url[url.length - 1] === '/' ? url.substr(0, url.length - 1) : url; +} diff --git a/src/db/elasticsearch.ts b/src/db/elasticsearch.ts new file mode 100644 index 0000000000..27040d102a --- /dev/null +++ b/src/db/elasticsearch.ts @@ -0,0 +1,21 @@ +import * as elasticsearch from 'elasticsearch'; + +// Init ElasticSearch connection +const client = new elasticsearch.Client({ + host: `${config.elasticsearch.host}:${config.elasticsearch.port}` +}); + +// Send a HEAD request +client.ping({ + // Ping usually has a 3000ms timeout + requestTimeout: Infinity, + + // Undocumented params are appended to the query string + hello: 'elasticsearch!' +}, error => { + if (error) { + console.error('elasticsearch is down!'); + } +}); + +export default client; diff --git a/src/db/mongodb.ts b/src/db/mongodb.ts new file mode 100644 index 0000000000..e2b2479b49 --- /dev/null +++ b/src/db/mongodb.ts @@ -0,0 +1,8 @@ +import * as mongodb from 'mongodb'; + +export default async function(): Promise<mongodb.Db> { + const uri = config.mongodb.user && config.mongodb.pass + ? `mongodb://${config.mongodb.user}:${config.mongodb.pass}@${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}` + : `mongodb://${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`; + return await mongodb.MongoClient.connect(uri); +}; diff --git a/src/db/redis.ts b/src/db/redis.ts new file mode 100644 index 0000000000..ba4d647811 --- /dev/null +++ b/src/db/redis.ts @@ -0,0 +1,9 @@ +import * as redis from 'redis'; + +export default redis.createClient( + config.redis.port, + config.redis.host, + { + auth_pass: config.redis.pass + } +); diff --git a/src/file/resources/avatar.jpg b/src/file/resources/avatar.jpg Binary files differnew file mode 100644 index 0000000000..3c803f568e --- /dev/null +++ b/src/file/resources/avatar.jpg diff --git a/src/file/resources/bad-egg.png b/src/file/resources/bad-egg.png Binary files differnew file mode 100644 index 0000000000..a7c5930bd4 --- /dev/null +++ b/src/file/resources/bad-egg.png diff --git a/src/file/resources/dummy.png b/src/file/resources/dummy.png Binary files differnew file mode 100644 index 0000000000..39332b0c1b --- /dev/null +++ b/src/file/resources/dummy.png diff --git a/src/file/server.ts b/src/file/server.ts new file mode 100644 index 0000000000..0f269c4424 --- /dev/null +++ b/src/file/server.ts @@ -0,0 +1,115 @@ +/** + * File Server + */ + +import * as fs from 'fs'; +import * as express from 'express'; +import * as bodyParser from 'body-parser'; +import * as cors from 'cors'; +import * as mongodb from 'mongodb'; +import * as gm from 'gm'; + +import File from '../api/models/drive-file'; + +/** + * Init app + */ +const app = express(); + +app.disable('x-powered-by'); +app.locals.cache = true; +app.use(bodyParser.urlencoded({ extended: true })); +app.use(cors()); + +/** + * Statics + */ +app.use('/resources', express.static(__dirname + '/resources', { + maxAge: 1000 * 60 * 60 * 24 * 365 // 一年 +})); + +app.get('/', (req, res) => { + res.send('yee haw'); +}); + +app.get('/default-avatar.jpg', (req, res) => { + const file = fs.readFileSync(__dirname + '/resources/avatar.jpg'); + send(file, 'image/jpeg', req, res); +}); + +app.get('/app-default.jpg', (req, res) => { + const file = fs.readFileSync(__dirname + '/resources/dummy.png'); + send(file, 'image/png', req, res); +}); + +async function raw(data: Buffer, type: string, download: boolean, res: express.Response): Promise<any> { + res.header('Content-Type', type); + + if (download) { + res.header('Content-Disposition', 'attachment'); + } + + res.send(data); +} + +async function thumbnail(data: Buffer, type: string, resize: number, res: express.Response): Promise<any> { + if (!/^image\/.*$/.test(type)) { + data = fs.readFileSync(__dirname + '/resources/dummy.png'); + } + + let g = gm(data); + + if (resize) { + g = g.resize(resize, resize); + } + + g + .compress('jpeg') + .quality(80) + .toBuffer('jpeg', (err, img) => { + if (err !== undefined && err !== null) { + console.error(err); + res.sendStatus(500); + return; + } + + res.header('Content-Type', 'image/jpeg'); + res.send(img); + }); +} + +function send(data: Buffer, type: string, req: express.Request, res: express.Response): void { + if (req.query.thumbnail !== undefined) { + thumbnail(data, type, req.query.size, res); + } else { + raw(data, type, req.query.download !== undefined, res); + } +} + +/** + * Routing + */ + +app.get('/:id', async (req, res): Promise<void> => { + const file = await File.findOne({_id: new mongodb.ObjectID(req.params.id)}); + + if (file === null) { + res.status(404).sendFile(__dirname + '/resources/dummy.png'); + return; + } + + send(file.data.buffer, file.type, req, res); +}); + +app.get('/:id/:name', async (req, res): Promise<void> => { + const file = await File.findOne({_id: new mongodb.ObjectID(req.params.id)}); + + if (file === null) { + res.status(404).sendFile(__dirname + '/resources/dummy.png'); + return; + } + + send(file.data.buffer, file.type, req, res); +}); + +module.exports = app; diff --git a/src/himasaku/resources/himasaku.png b/src/himasaku/resources/himasaku.png Binary files differnew file mode 100644 index 0000000000..25cd91e954 --- /dev/null +++ b/src/himasaku/resources/himasaku.png diff --git a/src/himasaku/resources/index.html b/src/himasaku/resources/index.html new file mode 100644 index 0000000000..f9e45d7a74 --- /dev/null +++ b/src/himasaku/resources/index.html @@ -0,0 +1,35 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no"> + <meta name="description" content="ひまさく"> + <meta name="keywords" content="ひまさく, さくひま, 向日葵, 櫻子"> + <title>ひまさく</title> + <style> + html { + height: 100%; + font-size: 0; + } + + body { + margin: 0; + height: 100%; + overflow: hidden; + } + + img { + display: block; + position: absolute; + max-width: 100%; + margin: auto; + top: 0; right: 0; bottom: 0; left: 0; + pointer-events: none; + user-select: none; + } + </style> + </head> + <body> + <img src="/himasaku.png" alt="ひまさく"> + </body> +</html> diff --git a/src/himasaku/server.ts b/src/himasaku/server.ts new file mode 100644 index 0000000000..e4fb0ef177 --- /dev/null +++ b/src/himasaku/server.ts @@ -0,0 +1,23 @@ +/** + * Himasaku Server + */ + +import * as express from 'express'; + +/** + * Init app + */ +const app = express(); + +app.disable('x-powered-by'); +app.locals.cache = true; + +app.get('/himasaku.png', (req, res) => { + res.sendFile(__dirname + '/resources/himasaku.png'); +}); + +app.get('*', (req, res) => { + res.sendFile(__dirname + '/resources/index.html'); +}); + +module.exports = app; diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000000..0af8362122 --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,11 @@ +import * as mongodb from 'mongodb'; +import { IConfig } from './config'; + +declare var config: IConfig; + +declare module NodeJS { + interface Global { + config: IConfig; + db: mongodb.Db; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000000..b80b2da576 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,223 @@ +/** + * Misskey Core Entory Point! + */ + +Error.stackTraceLimit = Infinity; + +/** + * Module dependencies + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as cluster from 'cluster'; +const prominence = require('prominence'); +import { logInfo, logDone, logWarn, logFailed } from 'log-cool'; +import * as chalk from 'chalk'; +const git = require('git-last-commit'); +const portUsed = require('tcp-port-used'); +import ProgressBar from './utils/cli/progressbar'; +import initdb from './db/mongodb'; +import checkDependencies from './utils/check-dependencies'; + +// Init babel +require('babel-core/register'); +require('babel-polyfill'); + +const env = process.env.NODE_ENV; +const IS_PRODUCTION = env === 'production'; +const IS_DEBUG = !IS_PRODUCTION; + +global.config = require('./config').default(`${__dirname}/../.config/config.yml`); + +/** + * Initialize state + */ +enum State { + success, + warn, + failed +} + +// Set process title +process.title = 'Misskey'; + +// Start app +main(); + +/** + * Init proccess + */ +function main(): void { + // Master + if (cluster.isMaster) { + master(); + } else { // Workers + worker(); + } +} + +/** + * Init master proccess + */ +async function master(): Promise<void> { + let state: State; + + try { + // initialize app + state = await init(); + } catch (e) { + console.error(e); + process.exit(1); + } + + const res = (t: string, c: string) => + console.log(chalk.bold(`--> ${(chalk as any)[c](t)}\n`)); + + switch (state) { + case State.failed: + res('Fatal error occurred :(', 'red'); + process.exit(); + return; + case State.warn: + res('Some problem(s) :|', 'yellow'); + break; + case State.success: + res('OK :)', 'green'); + break; + } + + // Spawn workers + spawn(() => { + console.log(chalk.bold.green(`\nMisskey Core is now running. [port:${config.port}]`)); + + // Listen new workers + cluster.on('fork', worker => { + console.log(`Process forked: [${worker.id}]`); + }); + + // Listen online workers + cluster.on('online', worker => { + console.log(`Process is now online: [${worker.id}]`); + }); + + // Listen for dying workers + cluster.on('exit', worker => { + // Replace the dead worker, + // we're not sentimental + console.log(chalk.red(`[${worker.id}] died :(`)); + cluster.fork(); + }); + }); +} + +/** + * Init worker proccess + */ +function worker(): void { + // Register config + global.config = config; + + // Init mongo + initdb().then(db => { + global.db = db; + + // start server + require('./server'); + }, err => { + console.error(err); + process.exit(0); + }); +} + +/** + * Init app + */ +async function init(): Promise<State> { + console.log('Welcome to Misskey!\n'); + + console.log(chalk.bold('Misskey Core <aoi>')); + + let warn = false; + + // Get commit info + try { + const commit = await prominence(git).getLastCommit(); + console.log(`commit: ${commit.shortHash} ${commit.author.name} <${commit.author.email}>`); + console.log(` ${new Date(parseInt(commit.committedOn, 10) * 1000)}`); + } catch (e) { + // noop + } + + console.log('\nInitializing...\n'); + + if (IS_DEBUG) { + logWarn('It is not in the Production mode. Do not use in the Production environment.'); + } + + logInfo(`environment: ${env}`); + + // Get machine info + const totalmem = (os.totalmem() / 1024 / 1024 / 1024).toFixed(1); + const freemem = (os.freemem() / 1024 / 1024 / 1024).toFixed(1); + logInfo(`MACHINE: ${os.hostname()}`); + logInfo(`MACHINE: CPU: ${os.cpus().length}core`); + logInfo(`MACHINE: MEM: ${totalmem}GB (available: ${freemem}GB)`); + + if (!fs.existsSync(`${__dirname}/../.config/config.yml`)) { + logFailed('Configuration not found'); + return State.failed; + } + + logDone('Success to load configuration'); + logInfo(`maintainer: ${config.maintainer}`); + + checkDependencies(); + + // Check if a port is being used + if (await portUsed.check(config.port)) { + logFailed(`Port: ${config.port} is already used!`); + return State.failed; + } + + // Try to connect to MongoDB + try { + const db = await initdb(config); + logDone('Success to connect to MongoDB'); + db.close(); + } catch (e) { + logFailed(`MongoDB: ${e}`); + return State.failed; + } + + return warn ? State.warn : State.success; +} + +/** + * Spawn workers + */ +function spawn(callback: any): void { + // Count the machine's CPUs + const cpuCount = os.cpus().length; + + const progress = new ProgressBar(cpuCount, 'Starting workers'); + + // Create a worker for each CPU + for (let i = 0; i < cpuCount; i++) { + const worker = cluster.fork(); + worker.on('message', message => { + if (message === 'ready') { + progress.increment(); + } + }); + } + + // On all workers started + progress.on('complete', () => { + callback(); + }); +} + +// Dying away... +process.on('exit', () => { + console.log('Bye.'); +}); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000000..a327504b26 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,49 @@ +/** + * Core Server + */ + +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; + +import * as express from 'express'; +const vhost = require('vhost'); + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); + +/** + * Register modules + */ +app.use(vhost(`api.${config.host}`, require('./api/server'))); +app.use(vhost(config.secondary_host, require('./himasaku/server'))); +app.use(vhost(`file.${config.secondary_host}`, require('./file/server'))); +app.use(vhost(`proxy.${config.secondary_host}`, require('./web/service/proxy/server'))); +app.use(require('./web/server')); + +/** + * Create server + */ +const server = config.https.enable ? + https.createServer({ + key: fs.readFileSync(config.https.key), + cert: fs.readFileSync(config.https.cert), + ca: fs.readFileSync(config.https.ca) + }, app) : + http.createServer(app); + +/** + * Steaming + */ +require('./api/streaming')(server); + +/** + * Server listen + */ +server.listen(config.port, () => { + // Send a 'ready' message to parent process + process.send('ready'); +}); diff --git a/src/utils/check-dependencies.ts b/src/utils/check-dependencies.ts new file mode 100644 index 0000000000..7bcb87a68f --- /dev/null +++ b/src/utils/check-dependencies.ts @@ -0,0 +1,23 @@ +import {logInfo, logDone, logWarn} from 'log-cool'; +import {exec} from 'shelljs'; + +export default function(): void { + checkDependency('Node.js', 'node -v', x => x.match(/^v(.*)\r?\n$/)[1]); + checkDependency('npm', 'npm -v', x => x.match(/^(.*)\r?\n$/)[1]); + checkDependency('MongoDB', 'mongo --version', x => x.match(/^MongoDB shell version: (.*)\r?\n$/)[1]); + checkDependency('Redis', 'redis-server --version', x => x.match(/v=([0-9\.]*)/)[1]); + logDone('Successfully checked external dependencies'); +} + +function checkDependency(serviceName: string, command: string, transform: (x: string) => string): void { + const code = { + success: 0, + notFound: 127 + }; + const x = exec(command, { silent: true }) as any; + if (x.code === code.success) { + logInfo(`DEPS: ${serviceName} ${transform(x.stdout)}`); + } else if (x.code === code.notFound) { + logWarn(`Unable to find ${serviceName}`); + } +} diff --git a/src/utils/cli/indicator.ts b/src/utils/cli/indicator.ts new file mode 100644 index 0000000000..3e23f9b274 --- /dev/null +++ b/src/utils/cli/indicator.ts @@ -0,0 +1,35 @@ +import * as readline from 'readline'; + +/** + * Indicator + */ +export default class { + private clock: NodeJS.Timer; + + constructor(text: string) { + let i = 0; // Dots counter + + draw(); + this.clock = setInterval(draw, 300); + + function draw(): void { + cll(); + i = (i + 1) % 4; + const dots = new Array(i + 1).join('.'); + process.stdout.write(text + dots); // Write text + } + } + + public end(): void { + clearInterval(this.clock); + cll(); + } +} + +/** + * Clear current line + */ +function cll(): void { + readline.clearLine(process.stdout, 0); // Clear current text + readline.cursorTo(process.stdout, 0, null); // Move cursor to the head of line +} diff --git a/src/utils/cli/progressbar.ts b/src/utils/cli/progressbar.ts new file mode 100644 index 0000000000..19852b3ea3 --- /dev/null +++ b/src/utils/cli/progressbar.ts @@ -0,0 +1,87 @@ +import * as ev from 'events'; +import * as readline from 'readline'; +import * as chalk from 'chalk'; + +/** + * Progress bar + */ +class ProgressBar extends ev.EventEmitter { + public max: number; + public value: number; + public text: string; + private indicator: number; + + constructor(max: number, text: string = null) { + super(); + this.max = max; + this.value = 0; + this.text = text; + this.indicator = 0; + this.draw(); + + const iclock = setInterval(() => { + this.indicator = (this.indicator + 1) % 4; + this.draw(); + }, 200); + + this.on('complete', () => { + clearInterval(iclock); + }); + } + + public increment(): void { + this.value++; + this.draw(); + + // Check if it is fulfilled + if (this.value === this.max) { + this.indicator = null; + + cll(); + process.stdout.write(`${this.render()} -> ${chalk.bold('Complete')}\n`); + + this.emit('complete'); + } + } + + public draw(): void { + const str = this.render(); + cll(); + process.stdout.write(str); + } + + private render(): string { + const width = 30; + const t = this.text ? this.text + ' ' : ''; + + const v = Math.floor((this.value / this.max) * width); + const vs = new Array(v + 1).join('*'); + + const p = width - v; + const ps = new Array(p + 1).join(' '); + + const percentage = Math.floor((this.value / this.max) * 100); + const percentages = chalk.gray(`(${percentage}%)`); + + let i: string; + switch (this.indicator) { + case 0: i = '-'; break; + case 1: i = '\\'; break; + case 2: i = '|'; break; + case 3: i = '/'; break; + case null: i = '+'; break; + } + + return `${i} ${t}[${vs}${ps}] ${this.value}/${this.max} ${percentages}`; + } +} + +export default ProgressBar; + +/** + * Clear current line + */ +function cll(): void { + readline.clearLine(process.stdout, 0); // Clear current text + readline.cursorTo(process.stdout, 0, null); // Move cursor to the head of line +} diff --git a/src/web/about/base.pug b/src/web/about/base.pug new file mode 100644 index 0000000000..a026c03f28 --- /dev/null +++ b/src/web/about/base.pug @@ -0,0 +1,39 @@ +doctype html + +html(lang='ja', dir='ltr') + + head + meta(charset='utf-8') + meta(name='application-name', content='Misskey') + meta(name='theme-color', content='#f76d6c') + meta(name='referrer', content='origin') + meta(name='viewport', content='width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no') + link(rel='stylesheet', href='/resources/style.css') + title + block title + + body + nav + ul + li + p API + ul + li: a(href='/api/getting-started') Getting Started + li + p Entities + ul + li: a(href='/api/entities/post') Post + li: a(href='/api/entities/user') User + li: a(href='/api/library') ライブラリ + li: a(href='/license') ライセンス + + main + article + block content + footer + p.contribution + | 間違いを見つけた、またはドキュメントに貢献したいですか? + a(href='https://github.com/syuilo/misskey/blob/master/src/web/about/pages/' + path + '.pug', target='_blank') Github 上でこのページを編集する + | か、 + a(href='https://github.com/syuilo/misskey/fork', target='_blank') Github からこのサイトを Fork してプルリクエストしましょう! + p.copyright (c) syuilo 2016 diff --git a/src/web/about/pages/api/entities/post.pug b/src/web/about/pages/api/entities/post.pug new file mode 100644 index 0000000000..ad53be9540 --- /dev/null +++ b/src/web/about/pages/api/entities/post.pug @@ -0,0 +1,149 @@ +extend ../../../base + +block title + | Entity: Post + +block content + h1 Post + p 投稿を表します。 + + section + h2 Properties + table.entity + thead: tr + td Name + td Type + td Description + tbody + tr.nullable.optional + td app + td: a(href='./app', target='_blank') App + td 投稿したアプリ + tr.nullable + td app_id + td ID + td 投稿したアプリのID + tr + td created_at + td Date + td 投稿日時 + tr + td id + td ID + td 投稿ID + tr.optional + td is_liked + td Boolean + td いいね したかどうか + tr + td likes_count + td Number + td いいね数 + tr.nullable.optional + td media_ids + td ID[] + td 添付されたメディアのIDの配列 + tr.nullable.optional + td media + td: a(href='./drive-file', target='_blank') DriveFile[] + td 添付されたメディアの配列 + tr + td replies_count + td Number + td 返信数 + tr.optional + td reply_to + td: a(href='./post', target='_blank') Post + td 返信先の投稿 + tr.nullable + td reply_to_id + td ID + td 返信先の投稿のID + tr.optional + td repost + td: a(href='./post', target='_blank') Post + td Repostした投稿 + tr + td repost_count + td Number + td Repostされた数 + tr.nullable + td repost_id + td ID + td Repostした投稿のID + tr.nullable + td text + td String + td 本文 + tr.optional + td user + td: a(href='./user', target='_blank') User + td 投稿者 + tr + td user_id + td ID + td 投稿者のID + + section + h2 Example + pre: code. + { + "created_at": "2016-12-10T00:28:50.114Z", + "media_ids": null, + "reply_to_id": "584a16b15860fc52320137e3", + "repost_id": null, + "text": "小日向美穂だぞ!", + "user_id": "5848bf7764e572683f4402f8", + "app_id": null, + "likes_count": 1, + "replies_count": 1, + "id": "584b4c42d8e5186f8f755d0c", + "user": { + "birthday": null, + "created_at": "2016-12-08T02:03:35.332Z", + "bio": "女が嫌いです、女性は好きです", + "followers_count": 11, + "following_count": 11, + "links": null, + "location": "", + "name": "女が嫌い", + "posts_count": 26, + "likes_count": 2, + "liked_count": 20, + "username": "onnnagakirai", + "id": "5848bf7764e572683f4402f8", + "avatar_url": "https://file.himasaku.net/5848c0ec64e572683f4402fc", + "banner_url": "https://file.himasaku.net/5848c12864e572683f4402fd", + "is_following": true, + "is_followed": true + }, + "reply_to": { + "created_at": "2016-12-09T02:28:01.563Z", + "media_ids": null, + "reply_to_id": "5849d35e547e4249be329884", + "repost_id": null, + "text": "アイコン小日向美穂?", + "user_id": "57d01a501fdf2d07be417afe", + "app_id": null, + "replies_count": 1, + "id": "584a16b15860fc52320137e3", + "user": { + "birthday": null, + "created_at": "2016-09-07T13:46:56.605Z", + "bio": "どうすれば君だけのために生きていけるの", + "followers_count": 51, + "following_count": 97, + "links": null, + "location": "川崎", + "name": "きな子", + "posts_count": 4813, + "username": "syuilo", + "likes_count": 3141, + "liked_count": 750, + "id": "57d01a501fdf2d07be417afe", + "avatar_url": "https://file.himasaku.net/583ddc6e64df272771f74c1a", + "banner_url": "https://file.himasaku.net/584bfc82d8e5186f8f755ec5" + } + }, + "is_liked": true + } diff --git a/src/web/about/pages/api/entities/user.pug b/src/web/about/pages/api/entities/user.pug new file mode 100644 index 0000000000..eef973fd63 --- /dev/null +++ b/src/web/about/pages/api/entities/user.pug @@ -0,0 +1,118 @@ +extend ../../../base + +block title + | Entity: User + +block content + h1 User + p ユーザーを表します。 + + section + h2 Properties + table.entity + thead: tr + td Name + td Type + td Description + tbody + tr.nullable.optional + td avatar_id + td ID + td アバターに設定しているドライブのファイルのID + tr.nullable + td avatar_url + td String + td アバターURL + tr.nullable.optional + td banner_id + td ID + td バナーに設定しているドライブのファイルのID + tr.nullable + td banner_url + td String + td バナーURL + tr.nullable + td bio + td String + td プロフィール + tr.nullable + td birthday + td String + td 誕生日 + tr + td created_at + td Date + td アカウント作成日時 + tr.optional + td drive_capacity + td Number + td ドライブの最大容量(byte単位) + tr + td followers_count + td Number + td フォロワー数 + tr + td following_count + td Number + td フォロー数 + tr + td id + td ID + td ユーザーID + tr.optional + td is_followed + td Boolean + td フォローされているか + tr.optional + td is_following + td Boolean + td フォローしているか + tr + td liked_count + td Number + td 投稿にいいねされた数 + tr + td likes_count + td Number + td 投稿にいいねした数 + tr.nullable + td location + td String + td 場所 + tr + td name + td String + td ニックネーム + tr + td posts_count + td Number + td 投稿数 + tr + td username + td String + td ユーザー名 + + section + h2 Example + pre: code. + { + "avatar_id": "583ddc6e64df272771f74c1a", + "avatar_url": "https://file.himasaku.net/583ddc6e64df272771f74c1a", + "banner_id": "584bfc82d8e5186f8f755ec5", + "banner_url": "https://file.himasaku.net/584bfc82d8e5186f8f755ec5", + "bio": "どうすれば君だけのために生きていけるの", + "birthday": null, + "created_at": "2016-09-07T13:46:56.605Z", + "drive_capacity": 1073741824, + "email": null, + "followers_count": 51, + "following_count": 97, + "id": "57d01a501fdf2d07be417afe", + "liked_count": 750, + "likes_count": 3130, + "links": null, + "location": "川崎", + "name": "きな子", + "posts_count": 4811, + "username": "syuilo" + } diff --git a/src/web/about/pages/api/getting-started.pug b/src/web/about/pages/api/getting-started.pug new file mode 100644 index 0000000000..974964e3eb --- /dev/null +++ b/src/web/about/pages/api/getting-started.pug @@ -0,0 +1,74 @@ +extend ../../base + +block title + | Getting Started + +block content + h1 Getting Started + + p MisskeyはREST APIやStreaming APIを提供しており、プログラムからMisskeyの全ての機能を利用することができます。 + p それらのAPIを利用するには、まずAPIを利用したいアカウントのアクセストークンを取得する必要があります: + + section + h2 自分のアクセストークンを取得したい場合 + p 自分自身のアクセストークンは、設定 > API で確認できます。 + p.tip + | アカウントを乗っ取られてしまう可能性があるため、トークンは第三者に教えないでください(アプリなどにも入力しないでください)。 + br + | 万が一トークンが漏れたりその可能性がある場合は トークンを再生成できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します) + + section + h2 他人のアクセストークンを取得する + p + | 不特定多数のユーザーからAPIを利用したい場合、アプリケーションを作成します。 + br + | アプリケーションを作成すると、ユーザーが連携を許可した時に、そのユーザーのアクセストークンを取得することができます。 + p アプリケーションを作成しアクセストークンを取得するまでの流れを見ていきます。 + + section + h3 アプリケーションを作成する + p まずはあなたのアプリケーションを作成しましょう。 + p + | <a href=#{config.dev_url} target="_blank">デベロッパーセンター</a>にアクセスし、アプリ > アプリ作成 に進みます。 + br + | 次に、フォームに必要事項を記入します: + dl + dt アプリケーション名 + dd あなたのアプリケーションの名前。 + dt Named ID + dd アプリを識別する/a-z-/で構成されたID。 + dt アプリの概要 + dd アプリの簡単な説明を入力してください。 + dt コールバックURL + dd あなたのアプリケーションがWebアプリケーションである場合、ユーザーが後述するフォームで認証を終えた際にリダイレクトするURLを設定できます。 + dt 権限 + dd アプリケーションが要求する権限。ここで要求した機能だけがAPIからアクセスできます。 + p.tip + | 権限はアプリ作成後も変更できますが、新たな権限を付与する場合、その時点で関連付けられているユーザーはすべて無効になります。 + p + | アプリケーションを作成すると、作ったアプリの管理ページに進みます。 + br + | アプリのシークレットキー(App Secret)が表示されていますので、メモしておいてください。 + p.tip + | アプリに成りすまされる可能性があるため、極力このシークレットキーは公開しないようにしてください。 + + section + h3 ユーザーに認証させる + p あなたのアプリを使ってもらうには、ユーザーにアカウントへアクセスすることを許可してもらい、Misskeyにそのユーザーのアクセストークンを発行してもらう必要があります。 + p 認証セッションを開始するには、<code>#{config.api_url}/auth/session/generate</code>へパラメータに<code>app_secret</code>としてApp Secretを含めたリクエストを送信します。 + p + | そうすると、レスポンスとして認証セッションのトークンや認証フォームのURLが取得できます。 + br + | この認証フォームのURLをブラウザで表示し、ユーザーにフォームを表示してください。 + section + h4 あなたのアプリがコールバックURLを設定している場合 + p ユーザーがアプリの連携を許可すると設定しているコールバックURLに<code>token</code>という名前でセッションのトークンが含まれたクエリを付けてリダイレクトします。 + section + h4 あなたのアプリがコールバックURLを設定していない場合 + p ユーザーがアプリの連携を許可したことを(何らかの方法で(たとえばボタンを押させるなど))確認出来るようにしてください。 + p + | 次に、<code>#{config.api_url}/auth/session/userkey</code>へ<code>app_secret</code>としてApp Secretを、<code>token</code>としてセッションのトークンをパラメータとして付与したリクエストを送信してください。 + br + | 上手くいけば、認証したユーザーのアクセストークンがレスポンスとして取得できます。おめでとうございます! + + p アクセストークンを取得できたら、あとは簡単です。REST APIなら、リクエストにアクセストークンを<code>_userkey</code>(「自分のアクセストークンを取得したい場合」の方法で取得したアクセストークンの場合は<code>i</code>)としてパラメータに含めるだけです。 diff --git a/src/web/about/pages/api/library.pug b/src/web/about/pages/api/library.pug new file mode 100644 index 0000000000..b1ed16e71d --- /dev/null +++ b/src/web/about/pages/api/library.pug @@ -0,0 +1,14 @@ +extend ../../base + +block title + | ライブラリ + +block content + h1 ライブラリ + + p Misskey APIを便利に利用するためのライブラリ一覧です。 + + section + h2 .NET + ul + li: strong: a(href='https://github.com/syuilo/Misq') Misq (公式) diff --git a/src/web/about/pages/license.pug b/src/web/about/pages/license.pug new file mode 100644 index 0000000000..7adda0bff4 --- /dev/null +++ b/src/web/about/pages/license.pug @@ -0,0 +1,9 @@ +extend ../base + +block title + | ライセンス + +block content + h1 ライセンス + + include ../../../../LICENSE diff --git a/src/web/about/resources/style.css b/src/web/about/resources/style.css new file mode 100644 index 0000000000..53d658fa6b --- /dev/null +++ b/src/web/about/resources/style.css @@ -0,0 +1,199 @@ +html { + font-family: sans-serif; +} + +body { + margin: 0; + color: #34495e; +} + +nav { + display: block; + float: left; + width: 210px; +} +nav ul { + display: block; + margin: 0 0 16px 0; + padding: 0 0 0 16px; + list-style: none; +} +nav ul li { + margin: 0; + padding: 0; +} +nav ul li p { + margin: 16px 0 0 0; +} +@media screen and (max-width: 910px) { + nav { + display: none; + } +} + +main { + float: left; + box-sizing: border-box; + padding: 32px; + width: 100%; + max-width: 700px; +} +@media screen and (max-width: 700px) { + main { + font-size: 8px; + } +} + +footer { + padding: 32px 0 0 0; + margin: 32px 0 0 0; + border-top: solid 1px #eee; +} + +footer .contribution { + margin: 0 0 16px 0; +} + +footer .copyright { + margin: 16px 0 0 0; + color: #aaa; +} + +a { + text-decoration: none; + color: #f76d6c; +} + a:hover { + text-decoration: underline; + } + +section { + margin: 32px 0; +} + +h1 { + margin: 0 0 24px 0; + padding: 16px 0; + font-size: 1.5em; + border-bottom: solid 2px #eee; +} + +h2 { + margin: 0 0 24px 0; + padding: 0 0 16px 0; + font-size: 1.4em; + border-bottom: solid 1px #eee; +} + +h3 { + margin: 0; + padding: 0; + font-size: 1.25em; +} + +h4 { + margin: 0; +} + +p { + margin: 1em 0; + line-height: 1.6em; +} + +p.tip { + position: relative; + padding: 12px 24px 12px 30px; + margin: 1em 0; + font-size: 0.9em; + border-left: 4px solid #f66; + background-color: #f8f8f8; + border-bottom-right-radius: 2px; + border-top-right-radius: 2px; +} + p.tip:before { + position: absolute; + top: 14px; + left: -12px; + background-color: #f66; + color: #fff; + content: "!"; + width: 20px; + height: 20px; + border-radius: 100%; + text-align: center; + line-height: 20px; + font-weight: bold; + font-family: 'Dosis', 'Source Sans Pro', 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; + } + +table { + width: 100%; + border-spacing: 0; + border-collapse: collapse; +} + +table thead { + font-weight: bold; + border-bottom: solid 2px #eee; +} + +table tbody tr { + border-bottom: dashed 1px #eee; +} + +table th, table td { + padding: 8px 16px; +} + +table.entity tbody tr td:nth-child(1) { + font-family: Consolas, 'Courier New', Courier, Monaco, monospace; +} + +table.entity tbody tr td:nth-child(2) { + font-style: italic; +} + +table.entity tr td:nth-child(3):after { + margin-left: 8px; + opacity: 0.7; +} + +table.entity tr.nullable td:nth-child(2):after { + content: "?"; + opacity: 0.7; +} +table.entity tr.nullable td:nth-child(3):after { + content: "(Null許容)"; +} + +table.entity tr.optional { + opacity: 0.7; +} +table.entity tr.optional td:nth-child(3):after { + content: "(省略可能)"; +} + +table.entity tr.nullable.optional td:nth-child(3):after { + content: "(Null許容かつ省略可能)"; +} + +pre, code, var, samp, kbd { + font-family: Consolas, 'Courier New', Courier, Monaco, monospace; +} + +code { + display: inline-block; + margin: 0 4px; + padding: 0 8px; + color: #525252; + background: #f8f8f8; + border-radius: 2px; +} + +pre code { + display: block; + overflow: auto; + margin: 0; + padding: 32px; +} diff --git a/src/web/app/auth/resources/logo.svg b/src/web/app/auth/resources/logo.svg new file mode 100644 index 0000000000..19b8a2737e --- /dev/null +++ b/src/web/app/auth/resources/logo.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="1024px" height="512px" viewBox="0 256 1024 512" enable-background="new 0 256 1024 512" xml:space="preserve">
+<polyline opacity="0.5" fill="none" stroke="#000000" stroke-width="34" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" points="
+ 896.5,608.5 800.5,416.5 704.5,608.5 608.5,416.5 512.5,608.5 416.5,416.5 320.5,608.5 224.5,416.5 128.5,608.5 "/>
+</svg>
diff --git a/src/web/app/auth/script.js b/src/web/app/auth/script.js new file mode 100644 index 0000000000..9743415b12 --- /dev/null +++ b/src/web/app/auth/script.js @@ -0,0 +1,19 @@ +/** + * Authorize Form + */ + +const riot = require('riot'); +document.title = 'Misskey | アプリの連携'; +require('./tags.ls'); +const boot = require('../boot.ls'); + +/** + * Boot + */ +boot(me => { + mount(document.createElement('mk-index')); +}); + +function mount(content) { + riot.mount(document.getElementById('app').appendChild(content)); +} diff --git a/src/web/app/auth/style.styl b/src/web/app/auth/style.styl new file mode 100644 index 0000000000..046a5ff6ee --- /dev/null +++ b/src/web/app/auth/style.styl @@ -0,0 +1,14 @@ +@import "../base" + +html + background #eee + + @media (max-width 600px) + background #fff + +body + margin 0 + padding 32px 0 + + @media (max-width 600px) + padding 0 diff --git a/src/web/app/auth/tags.ls b/src/web/app/auth/tags.ls new file mode 100644 index 0000000000..5f618aaadf --- /dev/null +++ b/src/web/app/auth/tags.ls @@ -0,0 +1,2 @@ +require './tags/index.tag' +require './tags/form.tag' diff --git a/src/web/app/auth/tags/form.tag b/src/web/app/auth/tags/form.tag new file mode 100644 index 0000000000..f5b1555554 --- /dev/null +++ b/src/web/app/auth/tags/form.tag @@ -0,0 +1,126 @@ +mk-form + header + h1 + i { app.name } + | があなたの + b アカウント + | に + b アクセス + | することを + b 許可 + | しますか? + img(src={ app.icon_url + '?thumbnail&size=64' }) + div.app + section + h2 { app.name } + p.nid { app.name_id } + p.description { app.description } + section + h2 このアプリは次の権限を要求しています: + ul + virtual(each={ p in app.permission }) + li(if={ p == 'account-read' }) アカウントの情報を見る。 + li(if={ p == 'account-write' }) アカウントの情報を操作する。 + li(if={ p == 'post-write' }) 投稿する。 + li(if={ p == 'like-write' }) いいねしたりいいね解除する。 + li(if={ p == 'following-write' }) フォローしたりフォロー解除する。 + li(if={ p == 'drive-read' }) ドライブを見る。 + li(if={ p == 'drive-write' }) ドライブを操作する。 + li(if={ p == 'notification-read' }) 通知を見る。 + li(if={ p == 'notification-write' }) 通知を操作する。 + + div.action + button(onclick={ cancel }) キャンセル + button(onclick={ accept }) アクセスを許可 + +style. + 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 + +script. + @mixin \api + + @session = @opts.session + @app = @session.app + + @cancel = ~> + @api \auth/deny do + token: @session.token + .then ~> + @trigger \denied + + @accept = ~> + @api \auth/accept do + token: @session.token + .then ~> + @trigger \accepted diff --git a/src/web/app/auth/tags/index.tag b/src/web/app/auth/tags/index.tag new file mode 100644 index 0000000000..b7017daec6 --- /dev/null +++ b/src/web/app/auth/tags/index.tag @@ -0,0 +1,129 @@ +mk-index + main(if={ SIGNIN }) + p.fetching(if={ fetching }) + | 読み込み中 + mk-ellipsis + mk-form@form(if={ state == null && !fetching }, session={ session }) + div.denied(if={ state == 'denied' }) + h1 アプリケーションの連携をキャンセルしました。 + p このアプリがあなたのアカウントにアクセスすることはありません。 + div.accepted(if={ state == 'accepted' }) + h1 { session.app.is_authorized ? 'このアプリは既に連携済みです' : 'アプリケーションの連携を許可しました'} + p(if={ session.app.callback_url }) + | アプリケーションに戻っています + mk-ellipsis + p(if={ !session.app.callback_url }) アプリケーションに戻って、やっていってください。 + div.error(if={ state == 'fetch-session-error' }) + p セッションが存在しません。 + main.signin(if={ !SIGNIN }) + h1 サインインしてください + mk-signin + footer + img(src='/_/resources/auth/logo.svg', alt='Misskey') + +style. + 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 #50bbe6 + + &.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 + +script. + @mixin \i + @mixin \api + + @state = null + @fetching = true + + @token = window.location.href.split \/ .pop! + + @on \mount ~> + if not @SIGNIN then return + + # Fetch session + @api \auth/session/show do + token: @token + .then (session) ~> + @session = session + @fetching = false + + # 既に連携していた場合 + if @session.app.is_authorized + @api \auth/accept do + token: @session.token + .then ~> + @accepted! + else + @update! + + @refs.form.on \denied ~> + @state = \denied + @update! + + @refs.form.on \accepted @accepted + + .catch (error) ~> + @fetching = false + @state = \fetch-session-error + @update! + + @accepted = ~> + @state = \accepted + @update! + + if @session.app.callback_url + location.href = @session.app.callback_url + '?token=' + @session.token diff --git a/src/web/app/auth/view.pug b/src/web/app/auth/view.pug new file mode 100644 index 0000000000..a7b9f9263b --- /dev/null +++ b/src/web/app/auth/view.pug @@ -0,0 +1,6 @@ +extends ../base + +block head + meta(name='viewport', content='width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no') + link(rel='stylesheet', href='/_/resources/auth/style.css') + script(src='/_/resources/auth/script.js', async, defer) diff --git a/src/web/app/base.pug b/src/web/app/base.pug new file mode 100644 index 0000000000..805feaee6c --- /dev/null +++ b/src/web/app/base.pug @@ -0,0 +1,23 @@ +doctype html + +!= '\r\n<!-- Thank you for using Misskey! @syuilo -->\r\n' + +html(lang='ja', dir='ltr') + + head + meta(charset='utf-8') + meta(name='application-name', content='Misskey') + meta(name='theme-color', content= themeColor) + meta(name='referrer', content='origin') + title Misskey + style + include ./../../../built/web/resources/init.css + script(src='https://use.fontawesome.com/22aba0df4f.js', async) + block head + + body + noscript: div: p JavaScriptを有効にしてください + div#init: p + span . + span . + span . diff --git a/src/web/app/base.styl b/src/web/app/base.styl new file mode 100644 index 0000000000..5eab205480 --- /dev/null +++ b/src/web/app/base.styl @@ -0,0 +1,118 @@ +@charset 'utf-8' + +$theme-color = convert(themeColor) +$theme-color-foreground = convert(themeColorForeground) + +@import './reset' + +/* + ::selection + background $theme-color + color #fff +*/ + +* + tap-highlight-color rgba($theme-color, 0.7) + -webkit-tap-highlight-color rgba($theme-color, 0.7) + +html, body + margin 0 + padding 0 + scroll-behavior smooth + text-size-adjust 100% + font-family sans-serif + +html + &.progress + &, * + cursor progress !important + +#error + position fixed + z-index 32768 + top 0 + left 0 + width 100% + height 100% + background #00f + color #fff + + > p + text-align center + +#nprogress + pointer-events none + + position absolute + z-index 65536 + + .bar + background $theme-color + + position fixed + z-index 65537 + top 0 + left 0 + + width 100% + height 2px + + /* Fancy blur effect */ + .peg + display block + position absolute + right 0px + width 100px + height 100% + box-shadow 0 0 10px $theme-color, 0 0 5px $theme-color + opacity 1 + + transform rotate(3deg) translate(0px, -4px) + +#wait + display block + position fixed + z-index 65537 + top 15px + right 15px + + &:before + content "" + display block + width 18px + height 18px + box-sizing border-box + + border solid 2px transparent + border-top-color $theme-color + border-left-color $theme-color + border-radius 50% + + animation progress-spinner 400ms linear infinite + + @keyframes progress-spinner + 0% + transform rotate(0deg) + 100% + transform rotate(360deg) + +a + text-decoration none + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + * + cursor pointer + +mk-locker + display block + position fixed + top 0 + left 0 + z-index 65536 + width 100% + height 100% + cursor wait diff --git a/src/web/app/boot.ls b/src/web/app/boot.ls new file mode 100644 index 0000000000..d1230f8f0b --- /dev/null +++ b/src/web/app/boot.ls @@ -0,0 +1,154 @@ +#================================ +# MISSKEY BOOT LOADER +# +# Misskeyを起動します。 +# 1. 初期化 +# 2. ユーザー取得(ログインしていれば) +# 3. アプリケーションをマウント +#================================ + +# LOAD DEPENDENCIES +#-------------------------------- + +riot = require \riot +require \velocity +log = require './common/scripts/log.ls' +api = require './common/scripts/api.ls' +signout = require './common/scripts/signout.ls' +generate-default-userdata = require './common/scripts/generate-default-userdata.ls' +mixins = require './common/mixins.ls' +check-for-update = require './common/scripts/check-for-update.ls' +require './common/tags.ls' + +# MISSKEY ENTORY POINT +#-------------------------------- + +# for subdomains +document.domain = CONFIG.host + +# ↓ iOS待ちPolyfill (SEE: http://caniuse.com/#feat=fetch) +require \fetch + +# ↓ NodeList、HTMLCollectionで forEach を使えるようにする +if NodeList.prototype.for-each == undefined + NodeList.prototype.for-each = Array.prototype.for-each +if HTMLCollection.prototype.for-each == undefined + HTMLCollection.prototype.for-each = Array.prototype.for-each + +# ↓ iOSでプライベートモードだとlocalStorageが使えないので既存のメソッドを上書きする +try + local-storage.set-item \kyoppie \yuppie +catch e + Storage.prototype.set-item = ~> # noop + +# MAIN PROCESS +#-------------------------------- + +log "Misskey (aoi) v:#{VERSION}" + +# Check for Update +check-for-update! + +# Get token from cookie +i = ((document.cookie.match /i=(\w+)/) || [null null]).1 + +if i? then log "ME: #{i}" + +# ユーザーをフェッチしてコールバックする +module.exports = (callback) ~> + # Get cached account data + cached-me = JSON.parse local-storage.get-item \me + + if cached-me?.data?.cache + fetched cached-me + + # 後から新鮮なデータをフェッチ + fetchme i, true, (fresh-data) ~> + Object.assign cached-me, fresh-data + cached-me.trigger \updated + else + # キャッシュ無効なのにキャッシュが残ってたら掃除 + if cached-me? + local-storage.remove-item \me + + fetchme i, false, fetched + + function fetched me + + if me? + riot.observable me + + if me.data.cache + local-storage.set-item \me JSON.stringify me + + me.on \updated ~> + # キャッシュ更新 + local-storage.set-item \me JSON.stringify me + + log "Fetched! Hello #{me.username}." + + # activate mixins + mixins me + + # destroy loading screen + init = document.get-element-by-id \init + init.parent-node.remove-child init + + # set main element + document.create-element \div + ..set-attribute \id \app + .. |> document.body.append-child + + # Call main proccess + try + callback me + catch error + panic error + +# ユーザーをフェッチしてコールバックする +function fetchme token, silent, cb + me = null + + # Return when not signed in + if not token? then return done! + + # Fetch user + fetch "#{CONFIG.api.url}/i" do + method: \POST + headers: + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + body: "i=#token" + .then (res) ~> + # When failed to authenticate user + if res.status != 200 then signout! + + i <~ res.json!.then + me := i + me.token = token + + # initialize it if user data is empty + if me.data? then done! else init! + .catch ~> + if not silent + info = document.create-element \mk-core-error + |> document.body.append-child + riot.mount info, do + retry: ~> fetchme token, false, cb + else + # noop + + function done + if cb? then cb me + + function init + data = generate-default-userdata! + + api token, \i/appdata/set do + data: JSON.stringify data + .then ~> + me.data = data + done! + +function panic e + console.error e + document.body.innerHTML = '<div id="error"><p>致命的な問題が発生しました。</p></div>' diff --git a/src/web/app/client/script.js b/src/web/app/client/script.js new file mode 100644 index 0000000000..d8531e9cc8 --- /dev/null +++ b/src/web/app/client/script.js @@ -0,0 +1,40 @@ +const head = document.getElementsByTagName('head')[0]; +const ua = navigator.userAgent.toLowerCase(); +const isMobile = /mobile|iphone|ipad|android/.test(ua); + +if (isMobile) { + mountMobile(); +} else { + mountDesktop(); +} + +function mountDesktop() { + const style = document.createElement('link'); + style.setAttribute('href', '/_/resources/desktop/style.css'); + style.setAttribute('rel', 'stylesheet'); + head.appendChild(style); + + const script = document.createElement('script'); + script.setAttribute('src', '/_/resources/desktop/script.js'); + script.setAttribute('async', 'true'); + script.setAttribute('defer', 'true'); + head.appendChild(script); +} + +function mountMobile() { + const meta = document.createElement('meta'); + meta.setAttribute('name', 'viewport'); + meta.setAttribute('content', 'width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no'); + head.appendChild(meta); + + const style = document.createElement('link'); + style.setAttribute('href', '/_/resources/mobile/style.css'); + style.setAttribute('rel', 'stylesheet'); + head.appendChild(style); + + const script = document.createElement('script'); + script.setAttribute('src', '/_/resources/mobile/script.js'); + script.setAttribute('async', 'true'); + script.setAttribute('defer', 'true'); + head.appendChild(script); +} diff --git a/src/web/app/client/view.pug b/src/web/app/client/view.pug new file mode 100644 index 0000000000..6f0ff3cd5c --- /dev/null +++ b/src/web/app/client/view.pug @@ -0,0 +1,5 @@ +extends ../base + +block head + script + include ./../../../../built/web/resources/client/script.js diff --git a/src/web/app/common/mixins.ls b/src/web/app/common/mixins.ls new file mode 100644 index 0000000000..1320cacd1e --- /dev/null +++ b/src/web/app/common/mixins.ls @@ -0,0 +1,40 @@ +riot = require \riot + +module.exports = (me) ~> + i = if me? then me.token else null + + (require './scripts/i.ls') me + + riot.mixin \api do + api: (require './scripts/api.ls').bind null i + + riot.mixin \cropper do + Cropper: require \cropper + + riot.mixin \signout do + signout: require './scripts/signout.ls' + + riot.mixin \messaging-stream do + MessagingStreamConnection: require './scripts/messaging-stream.ls' + + riot.mixin \is-promise do + is-promise: require './scripts/is-promise.ls' + + riot.mixin \get-post-summary do + get-post-summary: require './scripts/get-post-summary.ls' + + riot.mixin \date-stringify do + date-stringify: require './scripts/date-stringify.ls' + + riot.mixin \text do + analyze: require 'misskey-text' + compile: require './scripts/text-compiler.js' + + riot.mixin \get-password-strength do + get-password-strength: require 'strength.js' + + riot.mixin \ui-progress do + Progress: require './scripts/loading.ls' + + riot.mixin \bytes-to-size do + bytes-to-size: require './scripts/bytes-to-size.js' diff --git a/src/web/app/common/pages/about/base.pug b/src/web/app/common/pages/about/base.pug new file mode 100644 index 0000000000..0bac19ee2b --- /dev/null +++ b/src/web/app/common/pages/about/base.pug @@ -0,0 +1,13 @@ +extends ../../../base + +block head + link(rel='stylesheet', href='/_/resources/common/pages/about/style.css') + script(src='/_/resources/common/pages/about/script.js', async, defer) + +block body + article + header + h1 + block header + div.body + block content diff --git a/src/web/app/common/pages/about/pages/staff.pug b/src/web/app/common/pages/about/pages/staff.pug new file mode 100644 index 0000000000..dfdf015a3d --- /dev/null +++ b/src/web/app/common/pages/about/pages/staff.pug @@ -0,0 +1,13 @@ +extends ../base + +block title + | スタッフ | Misskey + +block header + | スタッフ + +block content + div.members + div.member + p しゅいろ + p 統括、設計、グラフィックデザイン、プログラム
\ No newline at end of file diff --git a/src/web/app/common/scripts/api.ls b/src/web/app/common/scripts/api.ls new file mode 100644 index 0000000000..0656a56168 --- /dev/null +++ b/src/web/app/common/scripts/api.ls @@ -0,0 +1,67 @@ +riot = require \riot + +spinner = null +pending = 0 + +net = riot.observable! + +riot.mixin \net do + net: net + +log = (riot.mixin \log).log + +module.exports = (i, endpoint, data) -> + pending++ + + if i? and typeof i == \object then i = i.token + + body = [] + + # append user token when signed in + if i? then body.push "i=#i" + + for k, v of data + if v != undefined + v = encodeURIComponent v + body.push "#k=#v" + + opts = + method: \POST + headers: + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + body: body.join \& + + if endpoint == \signin + opts.credentials = \include + + ep = if (endpoint.index-of '://') > -1 + then endpoint + else "#{CONFIG.api.url}/#{endpoint}" + + if pending == 1 + spinner := document.create-element \div + ..set-attribute \id \wait + document.body.append-child spinner + + new Promise (resolve, reject) -> + timer = set-timeout -> + net.trigger \detected-slow-network + , 5000ms + + log "API: #{ep}" + + fetch ep, opts + .then (res) -> + pending-- + clear-timeout timer + if pending == 0 + spinner.parent-node.remove-child spinner + + if res.status == 200 + res.json!.then resolve + else if res.status == 204 + resolve! + else + res.json!.then (err) -> + reject err.error + .catch reject diff --git a/src/web/app/common/scripts/bytes-to-size.js b/src/web/app/common/scripts/bytes-to-size.js new file mode 100644 index 0000000000..717f9ad507 --- /dev/null +++ b/src/web/app/common/scripts/bytes-to-size.js @@ -0,0 +1,6 @@ +module.exports = function(bytes) { + var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + if (bytes == 0) return '0Byte'; + var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); + return Math.round(bytes / Math.pow(1024, i), 2) + sizes[i]; +} diff --git a/src/web/app/common/scripts/check-for-update.ls b/src/web/app/common/scripts/check-for-update.ls new file mode 100644 index 0000000000..48e250a4c7 --- /dev/null +++ b/src/web/app/common/scripts/check-for-update.ls @@ -0,0 +1,9 @@ +module.exports = -> + fetch \/api:meta + .then (res) ~> + meta <~ res.json!.then + if meta.commit.hash != VERSION + if window.confirm '新しいMisskeyのバージョンがあります。更新しますか?\r\n(このメッセージが繰り返し表示される場合は、サーバーにデータがまだ届いていない可能性があるので、少し時間を置いてから再度お試しください)' + location.reload true + .catch ~> + # ignore diff --git a/src/web/app/common/scripts/date-stringify.ls b/src/web/app/common/scripts/date-stringify.ls new file mode 100644 index 0000000000..9aa8b3e6c5 --- /dev/null +++ b/src/web/app/common/scripts/date-stringify.ls @@ -0,0 +1,14 @@ +module.exports = (date) -> + if typeof date == \string then date = new Date date + + text = + date.get-full-year! + \年 + + date.get-month! + \月 + + date.get-date! + \日 + + ' ' + + date.get-hours! + \時 + + date.get-minutes! + \分 + + ' ' + + "(#{[\日 \月 \火 \水 \木 \金 \土][date.get-day!]})" + + return text diff --git a/src/web/app/common/scripts/generate-default-userdata.ls b/src/web/app/common/scripts/generate-default-userdata.ls new file mode 100644 index 0000000000..de03e96151 --- /dev/null +++ b/src/web/app/common/scripts/generate-default-userdata.ls @@ -0,0 +1,27 @@ +uuid = require './uuid.js' + +home = + left: [ \profile \calendar \rss-reader \photo-stream ] + right: [ \broadcast \notifications \user-recommendation \donation \nav \tips ] + +module.exports = ~> + home-data = [] + + home.left.for-each (widget) ~> + home-data.push do + name: widget + id: uuid! + place: \left + + home.right.for-each (widget) ~> + home-data.push do + name: widget + id: uuid! + place: \right + + data = + cache: true + debug: false + home: home-data + + return data diff --git a/src/web/app/common/scripts/get-post-summary.ls b/src/web/app/common/scripts/get-post-summary.ls new file mode 100644 index 0000000000..0150d53004 --- /dev/null +++ b/src/web/app/common/scripts/get-post-summary.ls @@ -0,0 +1,26 @@ +get-post-summary = (post) ~> + summary = if post.text? then post.text else '' + + # メディアが添付されているとき + if post.media? + summary += " (#{post.media.length}枚の画像)" + + # 返信のとき + if post.reply_to_id? + if post.reply_to? + reply-summary = get-post-summary post.reply_to + summary += " RE: #{reply-summary}" + else + summary += " RE: ..." + + # Repostのとき + if post.repost_id? + if post.repost? + repost-summary = get-post-summary post.repost + summary += " RP: #{repost-summary}" + else + summary += " RP: ..." + + return summary.trim! + +module.exports = get-post-summary diff --git a/src/web/app/common/scripts/i.ls b/src/web/app/common/scripts/i.ls new file mode 100644 index 0000000000..5f3c016f8a --- /dev/null +++ b/src/web/app/common/scripts/i.ls @@ -0,0 +1,16 @@ +riot = require \riot + +module.exports = (me) -> + riot.mixin \i do + init: -> + @I = me + @SIGNIN = me? + + if @SIGNIN + @on \mount ~> me.on \updated @update + @on \unmount ~> me.off \updated @update + + update-i: (data) -> + if data? + Object.assign me, data + me.trigger \updated diff --git a/src/web/app/common/scripts/is-promise.ls b/src/web/app/common/scripts/is-promise.ls new file mode 100644 index 0000000000..e3c7adff85 --- /dev/null +++ b/src/web/app/common/scripts/is-promise.ls @@ -0,0 +1 @@ +module.exports = (x) -> typeof x.then == \function diff --git a/src/web/app/common/scripts/loading.ls b/src/web/app/common/scripts/loading.ls new file mode 100644 index 0000000000..ed791b21ac --- /dev/null +++ b/src/web/app/common/scripts/loading.ls @@ -0,0 +1,16 @@ +NProgress = require 'NProgress' +NProgress.configure do + trickle-speed: 500ms + show-spinner: false + +root = document.get-elements-by-tag-name \html .0 + +module.exports = + start: ~> + root.class-list.add \progress + NProgress.start! + done: ~> + root.class-list.remove \progress + NProgress.done! + set: (val) ~> + NProgress.set val diff --git a/src/web/app/common/scripts/log.ls b/src/web/app/common/scripts/log.ls new file mode 100644 index 0000000000..6e1e3735d8 --- /dev/null +++ b/src/web/app/common/scripts/log.ls @@ -0,0 +1,18 @@ +riot = require \riot + +logs = [] + +ev = riot.observable! + +function log(msg) + logs.push do + date: new Date! + message: msg + ev.trigger \log + +riot.mixin \log do + logs: logs + log: log + log-event: ev + +module.exports = log diff --git a/src/web/app/common/scripts/messaging-stream.ls b/src/web/app/common/scripts/messaging-stream.ls new file mode 100644 index 0000000000..298285dc93 --- /dev/null +++ b/src/web/app/common/scripts/messaging-stream.ls @@ -0,0 +1,34 @@ +# Stream +#================================ + +ReconnectingWebSocket = require 'reconnecting-websocket' +riot = require 'riot' + +class Connection + (me, otherparty) ~> + @event = riot.observable! + @me = me + host = CONFIG.api.url.replace \http \ws + @socket = new ReconnectingWebSocket "#{host}/messaging?otherparty=#{otherparty}" + + @socket.add-event-listener \open @on-open + @socket.add-event-listener \message @on-message + + on-open: ~> + @socket.send JSON.stringify do + i: @me.token + + on-message: (message) ~> + try + message = JSON.parse message.data + if message.type? + @event.trigger message.type, message.body + catch + # ignore + + close: ~> + @socket.remove-event-listener \open @on-open + @socket.remove-event-listener \message @on-message + @socket.close! + +module.exports = Connection diff --git a/src/web/app/common/scripts/signout.ls b/src/web/app/common/scripts/signout.ls new file mode 100644 index 0000000000..a647922678 --- /dev/null +++ b/src/web/app/common/scripts/signout.ls @@ -0,0 +1,4 @@ +module.exports = -> + local-storage.remove-item \me + document.cookie = "i=; domain=.#{CONFIG.host}; expires=Thu, 01 Jan 1970 00:00:01 GMT;" + location.href = \/ diff --git a/src/web/app/common/scripts/stream.ls b/src/web/app/common/scripts/stream.ls new file mode 100644 index 0000000000..534048248f --- /dev/null +++ b/src/web/app/common/scripts/stream.ls @@ -0,0 +1,42 @@ +# Stream +#================================ + +ReconnectingWebSocket = require \reconnecting-websocket +riot = require \riot + +module.exports = (me) ~> + state = \initializing + state-ev = riot.observable! + event = riot.observable! + + socket = new ReconnectingWebSocket CONFIG.api.url.replace \http \ws + + socket.onopen = ~> + state := \connected + state-ev.trigger \connected + socket.send JSON.stringify do + i: me.token + + socket.onclose = ~> + state := \reconnecting + state-ev.trigger \closed + + socket.onmessage = (message) ~> + try + message = JSON.parse message.data + if message.type? + event.trigger message.type, message.body + catch + # ignore + + get-state = ~> state + + event.on \i_updated (data) ~> + Object.assign me, data + me.trigger \updated + + { + state-ev + get-state + event + } diff --git a/src/web/app/common/scripts/text-compiler.js b/src/web/app/common/scripts/text-compiler.js new file mode 100644 index 0000000000..9915e3335f --- /dev/null +++ b/src/web/app/common/scripts/text-compiler.js @@ -0,0 +1,30 @@ +module.exports = function(tokens, canBreak, escape) { + if (canBreak == null) { + canBreak = true; + } + if (escape == null) { + escape = true; + } + return tokens.map(function(token) { + switch (token.type) { + case 'text': + if (escape) { + return token.content + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/(\r\n|\n|\r)/g, canBreak ? '<br>' : ' '); + } else { + return token.content + .replace(/(\r\n|\n|\r)/g, canBreak ? '<br>' : ' '); + } + case 'bold': + return '<strong>' + token.bold + '</strong>'; + case 'link': + return '<mk-url href="' + token.content + '" target="_blank"></mk-url>'; + case 'mention': + return '<a href="' + CONFIG.url + '/' + token.username + '" target="_blank" data-user-preview="' + token.content + '">' + token.content + '</a>'; + case 'hashtag': // TODO + return '<a>' + token.content + '</a>'; + } + }).join(''); +} diff --git a/src/web/app/common/scripts/uuid.js b/src/web/app/common/scripts/uuid.js new file mode 100644 index 0000000000..6161190d63 --- /dev/null +++ b/src/web/app/common/scripts/uuid.js @@ -0,0 +1,12 @@ +module.exports = function () { + var uuid = '', i, random; + for (i = 0; i < 32; i++) { + random = Math.random() * 16 | 0; + + if (i == 8 || i == 12 || i == 16 || i == 20) { + uuid += '-' + } + uuid += (i == 12 ? 4 : (i == 16 ? (random & 3 | 8) : random)).toString(16); + } + return uuid; +} diff --git a/src/web/app/common/tags.ls b/src/web/app/common/tags.ls new file mode 100644 index 0000000000..fe71a7bb37 --- /dev/null +++ b/src/web/app/common/tags.ls @@ -0,0 +1,16 @@ +require './tags/core-error.tag' +require './tags/url.tag' +require './tags/url-preview.tag' +require './tags/ripple-string.tag' +require './tags/time.tag' +require './tags/file-type-icon.tag' +require './tags/uploader.tag' +require './tags/ellipsis.tag' +require './tags/raw.tag' +require './tags/number.tag' +require './tags/special-message.tag' +require './tags/signin.tag' +require './tags/signup.tag' +require './tags/forkit.tag' +require './tags/introduction.tag' +require './tags/copyright.tag' diff --git a/src/web/app/common/tags/copyright.tag b/src/web/app/common/tags/copyright.tag new file mode 100644 index 0000000000..74acae4df7 --- /dev/null +++ b/src/web/app/common/tags/copyright.tag @@ -0,0 +1,5 @@ +mk-copyright + span (c) syuilo 2014-2016 + +style. + display block diff --git a/src/web/app/common/tags/core-error.tag b/src/web/app/common/tags/core-error.tag new file mode 100644 index 0000000000..19ef68bea6 --- /dev/null +++ b/src/web/app/common/tags/core-error.tag @@ -0,0 +1,63 @@ +mk-core-error + //i: i.fa.fa-times-circle + img(src='/_/resources/error.jpg', alt='') + h1: mk-ripple-string サーバーに接続できません + p.text + | インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから + a(onclick={ retry }) 再度お試し + | ください。 + p.thanks いつもMisskeyをご利用いただきありがとうございます。 + +style. + position fixed + z-index 16385 + top 0 + left 0 + width 100% + height 100% + text-align center + background #f8f8f8 + + > i + display block + margin-top 64px + font-size 5em + color #6998a0 + + > img + display block + height 200px + margin 64px auto 0 auto + pointer-events none + -ms-user-select none + -moz-user-select none + -webkit-user-select none + user-select none + + > h1 + display block + margin 32px auto 16px auto + font-size 1.5em + color #555 + + > .text + display block + margin 0 auto + max-width 600px + font-size 1em + color #666 + + > .thanks + display block + margin 32px auto 0 auto + padding 32px 0 32px 0 + max-width 600px + font-size 0.9em + font-style oblique + color #aaa + border-top solid 1px #eee + +script. + @retry = ~> + @unmount! + @opts.retry! diff --git a/src/web/app/common/tags/ellipsis.tag b/src/web/app/common/tags/ellipsis.tag new file mode 100644 index 0000000000..47eca62acd --- /dev/null +++ b/src/web/app/common/tags/ellipsis.tag @@ -0,0 +1,25 @@ +mk-ellipsis + span . + span . + span . + +style. + 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 diff --git a/src/web/app/common/tags/file-type-icon.tag b/src/web/app/common/tags/file-type-icon.tag new file mode 100644 index 0000000000..68b8f95ad7 --- /dev/null +++ b/src/web/app/common/tags/file-type-icon.tag @@ -0,0 +1,9 @@ +mk-file-type-icon + i.fa.fa-file-image-o(if={ kind == 'image' }) + +style. + display inline + +script. + @file = @opts.file + @kind = @file.type.split \/ .0 diff --git a/src/web/app/common/tags/forkit.tag b/src/web/app/common/tags/forkit.tag new file mode 100644 index 0000000000..7205fbe76b --- /dev/null +++ b/src/web/app/common/tags/forkit.tag @@ -0,0 +1,37 @@ +mk-forkit + a(href='https://github.com/syuilo/misskey', target='_blank', title='View source on Github', aria-label='View source on Github') + svg(width='80', height='80', viewBox='0 0 250 250', aria-hidden) + path(d='M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z') + path.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(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') + +style. + 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) diff --git a/src/web/app/common/tags/introduction.tag b/src/web/app/common/tags/introduction.tag new file mode 100644 index 0000000000..962f195cca --- /dev/null +++ b/src/web/app/common/tags/introduction.tag @@ -0,0 +1,22 @@ +mk-introduction + article + h1 Misskeyとは? + <p><ruby>Misskey<rt>みすきー</rt></ruby>は、<a href="http://syuilo.com" target="_blank">syuilo</a>が2014年くらいから<a href="https://github.com/syuilo" target="_blank">オープンソースで</a>開発・運営を行っている、ミニブログベースのSNSです。</p> + <p>Twitter, Facebook, LINE, Google+ などを<del>パクって</del><i>参考にして</i>います。</p> + <p>無料で誰でも利用でき、広告なども一切ありません。</p> + <p><a href={ CONFIG.urls.about } target="_blank">もっと知りたい方はこちら</a></p> + +style. + display block + + h1 + margin 0 + text-align center + font-size 1.2em + + p + margin 16px 0 + + &:last-child + margin 0 + text-align center diff --git a/src/web/app/common/tags/number.tag b/src/web/app/common/tags/number.tag new file mode 100644 index 0000000000..589c747b35 --- /dev/null +++ b/src/web/app/common/tags/number.tag @@ -0,0 +1,15 @@ +mk-number + +style. + display inline + +script. + @on \mount ~> + # バグ? https://github.com/riot/riot/issues/2103 + #value = @opts.value + value = @opts.riot-value + max = @opts.max + + if max? then if value > max then value = max + + @root.innerHTML = value.to-locale-string! diff --git a/src/web/app/common/tags/raw.tag b/src/web/app/common/tags/raw.tag new file mode 100644 index 0000000000..131826e597 --- /dev/null +++ b/src/web/app/common/tags/raw.tag @@ -0,0 +1,7 @@ +mk-raw + +style. + display inline + +script. + @root.innerHTML = @opts.content diff --git a/src/web/app/common/tags/ripple-string.tag b/src/web/app/common/tags/ripple-string.tag new file mode 100644 index 0000000000..3be6903369 --- /dev/null +++ b/src/web/app/common/tags/ripple-string.tag @@ -0,0 +1,24 @@ +mk-ripple-string + <yield/> + +style. + display inline + + > span + animation ripple-string 5s infinite ease-in-out both + + @keyframes ripple-string + 0%, 50%, 100% + opacity 1 + 25% + opacity 0.5 + +script. + @on \mount ~> + text = @root.innerHTML + @root.innerHTML = '' + (text.split '').for-each (c, i) ~> + ce = document.create-element \span + ce.innerHTML = c + ce.style.animation-delay = (i / 10) + 's' + @root.append-child ce diff --git a/src/web/app/common/tags/signin.tag b/src/web/app/common/tags/signin.tag new file mode 100644 index 0000000000..6f4013b1cb --- /dev/null +++ b/src/web/app/common/tags/signin.tag @@ -0,0 +1,136 @@ +mk-signin + form(onsubmit={ onsubmit }, class={ signing: signing }) + label.user-name + input@username( + type='text' + pattern='^[a-zA-Z0-9\-]+$' + placeholder='ユーザー名' + autofocus + required + oninput={ oninput }) + i.fa.fa-at + label.password + input@password( + type='password' + placeholder='パスワード' + required) + i.fa.fa-lock + button(type='submit', disabled={ signing }) { signing ? 'やっています...' : 'サインイン' } + +style. + display block + + > form + display block + z-index 2 + + &.signing + &, * + cursor wait !important + + label + display block + margin 12px 0 + + i + 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] + 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 + +script. + @mixin \api + + @user = null + @signing = false + + @oninput = ~> + @api \users/show do + username: @refs.username.value + .then (user) ~> + @user = user + @trigger \user user + @update! + + @onsubmit = (e) ~> + e.prevent-default! + + @signing = true + @update! + + @api \signin do + username: @refs.username.value + password: @refs.password.value + .then ~> + location.reload! + .catch ~> + alert 'something happened' + @signing = false + @update! + + false diff --git a/src/web/app/common/tags/signup.tag b/src/web/app/common/tags/signup.tag new file mode 100644 index 0000000000..730f00fb4e --- /dev/null +++ b/src/web/app/common/tags/signup.tag @@ -0,0 +1,352 @@ +mk-signup + form(onsubmit={ onsubmit }, autocomplete='off') + label.username + p.caption + i.fa.fa-at + | ユーザー名 + input@username( + type='text' + pattern='^[a-zA-Z0-9\-]{3,20}$' + placeholder='a~z、A~Z、0~9、-' + autocomplete='off' + required + onkeyup={ on-change-username }) + + p.profile-page-url-preview(if={ refs.username.value != '' && username-state != 'invalid-format' && username-state != 'min-range' && username-state != 'max-range' }) { CONFIG.url + '/' + refs.username.value } + + p.info(if={ username-state == 'wait' }, style='color:#999') + i.fa.fa-fw.fa-spinner.fa-pulse + | 確認しています... + p.info(if={ username-state == 'ok' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | 利用できます + p.info(if={ username-state == 'unavailable' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 既に利用されています + p.info(if={ username-state == 'error' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 通信エラー + p.info(if={ username-state == 'invalid-format' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | a~z、A~Z、0~9、-(ハイフン)が使えます + p.info(if={ username-state == 'min-range' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 3文字以上でお願いします! + p.info(if={ username-state == 'max-range' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 20文字以内でお願いします + + label.password + p.caption + i.fa.fa-lock + | パスワード + input@password( + type='password' + placeholder='8文字以上を推奨します' + autocomplete='off' + required + onkeyup={ on-change-password }) + + div.meter(if={ password-strength != '' }, data-strength={ password-strength }) + div.value@password-metar + + p.info(if={ password-strength == 'low' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 弱いパスワード + p.info(if={ password-strength == 'medium' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | まあまあのパスワード + p.info(if={ password-strength == 'high' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | 強いパスワード + + label.retype-password + p.caption + i.fa.fa-lock + | パスワード(再入力) + input@password-retype( + type='password' + placeholder='確認のため再入力してください' + autocomplete='off' + required + onkeyup={ on-change-password-retype }) + + p.info(if={ password-retype-state == 'match' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | 確認されました + p.info(if={ password-retype-state == 'not-match' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 一致していません + + label.recaptcha + p.caption + i.fa.fa-toggle-on(if={ recaptchaed }) + i.fa.fa-toggle-off(if={ !recaptchaed }) + | 認証 + div.g-recaptcha( + data-callback='onRecaptchaed' + data-expired-callback='onRecaptchaExpired' + data-sitekey={ CONFIG.recaptcha.site-key }) + + label.agree-tou + input( + name='agree-tou', + type='checkbox', + autocomplete='off', + required) + p + a() 利用規約 + | に同意する + + button(onclick={ onsubmit }) + | アカウント作成 + +style. + 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 + + > i + margin-right 0.25em + color #96adac + + > .info + display block + margin 4px 0 + font-size 0.8em + + > i + 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%) + +script. + @mixin \api + @mixin \get-password-strength + + @username-state = null + @password-strength = '' + @password-retype-state = null + @recaptchaed = false + + window.on-recaptchaed = ~> + @recaptchaed = true + @update! + + window.on-recaptcha-expired = ~> + @recaptchaed = false + @update! + + @on \mount ~> + head = (document.get-elements-by-tag-name \head).0 + script = document.create-element \script + ..set-attribute \src \https://www.google.com/recaptcha/api.js + head.append-child script + + @on-change-username = ~> + username = @refs.username.value + + if username == '' + @username-state = null + @update! + return + + err = switch + | not username.match /^[a-zA-Z0-9\-]+$/ => \invalid-format + | username.length < 3chars => \min-range + | username.length > 20chars => \max-range + | _ => null + + if err? + @username-state = err + @update! + else + @username-state = \wait + @update! + + @api \username/available do + username: username + .then (result) ~> + if result.available + @username-state = \ok + else + @username-state = \unavailable + @update! + .catch (err) ~> + @username-state = \error + @update! + + @on-change-password = ~> + password = @refs.password.value + + if password == '' + @password-strength = '' + return + + strength = @get-password-strength password + + if strength > 0.3 + @password-strength = \medium + if strength > 0.7 + @password-strength = \high + else + @password-strength = \low + + @update! + + @refs.password-metar.style.width = (strength * 100) + \% + + @on-change-password-retype = ~> + password = @refs.password.value + retyped-password = @refs.password-retype.value + + if retyped-password == '' + @password-retype-state = null + return + + if password == retyped-password + @password-retype-state = \match + else + @password-retype-state = \not-match + + @onsubmit = (e) ~> + e.prevent-default! + + username = @refs.username.value + password = @refs.password.value + + locker = document.body.append-child document.create-element \mk-locker + + @api \signup do + username: username + password: password + 'g-recaptcha-response': grecaptcha.get-response! + .then ~> + @api \signin do + username: username + password: password + .then ~> + location.href = CONFIG.url + .catch ~> + alert '何らかの原因によりアカウントの作成に失敗しました。再度お試しください。' + + grecaptcha.reset! + @recaptchaed = false + + locker.parent-node.remove-child locker + + false diff --git a/src/web/app/common/tags/special-message.tag b/src/web/app/common/tags/special-message.tag new file mode 100644 index 0000000000..5a6d5787ea --- /dev/null +++ b/src/web/app/common/tags/special-message.tag @@ -0,0 +1,24 @@ +mk-special-message + p(if={ m == 1 && d == 1 }) Happy New Year! + p(if={ m == 12 && d == 25 }) Merry Christmas! + +style. + 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 + +script. + now = new Date! + @d = now.get-date! + @m = now.get-month! + 1 diff --git a/src/web/app/common/tags/time.tag b/src/web/app/common/tags/time.tag new file mode 100644 index 0000000000..56c3b8ecc3 --- /dev/null +++ b/src/web/app/common/tags/time.tag @@ -0,0 +1,43 @@ +mk-time + time(datetime={ opts.time }) + span(if={ mode == 'relative' }) { relative } + span(if={ mode == 'absolute' }) { absolute } + span(if={ mode == 'detail' }) { absolute } ({ relative }) + +script. + @time = new Date @opts.time + @mode = @opts.mode || \relative + @tickid = null + + @absolute = + @time.get-full-year! + \年 + + @time.get-month! + \月 + + @time.get-date! + \日 + + ' ' + + @time.get-hours! + \時 + + @time.get-minutes! + \分 + + @on \mount ~> + if @mode == \relative or @mode == \detail + @tick! + @tickid = set-interval @tick, 1000ms + + @on \unmount ~> + if @mode == \relative or @mode == \detail + clear-interval @tickid + + @tick = ~> + now = new Date! + ago = (now - @time) / 1000ms + @relative = switch + | ago >= 31536000s => ~~(ago / 31536000s) + '年前' + | ago >= 2592000s => ~~(ago / 2592000s) + 'ヶ月前' + | ago >= 604800s => ~~(ago / 604800s) + '週間前' + | ago >= 86400s => ~~(ago / 86400s) + '日前' + | ago >= 3600s => ~~(ago / 3600s) + '時間前' + | ago >= 60s => ~~(ago / 60s) + '分前' + | ago >= 10s => ~~(ago % 60s) + '秒前' + | ago >= 0s => 'たった今' + | ago < 0s => '未来' + | _ => 'なぞのじかん' + @update! diff --git a/src/web/app/common/tags/uploader.tag b/src/web/app/common/tags/uploader.tag new file mode 100644 index 0000000000..6d4e9b6363 --- /dev/null +++ b/src/web/app/common/tags/uploader.tag @@ -0,0 +1,201 @@ +mk-uploader + ol(if={ uploads.length > 0 }) + li(each={ uploads }) + div.img(style='background-image: url({ img })') + p.name + i.fa.fa-spinner.fa-pulse + | { name } + p.status + span.initing(if={ progress == undefined }) + | 待機中 + mk-ellipsis + span.kb(if={ progress != undefined }) + | { String(Math.floor(progress.value / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') } + i KB + = ' / ' + | { String(Math.floor(progress.max / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') } + i KB + span.percentage(if={ progress != undefined }) { Math.floor((progress.value / progress.max) * 100) } + progress(if={ progress != undefined && progress.value != progress.max }, value={ progress.value }, max={ progress.max }) + div.progress.initing(if={ progress == undefined }) + div.progress.waiting(if={ progress != undefined && progress.value == progress.max }) + +style. + 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 + + > i + 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;} + +script. + @mixin \i + + @uploads = [] + + + @upload = (file, folder) ~> + id = Math.random! + + ctx = + id: id + name: file.name || \untitled + progress: undefined + + @uploads.push ctx + @trigger \change-uploads @uploads + @update! + + reader = new FileReader! + reader.onload = (e) ~> + ctx.img = e.target.result + @update! + reader.read-as-data-URL file + + data = new FormData! + data.append \i @I.token + data.append \file file + + if folder? + data.append \folder_id folder + + xhr = new XMLHttpRequest! + xhr.open \POST CONFIG.api.url + '/drive/files/create' true + xhr.onload = (e) ~> + drive-file = JSON.parse e.target.response + + @trigger \uploaded drive-file + + @uploads = @uploads.filter (x) -> x.id != id + @trigger \change-uploads @uploads + + @update! + + xhr.upload.onprogress = (e) ~> + if e.length-computable + if ctx.progress == undefined + ctx.progress = {} + ctx.progress.max = e.total + ctx.progress.value = e.loaded + @update! + + xhr.send data diff --git a/src/web/app/common/tags/url-preview.tag b/src/web/app/common/tags/url-preview.tag new file mode 100644 index 0000000000..605d26bc67 --- /dev/null +++ b/src/web/app/common/tags/url-preview.tag @@ -0,0 +1,105 @@ +mk-url-preview + a(href={ url }, target='_blank', title={ url }, if={ !loading }) + div.thumbnail(if={ thumbnail }, style={ 'background-image: url(' + thumbnail + ')' }) + article + header: h1 { title } + p { description } + footer + img.icon(if={ icon }, src={ icon }) + p { sitename } + +style. + 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 + + > img + display inline-block + width 16px + heigth 16px + margin-right 4px + vertical-align bottom + + > p + display inline-block + margin 0 + color #666 + font-size 0.8em + line-height 16px + + @media (max-width 500px) + font-size 8px + + > a + border none + + > .thumbnail + width 70px + + & + article + left 70px + width calc(100% - 70px) + + > article + padding 8px + +script. + @mixin \api + + @url = @opts.url + @loading = true + + @on \mount ~> + fetch CONFIG.url + '/api:url?url=' + @url + .then (res) ~> + info <~ res.json!.then + @title = info.title + @description = info.description + @thumbnail = info.thumbnail + @icon = info.icon + @sitename = info.sitename + + @loading = false + @update! diff --git a/src/web/app/common/tags/url.tag b/src/web/app/common/tags/url.tag new file mode 100644 index 0000000000..18892e8108 --- /dev/null +++ b/src/web/app/common/tags/url.tag @@ -0,0 +1,50 @@ +mk-url + a(href={ url }, target={ opts.target }) + span.schema { schema }// + span.hostname { hostname } + span.port(if={ port != '' }) :{ port } + span.pathname(if={ pathname != '' }) { pathname } + span.query { query } + span.hash { hash } + +style. + > a + &:after + content "\f14c" + display inline-block + padding-left 2px + font-family FontAwesome + 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 + +script. + @url = @opts.href + + @on \before-mount ~> + parser = document.create-element \a + parser.href = @url + + @schema = parser.protocol + @hostname = parser.hostname + @port = parser.port + @pathname = parser.pathname + @query = parser.search + @hash = parser.hash + + @update! diff --git a/src/web/app/desktop/mixins.ls b/src/web/app/desktop/mixins.ls new file mode 100644 index 0000000000..debd89fbd4 --- /dev/null +++ b/src/web/app/desktop/mixins.ls @@ -0,0 +1,47 @@ +riot = require \riot + +module.exports = (me) ~> + riot.mixin \sortable do + Sortable: require \Sortable + + if me? + (require './scripts/stream.ls') me + + require './scripts/user-preview.ls' + + require './scripts/open-window.ls' + + riot.mixin \notify do + notify: require './scripts/notify.ls' + + dialog = require './scripts/dialog.ls' + + riot.mixin \dialog do + dialog: dialog + + riot.mixin \NotImplementedException do + NotImplementedException: ~> + dialog do + '<i class="fa fa-exclamation-triangle"></i>Not implemented yet' + '要求された操作は実装されていません。<br>→<a href="https://github.com/syuilo/misskey" target="_blank">Misskeyの開発に参加する</a>' + [ + text: \OK + ] + + riot.mixin \input-dialog do + input-dialog: require './scripts/input-dialog.ls' + + riot.mixin \update-avatar do + update-avatar: require './scripts/update-avatar.ls' + + riot.mixin \update-banner do + update-banner: require './scripts/update-banner.ls' + + riot.mixin \update-wallpaper do + update-wallpaper: require './scripts/update-wallpaper.ls' + + riot.mixin \autocomplete do + Autocomplete: require './scripts/autocomplete.ls' + + riot.mixin \follow-scroll do + Follower: require './scripts/follow-scroll.ls' diff --git a/src/web/app/desktop/resources/header-logo.svg b/src/web/app/desktop/resources/header-logo.svg new file mode 100644 index 0000000000..19b8a2737e --- /dev/null +++ b/src/web/app/desktop/resources/header-logo.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="1024px" height="512px" viewBox="0 256 1024 512" enable-background="new 0 256 1024 512" xml:space="preserve">
+<polyline opacity="0.5" fill="none" stroke="#000000" stroke-width="34" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" points="
+ 896.5,608.5 800.5,416.5 704.5,608.5 608.5,416.5 512.5,608.5 416.5,416.5 320.5,608.5 224.5,416.5 128.5,608.5 "/>
+</svg>
diff --git a/src/web/app/desktop/resources/remove.png b/src/web/app/desktop/resources/remove.png Binary files differnew file mode 100644 index 0000000000..8b1f4c06c9 --- /dev/null +++ b/src/web/app/desktop/resources/remove.png diff --git a/src/web/app/desktop/router.ls b/src/web/app/desktop/router.ls new file mode 100644 index 0000000000..02a7e11816 --- /dev/null +++ b/src/web/app/desktop/router.ls @@ -0,0 +1,77 @@ +# Router +#================================ + +riot = require \riot +route = require \page +page = null + +module.exports = (me) ~> + + # Routing + #-------------------------------- + + route \/ index + route \/i>mentions mentions + route \/post::post post + route \/search::query search + route \/:user user.bind null \home + route \/:user/graphs user.bind null \graphs + route \/:user/:post post + route \* not-found + + # Handlers + #-------------------------------- + + function index + if me? then home! else entrance! + + function home + mount document.create-element \mk-home-page + + function entrance + mount document.create-element \mk-entrance + document.document-element.set-attribute \data-page \entrance + + function mentions + document.create-element \mk-home-page + ..set-attribute \mode \mentions + .. |> mount + + function search ctx + document.create-element \mk-search-page + ..set-attribute \query ctx.params.query + .. |> mount + + function user page, ctx + document.create-element \mk-user-page + ..set-attribute \user ctx.params.user + ..set-attribute \page page + .. |> mount + + function post ctx + document.create-element \mk-post-page + ..set-attribute \post ctx.params.post + .. |> mount + + function not-found + mount document.create-element \mk-not-found + + # Register mixin + #-------------------------------- + + riot.mixin \page do + page: route + + # Exec + #-------------------------------- + + route! + +# Mount +#================================ + +function mount content + document.document-element.remove-attribute \data-page + if page? then page.unmount! + body = document.get-element-by-id \app + page := riot.mount body.append-child content .0 diff --git a/src/web/app/desktop/script.js b/src/web/app/desktop/script.js new file mode 100644 index 0000000000..473797334f --- /dev/null +++ b/src/web/app/desktop/script.js @@ -0,0 +1,42 @@ +/** + * Desktop Client + */ + +require('chart.js'); +require('./tags.ls'); +const riot = require('riot'); +const boot = require('../boot.ls'); +const mixins = require('./mixins.ls'); +const route = require('./router.ls'); +const fuckAdBlock = require('./scripts/fuck-ad-block.ls'); + +/** + * Boot + */ +boot(me => { + /** + * Fuck AD Block + */ + fuckAdBlock(); + + /** + * Init Notification + */ + if ('Notification' in window) { + // 許可を得ていなかったらリクエスト + if (Notification.permission == 'default') { + Notification.requestPermission(); + } + } + + // Register mixins + mixins(me); + + // Debug + if (me != null && me.data.debug) { + riot.mount(document.body.appendChild(document.createElement('mk-log-window'))); + } + + // Start routing + route(me); +}); diff --git a/src/web/app/desktop/scripts/autocomplete.ls b/src/web/app/desktop/scripts/autocomplete.ls new file mode 100644 index 0000000000..636bb7f277 --- /dev/null +++ b/src/web/app/desktop/scripts/autocomplete.ls @@ -0,0 +1,108 @@ +# Autocomplete +#================================ + +get-caret-coordinates = require 'textarea-caret-position' +riot = require 'riot' + +# オートコンプリートを管理するクラスです。 +class Autocomplete + + @textarea = null + @suggestion = null + + # 対象のテキストエリアを与えてインスタンスを初期化します。 + (textarea) ~> + @textarea = textarea + + # このインスタンスにあるテキストエリアの入力のキャプチャを開始します。 + attach: ~> + @textarea.add-event-listener \input @on-input + + # このインスタンスにあるテキストエリアの入力のキャプチャを解除します。 + detach: ~> + @textarea.remove-event-listener \input @on-input + @close! + + # テキスト入力時 + on-input: ~> + @close! + + caret = @textarea.selection-start + text = @textarea.value.substr 0 caret + + mention-index = text.last-index-of \@ + + if mention-index == -1 + return + + username = text.substr mention-index + 1 + + if not username.match /^[a-zA-Z0-9-]+$/ + return + + @open \user username + + # サジェストを提示します。 + open: (type, q) ~> + # 既に開いているサジェストは閉じる + @close! + + # サジェスト要素作成 + suggestion = document.create-element \mk-autocomplete-suggestion + + # ~ サジェストを表示すべき位置を計算 ~ + + caret-position = get-caret-coordinates @textarea, @textarea.selection-start + + rect = @textarea.get-bounding-client-rect! + + x = rect.left + window.page-x-offset + caret-position.left + y = rect.top + window.page-y-offset + caret-position.top + + suggestion.style.left = x + \px + suggestion.style.top = y + \px + + # 要素追加 + el = document.body.append-child suggestion + + # マウント + mounted = riot.mount el, do + textarea: @textarea + complete: @complete + close: @close + type: type + q: q + + @suggestion = mounted.0 + + # サジェストを閉じます。 + close: ~> + if !@suggestion? + return + + @suggestion.unmount! + @suggestion = null + + @textarea.focus! + + # オートコンプリートする + complete: (user) ~> + @close! + value = user.username + + caret = @textarea.selection-start + source = @textarea.value + + before = source.substr 0 caret + trimed-before = before.substring 0 before.last-index-of \@ + after = source.substr caret + + # 結果を挿入する + @textarea.value = trimed-before + \@ + value + ' ' + after + + # キャレットを戻す + @textarea.focus! + pos = caret + value.length + @textarea.set-selection-range pos, pos + +module.exports = Autocomplete diff --git a/src/web/app/desktop/scripts/dialog.ls b/src/web/app/desktop/scripts/dialog.ls new file mode 100644 index 0000000000..f3dd6cea1b --- /dev/null +++ b/src/web/app/desktop/scripts/dialog.ls @@ -0,0 +1,17 @@ +# Dialog +#================================ + +riot = require 'riot' + +module.exports = (title, text, buttons, can-through, on-through) ~> + dialog = document.body.append-child document.create-element \mk-dialog + controller = riot.observable! + riot.mount dialog, do + controller: controller + title: title + text: text + buttons: buttons + can-through: can-through + on-through: on-through + controller.trigger \open + return controller diff --git a/src/web/app/desktop/scripts/follow-scroll.ls b/src/web/app/desktop/scripts/follow-scroll.ls new file mode 100644 index 0000000000..5072e9c583 --- /dev/null +++ b/src/web/app/desktop/scripts/follow-scroll.ls @@ -0,0 +1,56 @@ +class Follower + (el) -> + @follower = el + @last-scroll-top = window.scroll-y + @initial-follower-top = @follower.get-bounding-client-rect!.top + @page-top = 48 + + follow: -> + window-height = window.inner-height + follower-height = @follower.offset-height + + scroll-top = window.scroll-y + scroll-bottom = scroll-top + window-height + + follower-top = @follower.get-bounding-client-rect!.top + scroll-top + follower-bottom = follower-top + follower-height + + height-delta = Math.abs window-height - follower-height + scroll-delta = @last-scroll-top - scroll-top + + is-scrolling-down = (scroll-top > @last-scroll-top) + is-window-larger = (window-height > follower-height) + + console.log @initial-follower-top + + if (is-window-larger && scroll-top > @initial-follower-top) || (!is-window-larger && scroll-top > @initial-follower-top + height-delta) + @follower.class-list.add \fixed + else if !is-scrolling-down && scroll-top + @page-top <= @initial-follower-top + @follower.class-list.remove \fixed + @follower.style.top = 0 + return + + drag-bottom-down = (follower-bottom <= scroll-bottom && is-scrolling-down) + drag-top-up = (follower-top >= scroll-top + @page-top && !is-scrolling-down) + + if drag-bottom-down + console.log \down + @follower.style.top = if is-window-larger then 0 else -height-delta + \px + else if drag-top-up + console.log \up + @follower.style.top = @page-top + \px + else if @follower.class-list.contains \fixed + console.log \- + current-top = parse-int @follower.style.top, 10 + + min-top = -height-delta + scrolled-top = current-top + scroll-delta + + is-page-at-bottom = (scroll-top + window-height >= document.body.offset-height) + new-top = if is-page-at-bottom then min-top else scrolled-top + + @follower.style.top = new-top + \px + + @last-scroll-top = scroll-top + +module.exports = Follower diff --git a/src/web/app/desktop/scripts/fuck-ad-block.ls b/src/web/app/desktop/scripts/fuck-ad-block.ls new file mode 100644 index 0000000000..55431fcd00 --- /dev/null +++ b/src/web/app/desktop/scripts/fuck-ad-block.ls @@ -0,0 +1,19 @@ +# FUCK AD BLOCK +#================================ + +require 'fuck-adblock' +dialog = require './dialog.ls' + +module.exports = ~> + if fuck-ad-block == undefined + ad-block-detected! + else + fuck-ad-block.on-detected ad-block-detected + +function ad-block-detected + dialog do + '<i class="fa fa-exclamation-triangle"></i>広告ブロッカーを無効にしてください' + '<strong>Misskeyは広告を掲載していません</strong>が、広告をブロックする機能が有効だと一部の機能が利用できなかったり、不具合が発生する場合があります。' + [ + text: \OK + ] diff --git a/src/web/app/desktop/scripts/input-dialog.ls b/src/web/app/desktop/scripts/input-dialog.ls new file mode 100644 index 0000000000..f75b12dd01 --- /dev/null +++ b/src/web/app/desktop/scripts/input-dialog.ls @@ -0,0 +1,13 @@ +# Input Dialog +#================================ + +riot = require 'riot' + +module.exports = (title, placeholder, default-value, on-ok, on-cancel) ~> + dialog = document.body.append-child document.create-element \mk-input-dialog + riot.mount dialog, do + title: title + placeholder: placeholder + default: default-value + on-ok: on-ok + on-cancel: on-cancel diff --git a/src/web/app/desktop/scripts/notify.ls b/src/web/app/desktop/scripts/notify.ls new file mode 100644 index 0000000000..919bbc3dcf --- /dev/null +++ b/src/web/app/desktop/scripts/notify.ls @@ -0,0 +1,6 @@ +riot = require \riot + +module.exports = (message) ~> + notification = document.body.append-child document.create-element \mk-ui-notification + riot.mount notification, do + message: message diff --git a/src/web/app/desktop/scripts/open-window.ls b/src/web/app/desktop/scripts/open-window.ls new file mode 100644 index 0000000000..4388272ecf --- /dev/null +++ b/src/web/app/desktop/scripts/open-window.ls @@ -0,0 +1,8 @@ +riot = require \riot + +function open(name, opts) + window = document.body.append-child document.create-element name + riot.mount window, opts + +riot.mixin \open-window do + open-window: open diff --git a/src/web/app/desktop/scripts/stream.ls b/src/web/app/desktop/scripts/stream.ls new file mode 100644 index 0000000000..f84d6097a7 --- /dev/null +++ b/src/web/app/desktop/scripts/stream.ls @@ -0,0 +1,38 @@ +# Stream +#================================ + +stream = require '../../common/scripts/stream.ls' +get-post-summary = require '../../common/scripts/get-post-summary.ls' +riot = require \riot + +module.exports = (me) ~> + s = stream me + + s.event.on \drive_file_created (file) ~> + n = new Notification 'ファイルがアップロードされました' do + body: file.name + icon: file.url + '?thumbnail&size=64' + set-timeout (n.close.bind n), 5000ms + + s.event.on \mention (post) ~> + n = new Notification "#{post.user.name}さんから:" do + body: get-post-summary post + icon: post.user.avatar_url + '?thumbnail&size=64' + set-timeout (n.close.bind n), 6000ms + + s.event.on \reply (post) ~> + n = new Notification "#{post.user.name}さんから返信:" do + body: get-post-summary post + icon: post.user.avatar_url + '?thumbnail&size=64' + set-timeout (n.close.bind n), 6000ms + + s.event.on \quote (post) ~> + n = new Notification "#{post.user.name}さんが引用:" do + body: get-post-summary post + icon: post.user.avatar_url + '?thumbnail&size=64' + set-timeout (n.close.bind n), 6000ms + + riot.mixin \stream do + stream: s.event + get-stream-state: s.get-state + stream-state-ev: s.state-ev diff --git a/src/web/app/desktop/scripts/update-avatar.ls b/src/web/app/desktop/scripts/update-avatar.ls new file mode 100644 index 0000000000..513a59074c --- /dev/null +++ b/src/web/app/desktop/scripts/update-avatar.ls @@ -0,0 +1,81 @@ +# Update Avatar +#================================ + +riot = require 'riot' +dialog = require './dialog.ls' +api = require '../../common/scripts/api.ls' + +module.exports = (I, cb, file = null) ~> + + @file-selected = (file) ~> + cropper = document.body.append-child document.create-element \mk-crop-window + cropper = riot.mount cropper, do + file: file + title: 'アバターとして表示する部分を選択' + aspect-ratio: 1 / 1 + .0 + cropper.on \cropped (blob) ~> + data = new FormData! + data.append \i I.token + data.append \file blob, file.name + '.cropped.png' + api I, \drive/folders/find do + name: 'アイコン' + .then (icon-folder) ~> + if icon-folder.length == 0 + api I, \drive/folders/create do + name: 'アイコン' + .then (icon-folder) ~> + @uplaod data, icon-folder + else + @uplaod data, icon-folder.0 + cropper.on \skiped ~> + @set file + + @uplaod = (data, folder) ~> + + progress = document.body.append-child document.create-element \mk-progress-dialog + progress = riot.mount progress, do + title: '新しいアバターをアップロードしています' + .0 + + if folder? + data.append \folder_id folder.id + + xhr = new XMLHttpRequest! + xhr.open \POST CONFIG.api.url + \/drive/files/create true + xhr.onload = (e) ~> + file = JSON.parse e.target.response + progress.close! + @set file + + xhr.upload.onprogress = (e) ~> + if e.length-computable + progress.update-progress e.loaded, e.total + + xhr.send data + + @set = (file) ~> + api I, \i/update do + avatar_id: file.id + .then (i) ~> + dialog do + '<i class="fa fa-info-circle"></i>アバターを更新しました' + '新しいアバターが反映されるまで時間がかかる場合があります。' + [ + text: \わかった + ] + if cb? then cb i + .catch (err) ~> + console.error err + #@opts.ui.trigger \notification 'Error!' + + if file? + @file-selected file + else + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + browser = riot.mount browser, do + multiple: false + title: '<i class="fa fa-picture-o"></i>アバターにする画像を選択' + .0 + browser.one \selected (file) ~> + @file-selected file diff --git a/src/web/app/desktop/scripts/update-banner.ls b/src/web/app/desktop/scripts/update-banner.ls new file mode 100644 index 0000000000..5754cdcdb1 --- /dev/null +++ b/src/web/app/desktop/scripts/update-banner.ls @@ -0,0 +1,81 @@ +# Update Banner +#================================ + +riot = require 'riot' +dialog = require './dialog.ls' +api = require '../../common/scripts/api.ls' + +module.exports = (I, cb, file = null) ~> + + @file-selected = (file) ~> + cropper = document.body.append-child document.create-element \mk-crop-window + cropper = riot.mount cropper, do + file: file + title: 'バナーとして表示する部分を選択' + aspect-ratio: 16 / 9 + .0 + cropper.on \cropped (blob) ~> + data = new FormData! + data.append \i I.token + data.append \file blob, file.name + '.cropped.png' + api I, \drive/folders/find do + name: 'バナー' + .then (banner-folder) ~> + if banner-folder.length == 0 + api I, \drive/folders/create do + name: 'バナー' + .then (banner-folder) ~> + @uplaod data, banner-folder + else + @uplaod data, banner-folder.0 + cropper.on \skiped ~> + @set file + + @uplaod = (data, folder) ~> + + progress = document.body.append-child document.create-element \mk-progress-dialog + progress = riot.mount progress, do + title: '新しいバナーをアップロードしています' + .0 + + if folder? + data.append \folder_id folder.id + + xhr = new XMLHttpRequest! + xhr.open \POST CONFIG.api.url + \/drive/files/create true + xhr.onload = (e) ~> + file = JSON.parse e.target.response + progress.close! + @set file + + xhr.upload.onprogress = (e) ~> + if e.length-computable + progress.update-progress e.loaded, e.total + + xhr.send data + + @set = (file) ~> + api I, \i/update do + banner_id: file.id + .then (i) ~> + dialog do + '<i class="fa fa-info-circle"></i>バナーを更新しました' + '新しいバナーが反映されるまで時間がかかる場合があります。' + [ + text: \わかりました。 + ] + if cb? then cb i + .catch (err) ~> + console.error err + #@opts.ui.trigger \notification 'Error!' + + if file? + @file-selected file + else + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + browser = riot.mount browser, do + multiple: false + title: '<i class="fa fa-picture-o"></i>バナーにする画像を選択' + .0 + browser.one \selected (file) ~> + @file-selected file diff --git a/src/web/app/desktop/scripts/update-wallpaper.ls b/src/web/app/desktop/scripts/update-wallpaper.ls new file mode 100644 index 0000000000..49632400c0 --- /dev/null +++ b/src/web/app/desktop/scripts/update-wallpaper.ls @@ -0,0 +1,35 @@ +# Update Wallpaper +#================================ + +riot = require 'riot' +dialog = require './dialog.ls' +api = require '../../common/scripts/api.ls' + +module.exports = (I, cb, file = null) ~> + + @set = (file) ~> + api I, \i/appdata/set do + data: JSON.stringify do + wallpaper: file.id + .then (i) ~> + dialog do + '<i class="fa fa-info-circle"></i>壁紙を更新しました' + '新しい壁紙が反映されるまで時間がかかる場合があります。' + [ + text: \はい + ] + if cb? then cb i + .catch (err) ~> + console.error err + #@opts.ui.trigger \notification 'Error!' + + if file? + @set file + else + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + browser = riot.mount browser, do + multiple: false + title: '<i class="fa fa-picture-o"></i>壁紙にする画像を選択' + .0 + browser.one \selected (file) ~> + @set file diff --git a/src/web/app/desktop/scripts/user-preview.ls b/src/web/app/desktop/scripts/user-preview.ls new file mode 100644 index 0000000000..0c5a67aedb --- /dev/null +++ b/src/web/app/desktop/scripts/user-preview.ls @@ -0,0 +1,74 @@ +# User Preview +#================================ + +riot = require \riot + +riot.mixin \user-preview do + init: -> + @on \mount ~> + scan.call @ + @on \updated ~> + scan.call @ + + function scan + elems = @root.query-selector-all '[data-user-preview]:not([data-user-preview-attached])' + elems.for-each attach.bind @ + +function attach el + el.set-attribute \data-user-preview-attached true + user = el.get-attribute \data-user-preview + + tag = null + + show-timer = null + hide-timer = null + + el.add-event-listener \mouseover ~> + clear-timeout show-timer + clear-timeout hide-timer + show-timer := set-timeout ~> + show! + , 500ms + + el.add-event-listener \mouseleave ~> + clear-timeout show-timer + clear-timeout hide-timer + hide-timer := set-timeout ~> + close! + , 500ms + + @on \unmount ~> + clear-timeout show-timer + clear-timeout hide-timer + close! + + function show + if tag? + return + + preview = document.create-element \mk-user-preview + + rect = el.get-bounding-client-rect! + x = rect.left + el.offset-width + window.page-x-offset + y = rect.top + window.page-y-offset + + preview.style.top = y + \px + preview.style.left = x + \px + + preview.add-event-listener \mouseover ~> + clear-timeout hide-timer + + preview.add-event-listener \mouseleave ~> + clear-timeout show-timer + hide-timer := set-timeout ~> + close! + , 500ms + + tag := riot.mount (document.body.append-child preview), do + user: user + .0 + + function close + if tag? + tag.close! + tag := null diff --git a/src/web/app/desktop/style.styl b/src/web/app/desktop/style.styl new file mode 100644 index 0000000000..fa50f6ce31 --- /dev/null +++ b/src/web/app/desktop/style.styl @@ -0,0 +1,114 @@ +@import "../base" +@import "../../../../node_modules/cropperjs/dist/cropper.css" + +*::input-placeholder + color #D8CBC5 + +* + &:focus + outline none + + &::scrollbar + width 5px + background transparent + + &:horizontal + height 5px + + &::scrollbar-button + width 0 + height 0 + background rgba(0, 0, 0, 0.2) + + &::scrollbar-piece + background transparent + + &:start + background transparent + + &::scrollbar-thumb + background rgba(0, 0, 0, 0.2) + + &:hover + background rgba(0, 0, 0, 0.4) + + &:active + background $theme-color + + &::scrollbar-corner + background rgba(0, 0, 0, 0.2) + +html + background #fdfdfd + + // ↓ workaround of https://github.com/riot/riot/issues/2134 + &[data-page='entrance'] + #wait + right auto + left 15px + +html[theme='dark'] + background #100f0f + +button + font-family sans-serif + + * + pointer-events none + + &.style-normal + &.style-primary + display block + cursor pointer + padding 0 16px + margin 0 + min-width 100px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + &.style-normal + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.style-primary + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color diff --git a/src/web/app/desktop/tags.ls b/src/web/app/desktop/tags.ls new file mode 100644 index 0000000000..f78d367341 --- /dev/null +++ b/src/web/app/desktop/tags.ls @@ -0,0 +1,103 @@ +require './tags/contextmenu.tag' +require './tags/dialog.tag' +require './tags/window.tag' +require './tags/input-dialog.tag' +require './tags/follow-button.tag' +require './tags/drive/base-contextmenu.tag' +require './tags/drive/file-contextmenu.tag' +require './tags/drive/folder-contextmenu.tag' +require './tags/drive/file.tag' +require './tags/drive/folder.tag' +require './tags/drive/nav-folder.tag' +require './tags/drive/browser-window.tag' +require './tags/drive/browser.tag' +require './tags/select-file-from-drive-window.tag' +require './tags/crop-window.tag' +require './tags/settings.tag' +require './tags/settings-window.tag' +require './tags/analog-clock.tag' +require './tags/go-top.tag' +require './tags/ui-header.tag' +require './tags/ui-header-account.tag' +require './tags/ui-header-notifications.tag' +require './tags/ui-header-clock.tag' +require './tags/ui-header-nav.tag' +require './tags/ui-header-post-button.tag' +require './tags/ui-header-search.tag' +require './tags/notifications.tag' +require './tags/post-form-window.tag' +require './tags/post-form.tag' +require './tags/timeline-post.tag' +require './tags/post-preview.tag' +require './tags/repost-form-window.tag' +require './tags/home-widgets/user-recommendation.tag' +require './tags/home-widgets/timeline.tag' +require './tags/home-widgets/mentions.tag' +require './tags/home-widgets/calendar.tag' +require './tags/home-widgets/donation.tag' +require './tags/home-widgets/tips.tag' +require './tags/home-widgets/nav.tag' +require './tags/home-widgets/profile.tag' +require './tags/home-widgets/notifications.tag' +require './tags/home-widgets/rss-reader.tag' +require './tags/home-widgets/photo-stream.tag' +require './tags/home-widgets/broadcast.tag' +require './tags/stream-indicator.tag' +require './tags/timeline.tag' +require './tags/messaging/window.tag' +require './tags/messaging/room.tag' +require './tags/messaging/room-window.tag' +require './tags/messaging/message.tag' +require './tags/messaging/index.tag' +require './tags/messaging/form.tag' +require './tags/following-setuper.tag' +require './tags/ellipsis-icon.tag' +require './tags/ui.tag' +require './tags/home.tag' +require './tags/detect-slow-internet-connection-notice.tag' +require './tags/user-header.tag' +require './tags/user-profile.tag' +require './tags/user-timeline.tag' +require './tags/user.tag' +require './tags/user-home.tag' +require './tags/user-graphs.tag' +require './tags/user-photos.tag' +require './tags/big-follow-button.tag' +require './tags/pages/entrance.tag' +require './tags/pages/entrance/signin.tag' +require './tags/pages/entrance/signup.tag' +require './tags/pages/home.tag' +require './tags/pages/user.tag' +require './tags/pages/post.tag' +require './tags/pages/search.tag' +require './tags/pages/not-found.tag' +require './tags/autocomplete-suggestion.tag' +require './tags/progress-dialog.tag' +require './tags/user-preview.tag' +require './tags/post-detail.tag' +require './tags/post-detail-sub.tag' +require './tags/search.tag' +require './tags/search-posts.tag' +require './tags/set-avatar-suggestion.tag' +require './tags/set-banner-suggestion.tag' +require './tags/repost-form.tag' +require './tags/timeline-post-sub.tag' +require './tags/sub-post-content.tag' +require './tags/images-viewer.tag' +require './tags/image-dialog.tag' +require './tags/donation.tag' +require './tags/user-posts-graph.tag' +require './tags/user-friends-graph.tag' +require './tags/user-likes-graph.tag' +require './tags/post-status-graph.tag' +require './tags/debugger.tag' +require './tags/users-list.tag' +require './tags/user-following.tag' +require './tags/user-followers.tag' +require './tags/user-following-window.tag' +require './tags/user-followers-window.tag' +require './tags/list-user.tag' +require './tags/ui-notification.tag' +require './tags/signin-history.tag' +require './tags/log.tag' +require './tags/log-window.tag' diff --git a/src/web/app/desktop/tags/analog-clock.tag b/src/web/app/desktop/tags/analog-clock.tag new file mode 100644 index 0000000000..a4cfe5726c --- /dev/null +++ b/src/web/app/desktop/tags/analog-clock.tag @@ -0,0 +1,102 @@ +mk-analog-clock + canvas@canvas(width='256', height='256') + +style. + > canvas + display block + width 256px + height 256px + +script. + @on \mount ~> + @draw! + @clock = set-interval @draw, 1000ms + + @on \unmount ~> + clear-interval @clock + + @draw = ~> + now = new Date! + s = now.get-seconds! + m = now.get-minutes! + h = now.get-hours! + + vec2 = (x, y) -> + @x = x + @y = y + + ctx = @refs.canvas.get-context \2d + canv-w = @refs.canvas.width + canv-h = @refs.canvas.height + ctx.clear-rect 0, 0, canv-w, canv-h + + # 背景 + center = (Math.min (canv-w / 2), (canv-h / 2)) + line-start = center * 0.90 + line-end-short = center * 0.87 + line-end-long = center * 0.84 + for i from 0 to 59 by 1 + angle = Math.PI * i / 30 + uv = new vec2 (Math.sin angle), (-Math.cos angle) + ctx.begin-path! + ctx.line-width = 1 + ctx.move-to do + (canv-w / 2) + uv.x * line-start + (canv-h / 2) + uv.y * line-start + if i % 5 == 0 + ctx.stroke-style = 'rgba(255, 255, 255, 0.2)' + ctx.line-to do + (canv-w / 2) + uv.x * line-end-long + (canv-h / 2) + uv.y * line-end-long + else + ctx.stroke-style = 'rgba(255, 255, 255, 0.1)' + ctx.line-to do + (canv-w / 2) + uv.x * line-end-short + (canv-h / 2) + uv.y * line-end-short + ctx.stroke! + + # 分 + angle = Math.PI * (m + s / 60) / 30 + length = (Math.min canv-w, canv-h) / 2.6 + uv = new vec2 (Math.sin angle), (-Math.cos angle) + ctx.begin-path! + ctx.stroke-style = \#ffffff + ctx.line-width = 2 + ctx.move-to do + (canv-w / 2) - uv.x * length / 5 + (canv-h / 2) - uv.y * length / 5 + ctx.line-to do + (canv-w / 2) + uv.x * length + (canv-h / 2) + uv.y * length + ctx.stroke! + + # 時 + angle = Math.PI * (h % 12 + m / 60) / 6 + length = (Math.min canv-w, canv-h) / 4 + uv = new vec2 (Math.sin angle), (-Math.cos angle) + ctx.begin-path! + #ctx.stroke-style = \#ffffff + ctx.stroke-style = CONFIG.theme-color + ctx.line-width = 2 + ctx.move-to do + (canv-w / 2) - uv.x * length / 5 + (canv-h / 2) - uv.y * length / 5 + ctx.line-to do + (canv-w / 2) + uv.x * length + (canv-h / 2) + uv.y * length + ctx.stroke! + + # 秒 + angle = Math.PI * s / 30 + length = (Math.min canv-w, canv-h) / 2.6 + uv = new vec2 (Math.sin angle), (-Math.cos angle) + ctx.begin-path! + ctx.stroke-style = 'rgba(255, 255, 255, 0.5)' + ctx.line-width = 1 + ctx.move-to do + (canv-w / 2) - uv.x * length / 5 + (canv-h / 2) - uv.y * length / 5 + ctx.line-to do + (canv-w / 2) + uv.x * length + (canv-h / 2) + uv.y * length + ctx.stroke! diff --git a/src/web/app/desktop/tags/autocomplete-suggestion.tag b/src/web/app/desktop/tags/autocomplete-suggestion.tag new file mode 100644 index 0000000000..13d9df6914 --- /dev/null +++ b/src/web/app/desktop/tags/autocomplete-suggestion.tag @@ -0,0 +1,182 @@ +mk-autocomplete-suggestion + ol.users@users(if={ users.length > 0 }) + li(each={ users }, onclick={ parent.on-click }, onkeydown={ parent.on-keydown }, tabindex='-1') + img.avatar(src={ avatar_url + '?thumbnail&size=32' }, alt='') + span.name { name } + span.username @{ username } + +style. + display block + position absolute + z-index 65535 + margin-top calc(1em + 8px) + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + + > .users + display block + margin 0 + padding 4px 0 + max-height 190px + max-width 500px + overflow auto + list-style none + + > li + display block + padding 4px 12px + white-space nowrap + overflow hidden + font-size 0.9em + color rgba(0, 0, 0, 0.8) + cursor default + + &, * + user-select none + + &:hover + &[data-selected='true'] + color #fff + background $theme-color + + .name + color #fff + + .username + color #fff + + &:active + color #fff + background darken($theme-color, 10%) + + .name + color #fff + + .username + color #fff + + .avatar + vertical-align middle + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 100% + + .name + margin 0 8px 0 0 + /*font-weight bold*/ + font-weight normal + color rgba(0, 0, 0, 0.8) + + .username + font-weight normal + color rgba(0, 0, 0, 0.3) + +script. + @mixin \api + + @q = @opts.q + @textarea = @opts.textarea + @loading = true + @users = [] + @select = -1 + + @on \mount ~> + @textarea.add-event-listener \keydown @on-keydown + + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.add-event-listener \mousedown @mousedown + + @api \users/search_by_username do + query: @q + limit: 30users + .then (users) ~> + @users = users + @loading = false + @update! + .catch (err) ~> + console.error err + + @on \unmount ~> + @textarea.remove-event-listener \keydown @on-keydown + + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.remove-event-listener \mousedown @mousedown + + @mousedown = (e) ~> + if (!contains @root, e.target) and (@root != e.target) + @close! + + @on-click = (e) ~> + @complete e.item + + @on-keydown = (e) ~> + key = e.which + switch (key) + | 10, 13 => # Key[ENTER] + if @select != -1 + e.prevent-default! + e.stop-propagation! + @complete @users[@select] + else + @close! + | 27 => # Key[ESC] + e.prevent-default! + e.stop-propagation! + @close! + | 38 => # Key[↑] + if @select != -1 + e.prevent-default! + e.stop-propagation! + @select-prev! + else + @close! + | 9, 40 => # Key[TAB] or Key[↓] + e.prevent-default! + e.stop-propagation! + @select-next! + | _ => + @close! + + @select-next = ~> + @select++ + + if @select >= @users.length + @select = 0 + + @apply-select! + + @select-prev = ~> + @select-- + + if @select < 0 + @select = @users.length - 1 + + @apply-select! + + @apply-select = ~> + @refs.users.children.for-each (el) ~> + el.remove-attribute \data-selected + + @refs.users.children[@select].set-attribute \data-selected \true + @refs.users.children[@select].focus! + + @complete = (user) ~> + @opts.complete user + + @close = ~> + @opts.close! + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/big-follow-button.tag b/src/web/app/desktop/tags/big-follow-button.tag new file mode 100644 index 0000000000..636853407c --- /dev/null +++ b/src/web/app/desktop/tags/big-follow-button.tag @@ -0,0 +1,134 @@ +mk-big-follow-button + button(if={ !init }, class={ wait: wait, follow: !user.is_following, unfollow: user.is_following }, + onclick={ onclick }, + disabled={ wait }, + title={ user.is_following ? 'フォロー解除' : 'フォローする' }) + span(if={ !wait && user.is_following }) + i.fa.fa-minus + | フォロー解除 + span(if={ !wait && !user.is_following }) + i.fa.fa-plus + | フォロー + i.fa.fa-spinner.fa-pulse.fa-fw(if={ wait }) + div.init(if={ init }): i.fa.fa-spinner.fa-pulse.fa-fw + +style. + display block + + > button + > .init + display block + cursor pointer + padding 0 + margin 0 + width 100% + line-height 38px + font-size 1em + outline none + border-radius 4px + + * + pointer-events none + + i + margin-right 8px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &.follow + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.unfollow + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + &.wait + cursor wait !important + opacity 0.7 + +script. + @mixin \api + @mixin \is-promise + @mixin \stream + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + @init = true + @wait = false + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @init = false + @update! + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + @on \unmount ~> + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + @on-stream-follow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @on-stream-unfollow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @onclick = ~> + @wait = true + if @user.is_following + @api \following/delete do + user_id: @user.id + .then ~> + @user.is_following = false + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! + else + @api \following/create do + user_id: @user.id + .then ~> + @user.is_following = true + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! diff --git a/src/web/app/desktop/tags/contextmenu.tag b/src/web/app/desktop/tags/contextmenu.tag new file mode 100644 index 0000000000..7c3c7b8a24 --- /dev/null +++ b/src/web/app/desktop/tags/contextmenu.tag @@ -0,0 +1,138 @@ +mk-contextmenu + | <yield /> + +style. + $width = 240px + $item-height = 38px + $padding = 10px + + display none + position fixed + top 0 + left 0 + z-index 4096 + width $width + font-size 0.8em + background #fff + border-radius 0 4px 4px 4px + box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2) + + ul + display block + margin 0 + padding $padding 0 + list-style none + + li + display block + + &.separator + margin-top $padding + padding-top $padding + border-top solid 1px #eee + + &.has-child + > p + cursor default + + > i:last-child + position absolute + top 0 + right 8px + line-height $item-height + + &:hover > ul + visibility visible + + &:active + > p, a + background $theme-color + + > p, a + display block + z-index 1 + margin 0 + padding 0 32px 0 38px + line-height $item-height + color #868C8C + text-decoration none + cursor pointer + + &:hover + text-decoration none + + * + pointer-events none + + > i + width 28px + margin-left -28px + text-align center + + &:hover + > p, a + text-decoration none + background $theme-color + color $theme-color-foreground + + &:active + > p, a + text-decoration none + background darken($theme-color, 10%) + color $theme-color-foreground + + li > ul + visibility hidden + position absolute + top 0 + left $width + margin-top -($padding) + width $width + background #fff + border-radius 0 4px 4px 4px + box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2) + transition visibility 0s linear 0.2s + +script. + + @root.add-event-listener \contextmenu (e) ~> + e.prevent-default! + + @mousedown = (e) ~> + e.prevent-default! + if (!contains @root, e.target) and (@root != e.target) + @close! + return false + + @open = (pos) ~> + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.add-event-listener \mousedown @mousedown + @root.style.display = \block + @root.style.left = pos.x + \px + @root.style.top = pos.y + \px + + Velocity @root, \finish true + Velocity @root, { opacity: 0 } 0ms + Velocity @root, { + opacity: 1 + } { + queue: false + duration: 100ms + easing: \linear + } + + @close = ~> + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.remove-event-listener \mousedown @mousedown + @trigger \closed + @unmount! + + function contains(parent, child) + node = child.parent-node + while (node != null) + if (node == parent) + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/crop-window.tag b/src/web/app/desktop/tags/crop-window.tag new file mode 100644 index 0000000000..16e1a72b3a --- /dev/null +++ b/src/web/app/desktop/tags/crop-window.tag @@ -0,0 +1,189 @@ +mk-crop-window + mk-window@window(is-modal={ true }, width={ '800px' }) + <yield to="header"> + i.fa.fa-crop + | { parent.title } + </yield> + <yield to="content"> + div.body + img@img(src={ parent.image.url + '?thumbnail&quality=80' }, alt='') + div.action + button.skip(onclick={ parent.skip }) クロップをスキップ + button.cancel(onclick={ parent.cancel }) キャンセル + button.ok(onclick={ parent.ok }) 決定 + </yield> + +style. + display block + + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + + > .body + > img + width 100% + max-height 400px + + .cropper-modal { + opacity: 0.8; + } + + .cropper-view-box { + outline-color: $theme-color; + } + + .cropper-line, .cropper-point { + background-color: $theme-color; + } + + .cropper-bg { + animation: cropper-bg 0.5s linear infinite; + } + + @-webkit-keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } + } + + @-moz-keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } + } + + @-ms-keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } + } + + @keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } + } + + > .action + height 72px + background lighten($theme-color, 95%) + + .ok + .cancel + .skip + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + .ok + .cancel + width 120px + + .ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + .cancel + .skip + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + .cancel + right 148px + + .skip + left 16px + width 150px + +script. + @mixin \cropper + + @image = @opts.file + @title = @opts.title + @aspect-ratio = @opts.aspect-ratio + @cropper = null + + @on \mount ~> + @img = @refs.window.refs.img + @cropper = new @Cropper @img, do + aspect-ratio: @aspect-ratio + highlight: no + view-mode: 1 + + @ok = ~> + @cropper.get-cropped-canvas!.to-blob (blob) ~> + @trigger \cropped blob + @refs.window.close! + + @skip = ~> + @trigger \skiped + @refs.window.close! + + @cancel = ~> + @trigger \canceled + @refs.window.close! diff --git a/src/web/app/desktop/tags/debugger.tag b/src/web/app/desktop/tags/debugger.tag new file mode 100644 index 0000000000..e2b522cb00 --- /dev/null +++ b/src/web/app/desktop/tags/debugger.tag @@ -0,0 +1,87 @@ +mk-debugger + mk-window@window(is-modal={ false }, width={ '700px' }, height={ '550px' }) + <yield to="header"> + i.fa.fa-wrench + | Debugger + </yield> + <yield to="content"> + section.progress-dialog + h1 progress-dialog + button.style-normal(onclick={ parent.progress-dialog }): i.fa.fa-play + button.style-normal(onclick={ parent.progress-dialog-destroy }): i.fa.fa-stop + label + p TITLE: + input@progress-title(value='Title') + label + p VAL: + input@progress-value(type='number', oninput={ parent.progress-change }, value=0) + label + p MAX: + input@progress-max(type='number', oninput={ parent.progress-change }, value=100) + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + overflow auto + + > section + padding 32px + + // & + section + // margin-top 16px + + > h1 + display block + margin 0 + padding 0 0 8px 0 + font-size 1em + color #555 + border-bottom solid 1px #eee + + > label + display block + + > p + display inline + margin 0 + + > .progress-dialog + button + display inline-block + margin 8px + +script. + @mixin \open-window + + @on \mount ~> + @progress-title = @tags['mk-window'].progress-title + @progress-value = @tags['mk-window'].progress-value + @progress-max = @tags['mk-window'].progress-max + + @refs.window.on \closed ~> + @unmount! + + ################################ + + @progress-controller = riot.observable! + + @progress-dialog = ~> + @open-window \mk-progress-dialog do + title: @progress-title.value + value: @progress-value.value + max: @progress-max.value + controller: @progress-controller + + @progress-change = ~> + @progress-controller.trigger do + \update + @progress-value.value + @progress-max.value + + @progress-dialog-destroy = ~> + @progress-controller.trigger \close diff --git a/src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag b/src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag new file mode 100644 index 0000000000..f11a0c0857 --- /dev/null +++ b/src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag @@ -0,0 +1,56 @@ +mk-detect-slow-internet-connection-notice + i: i.fa.fa-exclamation + div: p インターネット回線が遅いようです。 + +style. + display block + pointer-events none + position fixed + z-index 16384 + top 64px + right 16px + margin 0 + padding 0 + width 298px + font-size 0.9em + background #fff + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + opacity 0 + + > i + display block + width 48px + line-height 48px + margin-right 0.25em + text-align center + color $theme-color-foreground + font-size 1.5em + background $theme-color + + > div + display block + position absolute + top 0 + left 48px + margin 0 + width 250px + height 48px + color #666 + + > p + display block + margin 0 + padding 8px + +script. + @mixin \net + + @net.on \detected-slow-network ~> + Velocity @root, { + opacity: 1 + } 200ms \linear + set-timeout ~> + Velocity @root, { + opacity: 0 + } 200ms \linear + , 10000ms diff --git a/src/web/app/desktop/tags/dialog.tag b/src/web/app/desktop/tags/dialog.tag new file mode 100644 index 0000000000..88a461db84 --- /dev/null +++ b/src/web/app/desktop/tags/dialog.tag @@ -0,0 +1,141 @@ +mk-dialog + div.bg@bg(onclick={ bg-click }) + div.main@main + header@header + div.body@body + div.buttons + virtual(each={ opts.buttons }) + button(onclick={ _onclick }) { text } + +style. + display block + + > .bg + display block + position fixed + z-index 8192 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + opacity 0 + pointer-events none + + > .main + display block + position fixed + z-index 8192 + top 20% + left 0 + right 0 + margin 0 auto 0 auto + padding 32px 42px + width 480px + background #fff + + > header + margin 1em 0 + color $theme-color + // color #43A4EC + font-weight bold + + > i + margin-right 0.5em + + > .body + margin 1em 0 + color #888 + + > .buttons + > button + display inline-block + float right + margin 0 + padding 10px 10px + font-size 1.1em + font-weight normal + text-decoration none + color #888 + background transparent + outline none + border none + border-radius 0 + cursor pointer + transition color 0.1s ease + + i + margin 0 0.375em + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + +script. + @can-through = if opts.can-through? then opts.can-through else true + @opts.buttons.for-each (button) ~> + button._onclick = ~> + if button.onclick? + button.onclick! + @close! + + @on \mount ~> + @refs.header.innerHTML = @opts.title + @refs.body.innerHTML = @opts.text + + @refs.bg.style.pointer-events = \auto + Velocity @refs.bg, \finish true + Velocity @refs.bg, { + opacity: 1 + } { + queue: false + duration: 100ms + easing: \linear + } + + Velocity @refs.main, { + opacity: 0 + scale: 1.2 + } { + duration: 0 + } + Velocity @refs.main, { + opacity: 1 + scale: 1 + } { + duration: 300ms + easing: [ 0, 0.5, 0.5, 1 ] + } + + @close = ~> + @refs.bg.style.pointer-events = \none + Velocity @refs.bg, \finish true + Velocity @refs.bg, { + opacity: 0 + } { + queue: false + duration: 300ms + easing: \linear + } + + @refs.main.style.pointer-events = \none + Velocity @refs.main, \finish true + Velocity @refs.main, { + opacity: 0 + scale: 0.8 + } { + queue: false + duration: 300ms + easing: [ 0.5, -0.5, 1, 0.5 ] + complete: ~> + @unmount! + } + + @bg-click = ~> + if @can-through + if @opts.on-through? + @opts.on-through! + @close! diff --git a/src/web/app/desktop/tags/donation.tag b/src/web/app/desktop/tags/donation.tag new file mode 100644 index 0000000000..9f8a1a6729 --- /dev/null +++ b/src/web/app/desktop/tags/donation.tag @@ -0,0 +1,63 @@ +mk-donation + button.close(onclick={ close }) 閉じる x + div.message + p 利用者の皆さま、 + p + | 今日は、日本の皆さまにお知らせがあります。 + | Misskeyの援助をお願いいたします。 + | 私は独立性を守るため、一切の広告を掲載いたしません。 + | 平均で約¥1,500の寄付をいただき、運営しております。 + | 援助をしてくださる利用者はほんの少数です。 + | お願いいたします。 + | 今日、利用者の皆さまが¥300ご援助くだされば、募金活動を一時間で終了することができます。 + | コーヒー1杯ほどの金額です。 + | Misskeyを活用しておられるのでしたら、広告を掲載せずにもう1年活動できるよう、どうか1分だけお時間をください。 + | 私は小さな非営利個人ですが、サーバー、プログラム、人件費など、世界でトップクラスのウェブサイト同等のコストがかかります。 + | 利用者は何億人といますが、他の大きなサイトに比べてほんの少額の費用で運営しているのです。 + | 人間の可能性、自由、そして機会。知識こそ、これらの基盤を成すものです。 + | 私は、誰もが無料かつ制限なく知識に触れられるべきだと信じています。 + | 募金活動を終了し、Misskeyの改善に戻れるようご援助ください。 + | よろしくお願いいたします。 + +style. + display block + color #fff + background #03072C + + > .close + position absolute + top 16px + right 16px + z-index 1 + + > .message + padding 32px + font-size 1.4em + font-family serif + + > p + display block + margin 0 auto + max-width 1200px + + > p:first-child + margin-bottom 16px + +script. + @mixin \api + @mixin \i + + @close = (e) ~> + e.prevent-default! + e.stop-propagation! + + @I.data.no_donation = true + @api \i/appdata/set do + data: JSON.stringify do + no_donation: @I.data.no_donation + .then ~> + @update-i! + + @unmount! + + @parent.parent.set-root-layout! diff --git a/src/web/app/desktop/tags/drive/base-contextmenu.tag b/src/web/app/desktop/tags/drive/base-contextmenu.tag new file mode 100644 index 0000000000..c8b51009ea --- /dev/null +++ b/src/web/app/desktop/tags/drive/base-contextmenu.tag @@ -0,0 +1,28 @@ +mk-drive-browser-base-contextmenu + mk-contextmenu@ctx + ul + li(onclick={ parent.create-folder }): p + i.fa.fa-folder-o + | フォルダーを作成 + li(onclick={ parent.upload }): p + i.fa.fa-upload + | ファイルをアップロード + +script. + @browser = @opts.browser + + @on \mount ~> + @refs.ctx.on \closed ~> + @trigger \closed + @unmount! + + @open = (pos) ~> + @refs.ctx.open pos + + @create-folder = ~> + @browser.create-folder! + @refs.ctx.close! + + @upload = ~> + @browser.select-local-file! + @refs.ctx.close! diff --git a/src/web/app/desktop/tags/drive/browser-window.tag b/src/web/app/desktop/tags/drive/browser-window.tag new file mode 100644 index 0000000000..b3a5fc9a47 --- /dev/null +++ b/src/web/app/desktop/tags/drive/browser-window.tag @@ -0,0 +1,29 @@ +mk-drive-browser-window + mk-window@window(is-modal={ false }, width={ '800px' }, height={ '500px' }) + <yield to="header"> + i.fa.fa-cloud + | ドライブ + </yield> + <yield to="content"> + mk-drive-browser(multiple={ true }, folder={ parent.folder }) + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + > mk-drive-browser + height 100% + +script. + @folder = if @opts.folder? then @opts.folder else null + + @on \mount ~> + @refs.window.on \closed ~> + @unmount! + + @close = ~> + @refs.window.close! diff --git a/src/web/app/desktop/tags/drive/browser.tag b/src/web/app/desktop/tags/drive/browser.tag new file mode 100644 index 0000000000..62e6425fe5 --- /dev/null +++ b/src/web/app/desktop/tags/drive/browser.tag @@ -0,0 +1,634 @@ +mk-drive-browser + nav + div.path(oncontextmenu={ path-oncontextmenu }) + mk-drive-browser-nav-folder(class={ current: folder == null }, folder={ null }) + virtual(each={ folder in hierarchy-folders }) + span.separator: i.fa.fa-angle-right + mk-drive-browser-nav-folder(folder={ folder }) + span.separator(if={ folder != null }): i.fa.fa-angle-right + span.folder.current(if={ folder != null }) + | { folder.name } + input.search(type='search', placeholder!=' 検索') + div.main@main(class={ uploading: uploads.length > 0, loading: loading }, onmousedown={ onmousedown }, ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }, oncontextmenu={ oncontextmenu }) + div.selection@selection + div.contents@contents + div.folders@folders-container(if={ folders.length > 0 }) + virtual(each={ folder in folders }) + mk-drive-browser-folder.folder(folder={ folder }) + button(if={ more-folders }) + | もっと読み込む + div.files@files-container(if={ files.length > 0 }) + virtual(each={ file in files }) + mk-drive-browser-file.file(file={ file }) + button(if={ more-files }) + | もっと読み込む + div.empty(if={ files.length == 0 && folders.length == 0 && !loading }) + p(if={ draghover }) + | ドロップですか?いいですよ、ボクはカワイイですからね + p(if={ !draghover && folder == null }) + strong ドライブには何もありません。 + br + | 右クリックして「ファイルをアップロード」を選んだり、ファイルをドラッグ&ドロップすることでもアップロードできます。 + p(if={ !draghover && folder != null }) + | このフォルダーは空です + div.loading(if={ loading }). + <div class="spinner"> + <div class="dot1"></div> + <div class="dot2"></div> + </div> + div.dropzone(if={ draghover }) + mk-uploader@uploader + input@file-input(type='file', accept='*/*', multiple, tabindex='-1', onchange={ change-file-input }) + +style. + display block + + > nav + display block + z-index 2 + width 100% + overflow auto + font-size 0.9em + color #555 + background #fff + //border-bottom 1px solid #dfdfdf + box-shadow 0 1px 0 rgba(0, 0, 0, 0.05) + + &, * + user-select none + + > .path + display inline-block + vertical-align bottom + margin 0 + padding 0 8px + width calc(100% - 200px) + line-height 38px + white-space nowrap + + > * + display inline-block + margin 0 + padding 0 8px + line-height 38px + cursor pointer + + i + margin-right 4px + + * + pointer-events none + + &:hover + text-decoration underline + + &.current + font-weight bold + cursor default + + &:hover + text-decoration none + + &.separator + margin 0 + padding 0 + opacity 0.5 + cursor default + + > i + margin 0 + + > .search + display inline-block + vertical-align bottom + user-select text + cursor auto + margin 0 + padding 0 18px + width 200px + font-size 1em + line-height 38px + background transparent + outline none + //border solid 1px #ddd + border none + border-radius 0 + box-shadow none + transition color 0.5s ease, border 0.5s ease + font-family FontAwesome, sans-serif + + &[data-active='true'] + background #fff + + &::-webkit-input-placeholder, + &:-ms-input-placeholder, + &:-moz-placeholder + color $ui-controll-foreground-color + + > .main + padding 8px + height calc(100% - 38px) + overflow auto + + &, * + user-select none + + &.loading + cursor wait !important + + * + pointer-events none + + > .contents + opacity 0.5 + + &.uploading + height calc(100% - 38px - 100px) + + > .selection + display none + position absolute + z-index 128 + top 0 + left 0 + border solid 1px $theme-color + background rgba($theme-color, 0.5) + pointer-events none + + > .contents + + > .folders + &:after + content "" + display block + clear both + + > .folder + float left + + > .files + &:after + content "" + display block + clear both + + > .file + float left + + > .empty + padding 16px + text-align center + color #999 + pointer-events none + + > p + margin 0 + + > .loading + .spinner + margin 100px auto + width 40px + height 40px + text-align center + + animation sk-rotate 2.0s infinite linear + + .dot1, .dot2 + width 60% + height 60% + display inline-block + position absolute + top 0 + background-color rgba(0, 0, 0, 0.3) + border-radius 100% + + animation sk-bounce 2.0s infinite ease-in-out + + .dot2 + top auto + bottom 0 + animation-delay -1.0s + + @keyframes sk-rotate { 100% { transform: rotate(360deg); }} + + @keyframes sk-bounce { + 0%, 100% { + transform: scale(0.0); + } 50% { + transform: scale(1.0); + } + } + + > .dropzone + position absolute + left 0 + top 38px + width 100% + height calc(100% - 38px) + border dashed 2px rgba($theme-color, 0.5) + pointer-events none + + > mk-uploader + height 100px + padding 16px + background #fff + + > input + display none + +script. + @mixin \api + @mixin \dialog + @mixin \input-dialog + @mixin \stream + + @files = [] + @folders = [] + @hierarchy-folders = [] + + @uploads = [] + + # 現在の階層(フォルダ) + # * null でルートを表す + @folder = null + + @multiple = if @opts.multiple? then @opts.multiple else false + + # ドロップされようとしているか + @draghover = false + + # 自信の所有するアイテムがドラッグをスタートさせたか + # (自分自身の階層にドロップできないようにするためのフラグ) + @is-drag-source = false + + @on \mount ~> + @refs.uploader.on \uploaded (file) ~> + @add-file file, true + + @refs.uploader.on \change-uploads (uploads) ~> + @uploads = uploads + @update! + + @stream.on \drive_file_created @on-stream-drive-file-created + @stream.on \drive_file_updated @on-stream-drive-file-updated + @stream.on \drive_folder_created @on-stream-drive-folder-created + @stream.on \drive_folder_updated @on-stream-drive-folder-updated + + # Riotのバグでnullを渡しても""になる + # https://github.com/riot/riot/issues/2080 + #if @opts.folder? + if @opts.folder? and @opts.folder != '' + @move @opts.folder + else + @load! + + @on \unmount ~> + @stream.off \drive_file_created @on-stream-drive-file-created + @stream.off \drive_file_updated @on-stream-drive-file-updated + @stream.off \drive_folder_created @on-stream-drive-folder-created + @stream.off \drive_folder_updated @on-stream-drive-folder-updated + + @on-stream-drive-file-created = (file) ~> + @add-file file, true + + @on-stream-drive-file-updated = (file) ~> + current = if @folder? then @folder.id else null + if current != file.folder_id + @remove-file file + else + @add-file file, true + + @on-stream-drive-folder-created = (folder) ~> + @add-folder folder, true + + @on-stream-drive-folder-updated = (folder) ~> + current = if @folder? then @folder.id else null + if current != folder.parent_id + @remove-folder folder + else + @add-folder folder, true + + @onmousedown = (e) ~> + if (contains @refs.folders-container, e.target) or (contains @refs.files-container, e.target) + return true + + rect = @refs.main.get-bounding-client-rect! + + left = e.page-x + @refs.main.scroll-left - rect.left - window.page-x-offset + top = e.page-y + @refs.main.scroll-top - rect.top - window.page-y-offset + + move = (e) ~> + @refs.selection.style.display = \block + + cursor-x = e.page-x + @refs.main.scroll-left - rect.left - window.page-x-offset + cursor-y = e.page-y + @refs.main.scroll-top - rect.top - window.page-y-offset + w = cursor-x - left + h = cursor-y - top + + if w > 0 + @refs.selection.style.width = w + \px + @refs.selection.style.left = left + \px + else + @refs.selection.style.width = -w + \px + @refs.selection.style.left = cursor-x + \px + + if h > 0 + @refs.selection.style.height = h + \px + @refs.selection.style.top = top + \px + else + @refs.selection.style.height = -h + \px + @refs.selection.style.top = cursor-y + \px + + up = (e) ~> + document.document-element.remove-event-listener \mousemove move + document.document-element.remove-event-listener \mouseup up + + @refs.selection.style.display = \none + + document.document-element.add-event-listener \mousemove move + document.document-element.add-event-listener \mouseup up + + @path-oncontextmenu = (e) ~> + e.prevent-default! + e.stop-immediate-propagation! + return false + + @ondragover = (e) ~> + e.prevent-default! + e.stop-propagation! + + # ドラッグ元が自分自身の所有するアイテムかどうか + if !@is-drag-source + # ドラッグされてきたものがファイルだったら + if e.data-transfer.effect-allowed == \all + e.data-transfer.drop-effect = \copy + else + e.data-transfer.drop-effect = \move + @draghover = true + else + # 自分自身にはドロップさせない + e.data-transfer.drop-effect = \none + return false + + @ondragenter = (e) ~> + e.prevent-default! + if !@is-drag-source + @draghover = true + + @ondragleave = (e) ~> + @draghover = false + + @ondrop = (e) ~> + e.prevent-default! + e.stop-propagation! + + @draghover = false + + # ドロップされてきたものがファイルだったら + if e.data-transfer.files.length > 0 + Array.prototype.for-each.call e.data-transfer.files, (file) ~> + @upload file, @folder + return false + + # データ取得 + data = e.data-transfer.get-data 'text' + if !data? + return false + + # パース + obj = JSON.parse data + + # (ドライブの)ファイルだったら + if obj.type == \file + file = obj.id + if (@files.some (f) ~> f.id == file) + return false + @remove-file file + @api \drive/files/update do + file_id: file + folder_id: if @folder? then @folder.id else \null + .then ~> + # something + .catch (err, text-status) ~> + console.error err + + # (ドライブの)フォルダーだったら + else if obj.type == \folder + folder = obj.id + # 移動先が自分自身ならreject + if @folder? and folder == @folder.id + return false + if (@folders.some (f) ~> f.id == folder) + return false + @remove-folder folder + @api \drive/folders/update do + folder_id: folder + parent_id: if @folder? then @folder.id else \null + .then ~> + # something + .catch (err) ~> + if err == 'detected-circular-definition' + @dialog do + '<i class="fa fa-exclamation-triangle"></i>操作を完了できません' + '移動先のフォルダーは、移動するフォルダーのサブフォルダーです。' + [ + text: \OK + ] + + return false + + @oncontextmenu = (e) ~> + e.prevent-default! + e.stop-immediate-propagation! + + ctx = document.body.append-child document.create-element \mk-drive-browser-base-contextmenu + ctx = riot.mount ctx, do + browser: @ + ctx = ctx.0 + ctx.open do + x: e.page-x - window.page-x-offset + y: e.page-y - window.page-y-offset + + return false + + @select-local-file = ~> + @refs.file-input.click! + + @create-folder = ~> + name <~ @input-dialog do + 'フォルダー作成' + 'フォルダー名' + null + + @api \drive/folders/create do + name: name + folder_id: if @folder? then @folder.id else undefined + .then (folder) ~> + @add-folder folder, true + @update! + .catch (err) ~> + console.error err + + @change-file-input = ~> + files = @refs.file-input.files + for i from 0 to files.length - 1 + file = files.item i + @upload file, @folder + + @upload = (file, folder) ~> + if folder? and typeof folder == \object + folder = folder.id + @refs.uploader.upload file, folder + + @get-selection = ~> + @files.filter (file) -> file._selected + + @new-window = (folder-id) ~> + browser = document.body.append-child document.create-element \mk-drive-browser-window + riot.mount browser, do + folder: folder-id + + @move = (target-folder) ~> + if target-folder? and typeof target-folder == \object + target-folder = target-folder.id + + if target-folder == null + @go-root! + return + + @loading = true + @update! + + @api \drive/folders/show do + folder_id: target-folder + .then (folder) ~> + @folder = folder + @hierarchy-folders = [] + + x = (f) ~> + @hierarchy-folders.unshift f + if f.parent? + x f.parent + + if folder.parent? + x folder.parent + + @update! + @load! + .catch (err, text-status) -> + console.error err + + @add-folder = (folder, unshift = false) ~> + current = if @folder? then @folder.id else null + if current != folder.parent_id + return + + if (@folders.some (f) ~> f.id == folder.id) + exist = (@folders.map (f) -> f.id).index-of folder.id + @folders[exist] = folder + @update! + return + + if unshift + @folders.unshift folder + else + @folders.push folder + + @update! + + @add-file = (file, unshift = false) ~> + current = if @folder? then @folder.id else null + if current != file.folder_id + return + + if (@files.some (f) ~> f.id == file.id) + exist = (@files.map (f) -> f.id).index-of file.id + @files[exist] = file + @update! + return + + if unshift + @files.unshift file + else + @files.push file + + @update! + + @remove-folder = (folder) ~> + if typeof folder == \object + folder = folder.id + @folders = @folders.filter (f) -> f.id != folder + @update! + + @remove-file = (file) ~> + if typeof file == \object + file = file.id + @files = @files.filter (f) -> f.id != file + @update! + + @go-root = ~> + if @folder != null + @folder = null + @hierarchy-folders = [] + @update! + @load! + + @load = ~> + @folders = [] + @files = [] + @more-folders = false + @more-files = false + @loading = true + @update! + + load-folders = null + load-files = null + + folders-max = 30 + files-max = 30 + + # フォルダ一覧取得 + @api \drive/folders do + folder_id: if @folder? then @folder.id else null + limit: folders-max + 1 + .then (folders) ~> + if folders.length == folders-max + 1 + @more-folders = true + folders.pop! + load-folders := folders + complete! + .catch (err, text-status) ~> + console.error err + + # ファイル一覧取得 + @api \drive/files do + folder_id: if @folder? then @folder.id else null + limit: files-max + 1 + .then (files) ~> + if files.length == files-max + 1 + @more-files = true + files.pop! + load-files := files + complete! + .catch (err, text-status) ~> + console.error err + + flag = false + complete = ~> + if flag + load-folders.for-each (folder) ~> + @add-folder folder + load-files.for-each (file) ~> + @add-file file + @loading = false + @update! + else + flag := true + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/drive/file-contextmenu.tag b/src/web/app/desktop/tags/drive/file-contextmenu.tag new file mode 100644 index 0000000000..7d7dca6c92 --- /dev/null +++ b/src/web/app/desktop/tags/drive/file-contextmenu.tag @@ -0,0 +1,97 @@ +mk-drive-browser-file-contextmenu + mk-contextmenu@ctx: ul + li(onclick={ parent.rename }): p + i.fa.fa-i-cursor + | 名前を変更 + li(onclick={ parent.copy-url }): p + i.fa.fa-link + | URLをコピー + li: a(href={ parent.file.url + '?download' }, download={ parent.file.name }, onclick={ parent.download }) + i.fa.fa-download + | ダウンロード + li.separator + li(onclick={ parent.delete }): p + i.fa.fa-trash-o + | 削除 + li.separator + li.has-child + p + | その他... + i.fa.fa-caret-right + ul + li(onclick={ parent.set-avatar }): p + | アバターに設定 + li(onclick={ parent.set-banner }): p + | バナーに設定 + li(onclick={ parent.set-wallpaper }): p + | 壁紙に設定 + li.has-child + p + | アプリで開く... + i.fa.fa-caret-right + ul + li(onclick={ parent.add-app }): p + | アプリを追加... + +script. + @mixin \api + @mixin \i + @mixin \update-avatar + @mixin \update-banner + @mixin \update-wallpaper + @mixin \input-dialog + @mixin \NotImplementedException + + @browser = @opts.browser + @file = @opts.file + + @on \mount ~> + @refs.ctx.on \closed ~> + @trigger \closed + @unmount! + + @open = (pos) ~> + @refs.ctx.open pos + + @rename = ~> + @refs.ctx.close! + + name <~ @input-dialog do + 'ファイル名の変更' + '新しいファイル名を入力してください' + @file.name + + @api \drive/files/update do + file_id: @file.id + name: name + .then ~> + # something + .catch (err) ~> + console.error err + + @copy-url = ~> + @NotImplementedException! + + @download = ~> + @refs.ctx.close! + + @set-avatar = ~> + @refs.ctx.close! + @update-avatar @I, (i) ~> + @update-i i + , @file + + @set-banner = ~> + @refs.ctx.close! + @update-banner @I, (i) ~> + @update-i i + , @file + + @set-wallpaper = ~> + @refs.ctx.close! + @update-wallpaper @I, (i) ~> + @update-i i + , @file + + @add-app = ~> + @NotImplementedException! diff --git a/src/web/app/desktop/tags/drive/file.tag b/src/web/app/desktop/tags/drive/file.tag new file mode 100644 index 0000000000..1702bb6501 --- /dev/null +++ b/src/web/app/desktop/tags/drive/file.tag @@ -0,0 +1,207 @@ +mk-drive-browser-file(data-is-selected={ (file._selected || false).toString() }, data-is-contextmenu-showing={ is-contextmenu-showing.toString() }, onclick={ onclick }, oncontextmenu={ oncontextmenu }, draggable='true', ondragstart={ ondragstart }, ondragend={ ondragend }, title={ title }) + div.label(if={ I.avatar_id == file.id }) + img(src='/_/resources/label.svg') + p アバター + div.label(if={ I.banner_id == file.id }) + img(src='/_/resources/label.svg') + p バナー + div.label(if={ I.data.wallpaper == file.id }) + img(src='/_/resources/label.svg') + p 壁紙 + div.thumbnail: img(src={ file.url + '?thumbnail&size=128' }, alt='') + p.name + span { file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name } + span.ext(if={ file.name.lastIndexOf('.') != -1 }) { file.name.substr(file.name.lastIndexOf('.')) } + +style. + display block + margin 4px + padding 8px 0 0 0 + width 144px + height 180px + border-radius 4px + + &, * + cursor pointer + + &:hover + background rgba(0, 0, 0, 0.05) + + > .label + &:before + &:after + background #0b65a5 + + &:active + background rgba(0, 0, 0, 0.1) + + > .label + &:before + &:after + background #0b588c + + &[data-is-selected='true'] + background $theme-color + + &:hover + background lighten($theme-color, 10%) + + &:active + background darken($theme-color, 10%) + + > .label + &:before + &:after + display none + + > .name + color $theme-color-foreground + + &[data-is-contextmenu-showing='true'] + &:after + content "" + pointer-events none + position absolute + top -4px + right -4px + bottom -4px + left -4px + border 2px dashed rgba($theme-color, 0.3) + border-radius 4px + + > .label + position absolute + top 0 + left 0 + pointer-events none + + &:before + content "" + display block + position absolute + z-index 1 + top 0 + left 57px + width 28px + height 8px + background #0c7ac9 + + &:after + content "" + display block + position absolute + z-index 1 + top 57px + left 0 + width 8px + height 28px + background #0c7ac9 + + > img + position absolute + z-index 2 + top 0 + left 0 + + > p + position absolute + z-index 3 + top 19px + left -28px + width 120px + margin 0 + text-align center + line-height 28px + color #fff + transform rotate(-45deg) + + > .thumbnail + width 128px + height 128px + left 8px + + > img + display block + position absolute + top 0 + left 0 + right 0 + bottom 0 + margin auto + max-width 128px + max-height 128px + pointer-events none + + > .name + display block + margin 4px 0 0 0 + font-size 0.8em + text-align center + word-break break-all + color #444 + overflow hidden + + > .ext + opacity 0.5 + +script. + @mixin \i + @mixin \bytes-to-size + + @file = @opts.file + @browser = @parent + + @title = @file.name + '\n' + @file.type + ' ' + (@bytes-to-size @file.datasize) + + @is-contextmenu-showing = false + + @onclick = ~> + if @browser.multiple + if @file._selected? + @file._selected = !@file._selected + else + @file._selected = true + @browser.trigger \change-selection @browser.get-selection! + else + if @file._selected + @browser.trigger \selected @file + else + @browser.files.for-each (file) ~> + file._selected = false + @file._selected = true + @browser.trigger \change-selection @file + + @oncontextmenu = (e) ~> + e.prevent-default! + e.stop-immediate-propagation! + + @is-contextmenu-showing = true + @update! + ctx = document.body.append-child document.create-element \mk-drive-browser-file-contextmenu + ctx = riot.mount ctx, do + browser: @browser + file: @file + ctx = ctx.0 + ctx.open do + x: e.page-x - window.page-x-offset + y: e.page-y - window.page-y-offset + ctx.on \closed ~> + @is-contextmenu-showing = false + @update! + return false + + @ondragstart = (e) ~> + e.data-transfer.effect-allowed = \move + e.data-transfer.set-data 'text' JSON.stringify do + type: \file + id: @file.id + file: @file + @is-dragging = true + + # 親ブラウザに対して、ドラッグが開始されたフラグを立てる + # (=あなたの子供が、ドラッグを開始しましたよ) + @browser.is-drag-source = true + + @ondragend = (e) ~> + @is-dragging = false + @browser.is-drag-source = false diff --git a/src/web/app/desktop/tags/drive/folder-contextmenu.tag b/src/web/app/desktop/tags/drive/folder-contextmenu.tag new file mode 100644 index 0000000000..67fb1047b7 --- /dev/null +++ b/src/web/app/desktop/tags/drive/folder-contextmenu.tag @@ -0,0 +1,62 @@ +mk-drive-browser-folder-contextmenu + mk-contextmenu@ctx: ul + li(onclick={ parent.move }): p + i.fa.fa-arrow-right + | このフォルダへ移動 + li(onclick={ parent.new-window }): p + i.fa.fa-share-square-o + | 新しいウィンドウで表示 + li.separator + li(onclick={ parent.rename }): p + i.fa.fa-i-cursor + | 名前を変更 + li.separator + li(onclick={ parent.delete }): p + i.fa.fa-trash-o + | 削除 + +script. + @mixin \api + @mixin \input-dialog + + @browser = @opts.browser + @folder = @opts.folder + + @open = (pos) ~> + @refs.ctx.open pos + + @refs.ctx.on \closed ~> + @trigger \closed + @unmount! + + @move = ~> + @browser.move @folder.id + @refs.ctx.close! + + @new-window = ~> + @browser.new-window @folder.id + @refs.ctx.close! + + @create-folder = ~> + @browser.create-folder! + @refs.ctx.close! + + @upload = ~> + @browser.select-lcoal-file! + @refs.ctx.close! + + @rename = ~> + @refs.ctx.close! + + name <~ @input-dialog do + 'フォルダ名の変更' + '新しいフォルダ名を入力してください' + @folder.name + + @api \drive/folders/update do + folder_id: @folder.id + name: name + .then ~> + # something + .catch (err) ~> + console.error err diff --git a/src/web/app/desktop/tags/drive/folder.tag b/src/web/app/desktop/tags/drive/folder.tag new file mode 100644 index 0000000000..0f3b06d543 --- /dev/null +++ b/src/web/app/desktop/tags/drive/folder.tag @@ -0,0 +1,183 @@ +mk-drive-browser-folder(data-is-contextmenu-showing={ is-contextmenu-showing.toString() }, data-draghover={ draghover.toString() }, onclick={ onclick }, onmouseover={ onmouseover }, onmouseout={ onmouseout }, ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }, oncontextmenu={ oncontextmenu }, draggable='true', ondragstart={ ondragstart }, ondragend={ ondragend }, title={ title }) + p.name + i.fa.fa-fw(class={ fa-folder-o: !hover, fa-folder-open-o: hover }) + | { folder.name } + +style. + display block + margin 4px + padding 8px + width 144px + height 64px + background lighten($theme-color, 95%) + border-radius 4px + + &, * + cursor pointer + + * + pointer-events none + + &:hover + background lighten($theme-color, 90%) + + &:active + background lighten($theme-color, 85%) + + &[data-is-contextmenu-showing='true'] + &[data-draghover='true'] + &:after + content "" + pointer-events none + position absolute + top -4px + right -4px + bottom -4px + left -4px + border 2px dashed rgba($theme-color, 0.3) + border-radius 4px + + &[data-draghover='true'] + background lighten($theme-color, 90%) + + > .name + margin 0 + font-size 0.9em + color darken($theme-color, 30%) + + > i + margin-right 4px + margin-left 2px + text-align left + +script. + @mixin \api + @mixin \dialog + + @folder = @opts.folder + @browser = @parent + + @title = @folder.name + @hover = false + @draghover = false + @is-contextmenu-showing = false + + @onclick = ~> + @browser.move @folder + + @onmouseover = ~> + @hover = true + + @onmouseout = ~> + @hover = false + + @ondragover = (e) ~> + e.prevent-default! + e.stop-propagation! + + # 自分自身がドラッグされていない場合 + if !@is-dragging + # ドラッグされてきたものがファイルだったら + if e.data-transfer.effect-allowed == \all + e.data-transfer.drop-effect = \copy + else + e.data-transfer.drop-effect = \move + else + # 自分自身にはドロップさせない + e.data-transfer.drop-effect = \none + return false + + @ondragenter = ~> + if !@is-dragging + @draghover = true + + @ondragleave = ~> + @draghover = false + + @ondrop = (e) ~> + e.stop-propagation! + @draghover = false + + # ファイルだったら + if e.data-transfer.files.length > 0 + Array.prototype.for-each.call e.data-transfer.files, (file) ~> + @browser.upload file, @folder + return false + + # データ取得 + data = e.data-transfer.get-data 'text' + if !data? + return false + + # パース + obj = JSON.parse data + + # (ドライブの)ファイルだったら + if obj.type == \file + file = obj.id + @browser.remove-file file + @api \drive/files/update do + file_id: file + folder_id: @folder.id + .then ~> + # something + .catch (err, text-status) ~> + console.error err + + # (ドライブの)フォルダーだったら + else if obj.type == \folder + folder = obj.id + # 移動先が自分自身ならreject + if folder == @folder.id + return false + @browser.remove-folder folder + @api \drive/folders/update do + folder_id: folder + parent_id: @folder.id + .then ~> + # something + .catch (err) ~> + if err == 'detected-circular-definition' + @dialog do + '<i class="fa fa-exclamation-triangle"></i>操作を完了できません' + '移動先のフォルダーは、移動するフォルダーのサブフォルダーです。' + [ + text: \OK + ] + + return false + + @ondragstart = (e) ~> + e.data-transfer.effect-allowed = \move + e.data-transfer.set-data 'text' JSON.stringify do + type: \folder + id: @folder.id + @is-dragging = true + + # 親ブラウザに対して、ドラッグが開始されたフラグを立てる + # (=あなたの子供が、ドラッグを開始しましたよ) + @browser.is-drag-source = true + + @ondragend = (e) ~> + @is-dragging = false + @browser.is-drag-source = false + + @oncontextmenu = (e) ~> + e.prevent-default! + e.stop-immediate-propagation! + + @is-contextmenu-showing = true + @update! + ctx = document.body.append-child document.create-element \mk-drive-browser-folder-contextmenu + ctx = riot.mount ctx, do + browser: @browser + folder: @folder + ctx = ctx.0 + ctx.open do + x: e.page-x - window.page-x-offset + y: e.page-y - window.page-y-offset + ctx.on \closed ~> + @is-contextmenu-showing = false + @update! + + return false diff --git a/src/web/app/desktop/tags/drive/nav-folder.tag b/src/web/app/desktop/tags/drive/nav-folder.tag new file mode 100644 index 0000000000..398a26a80b --- /dev/null +++ b/src/web/app/desktop/tags/drive/nav-folder.tag @@ -0,0 +1,96 @@ +mk-drive-browser-nav-folder(data-draghover={ draghover }, onclick={ onclick }, ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }) + i.fa.fa-cloud(if={ folder == null }) + span { folder == null ? 'ドライブ' : folder.name } + +style. + &[data-draghover] + background #eee + +script. + @mixin \api + + # Riotのバグでnullを渡しても""になる + # https://github.com/riot/riot/issues/2080 + #@folder = @opts.folder + @folder = if @opts.folder? and @opts.folder != '' then @opts.folder else null + @browser = @parent + + @hover = false + + @onclick = ~> + @browser.move @folder + + @onmouseover = ~> + @hover = true + + @onmouseout = ~> + @hover = false + + @ondragover = (e) ~> + e.prevent-default! + e.stop-propagation! + + # このフォルダがルートかつカレントディレクトリならドロップ禁止 + if @folder == null and @browser.folder == null + e.data-transfer.drop-effect = \none + # ドラッグされてきたものがファイルだったら + else if e.data-transfer.effect-allowed == \all + e.data-transfer.drop-effect = \copy + else + e.data-transfer.drop-effect = \move + return false + + @ondragenter = ~> + if @folder != null or @browser.folder != null + @draghover = true + + @ondragleave = ~> + if @folder != null or @browser.folder != null + @draghover = false + + @ondrop = (e) ~> + e.stop-propagation! + @draghover = false + + # ファイルだったら + if e.data-transfer.files.length > 0 + Array.prototype.for-each.call e.data-transfer.files, (file) ~> + @browser.upload file, @folder + return false + + # データ取得 + data = e.data-transfer.get-data 'text' + if !data? + return false + + # パース + obj = JSON.parse data + + # (ドライブの)ファイルだったら + if obj.type == \file + file = obj.id + @browser.remove-file file + @api \drive/files/update do + file_id: file + folder_id: if @folder? then @folder.id else null + .then ~> + # something + .catch (err, text-status) ~> + console.error err + + # (ドライブの)フォルダーだったら + else if obj.type == \folder + folder = obj.id + # 移動先が自分自身ならreject + if @folder? and folder == @folder.id + return false + @browser.remove-folder folder + @api \drive/folders/update do + folder_id: folder + parent_id: if @folder? then @folder.id else null + .then ~> + # something + .catch (err, text-status) ~> + console.error err + + return false diff --git a/src/web/app/desktop/tags/ellipsis-icon.tag b/src/web/app/desktop/tags/ellipsis-icon.tag new file mode 100644 index 0000000000..5d18bc0473 --- /dev/null +++ b/src/web/app/desktop/tags/ellipsis-icon.tag @@ -0,0 +1,34 @@ +mk-ellipsis-icon + div + div + div + +style. + display block + width 70px + margin 0 auto + text-align center + + > div + display inline-block + width 18px + height 18px + background-color rgba(0, 0, 0, 0.3) + border-radius 100% + animation bounce 1.4s infinite ease-in-out both + + &:nth-child(1) + animation-delay 0s + + &:nth-child(2) + margin 0 6px + animation-delay 0.16s + + &:nth-child(3) + animation-delay 0.32s + + @keyframes bounce + 0%, 80%, 100% + transform scale(0) + 40% + transform scale(1) diff --git a/src/web/app/desktop/tags/follow-button.tag b/src/web/app/desktop/tags/follow-button.tag new file mode 100644 index 0000000000..41bd1f0e12 --- /dev/null +++ b/src/web/app/desktop/tags/follow-button.tag @@ -0,0 +1,127 @@ +mk-follow-button + button(if={ !init }, class={ wait: wait, follow: !user.is_following, unfollow: user.is_following }, + onclick={ onclick }, + disabled={ wait }, + title={ user.is_following ? 'フォロー解除' : 'フォローする' }) + i.fa.fa-minus(if={ !wait && user.is_following }) + i.fa.fa-plus(if={ !wait && !user.is_following }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ wait }) + div.init(if={ init }): i.fa.fa-spinner.fa-pulse.fa-fw + +style. + display block + + > button + > .init + display block + cursor pointer + padding 0 + margin 0 + width 32px + height 32px + font-size 1em + outline none + border-radius 4px + + * + pointer-events none + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &.follow + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.unfollow + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + &.wait + cursor wait !important + opacity 0.7 + +script. + @mixin \api + @mixin \is-promise + @mixin \stream + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + @init = true + @wait = false + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @init = false + @update! + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + @on \unmount ~> + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + @on-stream-follow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @on-stream-unfollow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @onclick = ~> + @wait = true + if @user.is_following + @api \following/delete do + user_id: @user.id + .then ~> + @user.is_following = false + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! + else + @api \following/create do + user_id: @user.id + .then ~> + @user.is_following = true + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! diff --git a/src/web/app/desktop/tags/following-setuper.tag b/src/web/app/desktop/tags/following-setuper.tag new file mode 100644 index 0000000000..9b75a251e0 --- /dev/null +++ b/src/web/app/desktop/tags/following-setuper.tag @@ -0,0 +1,163 @@ +mk-following-setuper + p.title 気になるユーザーをフォロー: + div.users(if={ !loading && users.length > 0 }) + div.user(each={ users }) + a.avatar-anchor(href={ CONFIG.url + '/' + username }) + img.avatar(src={ avatar_url + '?thumbnail&size=42' }, alt='', data-user-preview={ id }) + div.body + a.name(href={ CONFIG.url + '/' + username }, target='_blank', data-user-preview={ id }) { name } + p.username @{ username } + mk-follow-button(user={ this }) + p.empty(if={ !loading && users.length == 0 }) + | おすすめのユーザーは見つかりませんでした。 + p.loading(if={ loading }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + a.refresh(onclick={ refresh }) もっと見る + button.close(onclick={ close }, title='閉じる'): i.fa.fa-times + +style. + display block + padding 24px + background #fff + + > .title + margin 0 0 12px 0 + font-size 1em + font-weight bold + color #888 + + > .users + &:after + content "" + display block + clear both + + > .user + padding 16px + width 238px + float left + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 42px + height 42px + margin 0 + border-radius 8px + vertical-align bottom + + > .body + float left + width calc(100% - 54px) + + > .name + margin 0 + font-size 16px + line-height 24px + color #555 + + > .username + margin 0 + font-size 15px + line-height 16px + color #ccc + + > mk-follow-button + position absolute + top 16px + right 16px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + > .refresh + display block + margin 0 8px 0 0 + text-align right + font-size 0.9em + color #999 + + > .close + cursor pointer + display block + position absolute + top 6px + right 6px + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + background transparent + + &:hover + color #555 + + &:active + color #222 + + > i + padding 14px + +script. + @mixin \api + @mixin \user-preview + + @users = null + @loading = true + + @limit = 6users + @page = 0 + + @on \mount ~> + @load! + + @load = ~> + @loading = true + @users = null + @update! + + @api \users/recommendation do + limit: @limit + offset: @limit * @page + .then (users) ~> + @loading = false + @users = users + @update! + .catch (err, text-status) -> + console.error err + + @refresh = ~> + if @users.length < @limit + @page = 0 + else + @page++ + @load! + + @close = ~> + @unmount! diff --git a/src/web/app/desktop/tags/go-top.tag b/src/web/app/desktop/tags/go-top.tag new file mode 100644 index 0000000000..a11f4a3640 --- /dev/null +++ b/src/web/app/desktop/tags/go-top.tag @@ -0,0 +1,15 @@ +mk-go-top + button.hidden(title='一番上へ') + i.fa.fa-angle-up + +script. + + window.add-event-listener \load @on-scroll + window.add-event-listener \scroll @on-scroll + window.add-event-listener \resize @on-scroll + + @on-scroll = ~> + if $ window .scroll-top! > 500px + @remove-class \hidden + else + @add-class \hidden diff --git a/src/web/app/desktop/tags/home-widgets/broadcast.tag b/src/web/app/desktop/tags/home-widgets/broadcast.tag new file mode 100644 index 0000000000..43f1251643 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/broadcast.tag @@ -0,0 +1,75 @@ +mk-broadcast-home-widget + div.icon + svg(height='32', version='1.1', viewBox='0 0 32 32', width='32') + path.tower(d='M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z') + path.wave.a(d='M4.66,1.04c-0.508-0.508-1.332-0.508-1.84,0c-1.86,1.92-2.8,4.44-2.8,6.94c0,2.52,0.94,5.04,2.8,6.96 c0.5,0.52,1.32,0.52,1.82,0s0.5-1.36,0-1.88C3.28,11.66,2.6,9.82,2.6,7.98S3.28,4.3,4.64,2.9C5.157,2.391,5.166,1.56,4.66,1.04z') + path.wave.b(d='M9.58,12.22c0.5-0.5,0.5-1.34,0-1.84C8.94,9.72,8.62,8.86,8.62,8s0.32-1.72,0.96-2.38c0.5-0.52,0.5-1.34,0-1.84 C9.346,3.534,9.02,3.396,8.68,3.4c-0.32,0-0.66,0.12-0.9,0.38C6.64,4.94,6.08,6.48,6.08,8s0.58,3.06,1.7,4.22 C8.28,12.72,9.1,12.72,9.58,12.22z') + path.wave.c(d='M22.42,3.78c-0.5,0.5-0.5,1.34,0,1.84c0.641,0.66,0.96,1.52,0.96,2.38s-0.319,1.72-0.96,2.38c-0.5,0.52-0.5,1.34,0,1.84 c0.487,0.497,1.285,0.505,1.781,0.018c0.007-0.006,0.013-0.012,0.02-0.018c1.139-1.16,1.699-2.7,1.699-4.22s-0.561-3.06-1.699-4.22 c-0.494-0.497-1.297-0.5-1.794-0.007C22.424,3.775,22.422,3.778,22.42,3.78z') + path.wave.d(d='M29.18,1.06c-0.479-0.502-1.273-0.522-1.775-0.044c-0.016,0.015-0.029,0.029-0.045,0.044c-0.5,0.52-0.5,1.36,0,1.88 c1.361,1.4,2.041,3.24,2.041,5.08s-0.68,3.66-2.041,5.08c-0.5,0.52-0.5,1.36,0,1.88c0.509,0.508,1.332,0.508,1.841,0 c1.86-1.92,2.8-4.44,2.8-6.96C31.99,5.424,30.98,2.931,29.18,1.06z') + + h1 開発者募集中! + p: a(href='https://github.com/syuilo/misskey', target='_blank') Misskeyはオープンソースで開発されています。Webのリポジトリはこちら + +style. + display block + padding 10px 10px 10px 50px + background transparent + border-color #4078c0 !important + + &:after + content "" + display block + clear both + + > .icon + display block + float left + margin-left -40px + + > svg + fill currentColor + color #4078c0 + + > .wave + opacity 1 + + &.a + animation wave 20s ease-in-out 2.1s infinite + &.b + animation wave 20s ease-in-out 2s infinite + &.c + animation wave 20s ease-in-out 2s infinite + &.d + animation wave 20s ease-in-out 2.1s infinite + + @keyframes wave + 0% + opacity 1 + 1.5% + opacity 0 + 3.5% + opacity 0 + 5% + opacity 1 + 6.5% + opacity 0 + 8.5% + opacity 0 + 10% + opacity 1 + + > h1 + margin 0 + font-size 0.95em + font-weight normal + color #4078c0 + + > p + display block + z-index 1 + margin 0 + font-size 0.7em + color #555 + + a + color #555 diff --git a/src/web/app/desktop/tags/home-widgets/calendar.tag b/src/web/app/desktop/tags/home-widgets/calendar.tag new file mode 100644 index 0000000000..26cea1c699 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/calendar.tag @@ -0,0 +1,147 @@ +mk-calendar-home-widget(data-special={ special }) + div.calendar(data-is-holiday={ is-holiday }) + p.month-and-year + span.year { year }年 + span.month { month }月 + p.day { day }日 + p.week-day { week-day }曜日 + div.info + div + p + | 今日: + b { day-p.to-fixed(1) }% + div.meter + div.val(style={ 'width:' + day-p + '%' }) + + div + p + | 今月: + b { month-p.to-fixed(1) }% + div.meter + div.val(style={ 'width:' + month-p + '%' }) + + div + p + | 今年: + b { year-p.to-fixed(1) }% + div.meter + div.val(style={ 'width:' + year-p + '%' }) + +style. + display block + padding 16px 0 + color #777 + background #fff + + &[data-special='on-new-years-day'] + border-color #ef95a0 !important + + &:after + content "" + display block + clear both + + > .calendar + float left + width 60% + text-align center + + &[data-is-holiday] + > .day + color #ef95a0 + + > p + margin 0 + line-height 18px + font-size 14px + + > span + margin 0 4px + + > .day + margin 10px 0 + line-height 32px + font-size 28px + + > .info + display block + float left + width 40% + padding 0 16px 0 0 + + > div + margin-bottom 8px + + &:last-child + margin-bottom 4px + + > p + margin 0 0 2px 0 + font-size 12px + line-height 18px + color #888 + + > b + margin-left 2px + + > .meter + width 100% + overflow hidden + background #eee + border-radius 8px + + > .val + height 4px + background $theme-color + + &:nth-child(1) + > .meter > .val + background #f7796c + + &:nth-child(2) + > .meter > .val + background #a1de41 + + &:nth-child(3) + > .meter > .val + background #41ddde + +script. + @draw = ~> + now = new Date! + nd = now.get-date! + nm = now.get-month! + ny = now.get-full-year! + + @year = ny + @month = nm + 1 + @day = nd + @week-day = [\日 \月 \火 \水 \木 \金 \土][now.get-day!] + + @day-numer = (now - (new Date ny, nm, nd)) + @day-denom = 1000ms * 60s * 60m * 24h + @month-numer = (now - (new Date ny, nm, 1)) + @month-denom = (new Date ny, nm + 1, 1) - (new Date ny, nm, 1) + @year-numer = (now - (new Date ny, 0, 0)) + @year-denom = (new Date ny + 1, 0, 0) - (new Date ny, 0, 0) + + @day-p = @day-numer / @day-denom * 100 + @month-p = @month-numer / @month-denom * 100 + @year-p = @year-numer / @year-denom * 100 + + @is-holiday = + (now.get-day! == 0 or now.get-day! == 6) + + @special = + | nm == 0 and nd == 1 => \on-new-years-day + | _ => false + + @update! + + @draw! + + @on \mount ~> + @clock = set-interval @draw, 1000ms + + @on \unmount ~> + clear-interval @clock diff --git a/src/web/app/desktop/tags/home-widgets/donation.tag b/src/web/app/desktop/tags/home-widgets/donation.tag new file mode 100644 index 0000000000..a41bd9f8a4 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/donation.tag @@ -0,0 +1,37 @@ +mk-donation-home-widget + article + h1 + i.fa.fa-heart + | 寄付のお願い + p + | Misskeyの運営にはドメイン、サーバー等のコストが掛かります。 + | Misskeyは広告を掲載したりしないため、 収入を皆様からの寄付に頼っています。 + | もしご興味があれば、 + a(href='/syuilo', data-user-preview='@syuilo') @syuilo + | までご連絡ください。ご協力ありがとうございます。 + +style. + display block + background #fff + border-color #ead8bb !important + + > article + padding 20px + + > h1 + margin 0 0 5px 0 + font-size 1em + color #888 + + > i + margin-right 0.25em + + > p + display block + z-index 1 + margin 0 + font-size 0.8em + color #999 + +script. + @mixin \user-preview diff --git a/src/web/app/desktop/tags/home-widgets/mentions.tag b/src/web/app/desktop/tags/home-widgets/mentions.tag new file mode 100644 index 0000000000..0f0cd0269d --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/mentions.tag @@ -0,0 +1,117 @@ +mk-mentions-home-widget + header + span(data-is-active={ mode == 'all' }, onclick={ set-mode.bind(this, 'all') }) すべて + span(data-is-active={ mode == 'following' }, onclick={ set-mode.bind(this, 'following') }) フォロー中 + div.loading(if={ is-loading }) + mk-ellipsis-icon + p.empty(if={ is-empty }) + i.fa.fa-comments-o + span(if={ mode == 'all' }) あなた宛ての投稿はありません。 + span(if={ mode == 'following' }) あなたがフォローしているユーザーからの言及はありません。 + mk-timeline@timeline + <yield to="footer"> + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + </yield> + +style. + display block + background #fff + + > header + padding 8px 16px + border-bottom solid 1px #eee + + > span + margin-right 16px + line-height 27px + font-size 18px + color #555 + + &:not([data-is-active]) + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + > .loading + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + +script. + @mixin \i + @mixin \api + + @is-loading = true + @is-empty = false + @more-loading = false + @mode = \all + + @on \mount ~> + document.add-event-listener \keydown @on-document-keydown + window.add-event-listener \scroll @on-scroll + + @fetch ~> + @trigger \loaded + + @on \unmount ~> + document.remove-event-listener \keydown @on-document-keydown + window.remove-event-listener \scroll @on-scroll + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 84 # t + @refs.timeline.focus! + + @fetch = (cb) ~> + @api \posts/mentions do + following: @mode == \following + .then (posts) ~> + @is-loading = false + @is-empty = posts.length == 0 + @update! + @refs.timeline.set-posts posts + if cb? then cb! + .catch (err) ~> + console.error err + if cb? then cb! + + @more = ~> + if @more-loading or @is-loading or @refs.timeline.posts.length == 0 + return + @more-loading = true + @update! + @api \posts/mentions do + following: @mode == \following + max_id: @refs.timeline.tail!.id + .then (posts) ~> + @more-loading = false + @update! + @refs.timeline.prepend-posts posts + .catch (err) ~> + console.error err + + @on-scroll = ~> + current = window.scroll-y + window.inner-height + if current > document.body.offset-height - 8 + @more! + + @set-mode = (mode) ~> + @update do + mode: mode + @fetch! diff --git a/src/web/app/desktop/tags/home-widgets/nav.tag b/src/web/app/desktop/tags/home-widgets/nav.tag new file mode 100644 index 0000000000..5b8e7e1b3c --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/nav.tag @@ -0,0 +1,23 @@ +mk-nav-home-widget + a(href={ CONFIG.urls.about }) Misskeyについて + i ・ + a(href={ CONFIG.urls.about + '/status' }) ステータス + i ・ + a(href='https://github.com/syuilo/misskey') リポジトリ + i ・ + a(href={ CONFIG.urls.dev }) 開発者 + i ・ + a(href='https://twitter.com/misskey_xyz', target='_blank') Follow us on <i class="fa fa-twitter"></i> + +style. + display block + padding 16px + font-size 12px + color #aaa + background #fff + + a + color #999 + + i + color #ccc diff --git a/src/web/app/desktop/tags/home-widgets/notifications.tag b/src/web/app/desktop/tags/home-widgets/notifications.tag new file mode 100644 index 0000000000..588a765d02 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/notifications.tag @@ -0,0 +1,49 @@ +mk-notifications-home-widget + p.title + i.fa.fa-bell-o + | 通知 + button(onclick={ settings }, title='通知の設定'): i.fa.fa-cog + mk-notifications + +style. + display block + background #fff + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > mk-notifications + max-height 300px + overflow auto + +script. + @settings = ~> + w = riot.mount document.body.append-child document.create-element \mk-settings-window .0 + w.switch \notification diff --git a/src/web/app/desktop/tags/home-widgets/photo-stream.tag b/src/web/app/desktop/tags/home-widgets/photo-stream.tag new file mode 100644 index 0000000000..b972706574 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/photo-stream.tag @@ -0,0 +1,86 @@ +mk-photo-stream-home-widget + p.title + i.fa.fa-camera + | フォトストリーム + p.initializing(if={ initializing }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + div.stream(if={ !initializing && images.length > 0 }) + virtual(each={ image in images }) + div.img(style={ 'background-image: url(' + image.url + '?thumbnail&size=256)' }) + p.empty(if={ !initializing && images.length == 0 }) + | 写真はありません + +style. + display block + background #fff + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > .stream + display -webkit-flex + display -moz-flex + display -ms-flex + display flex + justify-content center + flex-wrap wrap + padding 8px + + > .img + flex 1 1 33% + width 33% + height 80px + background-position center center + background-size cover + background-clip content-box + border solid 2px transparent + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \stream + + @images = [] + @initializing = true + + @on \mount ~> + @stream.on \drive_file_created @on-stream-drive-file-created + + @api \drive/stream do + type: 'image/*' + limit: 9images + .then (images) ~> + @initializing = false + @images = images + @update! + + @on \unmount ~> + @stream.off \drive_file_created @on-stream-drive-file-created + + @on-stream-drive-file-created = (file) ~> + if /^image\/.+$/.test file.type + @images.unshift file + if @images.length > 9 + @images.pop! + @update! diff --git a/src/web/app/desktop/tags/home-widgets/profile.tag b/src/web/app/desktop/tags/home-widgets/profile.tag new file mode 100644 index 0000000000..ae8d43c645 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/profile.tag @@ -0,0 +1,55 @@ +mk-profile-home-widget + div.banner(style={ I.banner_url ? 'background-image: url(' + I.banner_url + '?thumbnail&size=256)' : '' }, onclick={ set-banner }) + img.avatar(src={ I.avatar_url + '?thumbnail&size=64' }, onclick={ set-avatar }, alt='avatar', data-user-preview={ I.id }) + a.name(href={ CONFIG.url + '/' + I.username }) { I.name } + p.username @{ I.username } + +style. + display block + background #fff + + > .banner + height 100px + background-color #f5f5f5 + background-size cover + background-position center + + > .avatar + display block + position absolute + top 76px + left 16px + width 58px + height 58px + margin 0 + border solid 3px #fff + border-radius 8px + vertical-align bottom + + > .name + display block + margin 10px 0 0 92px + line-height 16px + font-weight bold + color #555 + + > .username + display block + margin 4px 0 8px 92px + line-height 16px + font-size 0.9em + color #999 + +script. + @mixin \i + @mixin \user-preview + @mixin \update-avatar + @mixin \update-banner + + @set-avatar = ~> + @update-avatar @I, (i) ~> + @update-i i + + @set-banner = ~> + @update-banner @I, (i) ~> + @update-i i diff --git a/src/web/app/desktop/tags/home-widgets/rss-reader.tag b/src/web/app/desktop/tags/home-widgets/rss-reader.tag new file mode 100644 index 0000000000..b9095fb2d2 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/rss-reader.tag @@ -0,0 +1,94 @@ +mk-rss-reader-home-widget + p.title + i.fa.fa-rss-square + | RSS + button(onclick={ settings }, title='設定'): i.fa.fa-cog + div.feed(if={ !initializing }) + virtual(each={ item in items }) + a(href={ item.link }, target='_blank') { item.title } + p.initializing(if={ initializing }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + background #fff + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > button + position absolute + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .feed + padding 12px 16px + font-size 0.9em + + > a + display block + padding 4px 0 + color #666 + border-bottom dashed 1px #eee + + &:last-child + border-bottom none + + > .initializing + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \NotImplementedException + + @url = 'http://news.yahoo.co.jp/pickup/rss.xml' + @items = [] + @initializing = true + + @on \mount ~> + @fetch! + @clock = set-interval @fetch, 60000ms + + @on \unmount ~> + clear-interval @clock + + @fetch = ~> + @api CONFIG.url + '/api:rss' do + url: @url + .then (feed) ~> + @items = feed.rss.channel.item + @initializing = false + @update! + .catch (err) -> + console.error err + + @settings = ~> + @NotImplementedException! diff --git a/src/web/app/desktop/tags/home-widgets/timeline.tag b/src/web/app/desktop/tags/home-widgets/timeline.tag new file mode 100644 index 0000000000..ea2746d1ed --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/timeline.tag @@ -0,0 +1,113 @@ +mk-timeline-home-widget + mk-following-setuper(if={ no-following }) + div.loading(if={ is-loading }) + mk-ellipsis-icon + p.empty(if={ is-empty }) + i.fa.fa-comments-o + | 自分の投稿や、自分がフォローしているユーザーの投稿が表示されます。 + mk-timeline@timeline + <yield to="footer"> + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + </yield> + +style. + display block + background #fff + + > mk-following-setuper + border-bottom solid 1px #eee + + > .loading + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + +script. + @mixin \i + @mixin \api + @mixin \stream + + @is-loading = true + @is-empty = false + @more-loading = false + @no-following = @I.following_count == 0 + + @on \mount ~> + @stream.on \post @on-stream-post + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + document.add-event-listener \keydown @on-document-keydown + window.add-event-listener \scroll @on-scroll + + @load ~> + @trigger \loaded + + @on \unmount ~> + @stream.off \post @on-stream-post + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + document.remove-event-listener \keydown @on-document-keydown + window.remove-event-listener \scroll @on-scroll + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 84 # t + @refs.timeline.focus! + + @load = (cb) ~> + @api \posts/timeline + .then (posts) ~> + @is-loading = false + @is-empty = posts.length == 0 + @update! + @refs.timeline.set-posts posts + if cb? then cb! + .catch (err) ~> + console.error err + if cb? then cb! + + @more = ~> + if @more-loading or @is-loading or @refs.timeline.posts.length == 0 + return + @more-loading = true + @update! + @api \posts/timeline do + max_id: @refs.timeline.tail!.id + .then (posts) ~> + @more-loading = false + @update! + @refs.timeline.prepend-posts posts + .catch (err) ~> + console.error err + + @on-stream-post = (post) ~> + @is-empty = false + @update! + @refs.timeline.add-post post + + @on-stream-follow = ~> + @load! + + @on-stream-unfollow = ~> + @load! + + @on-scroll = ~> + current = window.scroll-y + window.inner-height + if current > document.body.offset-height - 8 + @more! diff --git a/src/web/app/desktop/tags/home-widgets/tips.tag b/src/web/app/desktop/tags/home-widgets/tips.tag new file mode 100644 index 0000000000..9c1aa33ec0 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/tips.tag @@ -0,0 +1,70 @@ +mk-tips-home-widget + p@tip + i.fa.fa-lightbulb-o + span@text + +style. + display block + background transparent !important + border none !important + overflow visible !important + + > p + display block + margin 0 + padding 0 12px + text-align center + font-size 0.7em + color #999 + + > i + margin-right 4px + + kbd + display inline + padding 0 6px + margin 0 2px + font-size 1em + font-family inherit + border solid 1px #999 + border-radius 2px + +script. + @tips = [ + '<kbd>t</kbd>でタイムラインにフォーカスできます' + '<kbd>p</kbd>または<kbd>n</kbd>で投稿フォームを開きます' + '投稿フォームにはファイルをドラッグ&ドロップできます' + '投稿フォームにクリップボードにある画像データをペーストできます' + 'ドライブにファイルをドラッグ&ドロップしてアップロードできます' + 'ドライブでファイルをドラッグしてフォルダ移動できます' + 'ドライブでフォルダをドラッグしてフォルダ移動できます' + 'ホームをカスタマイズできます(準備中)' + 'MisskeyはMIT Licenseです' + ] + + @on \mount ~> + @set! + @clock = set-interval @change, 20000ms + + @on \unmount ~> + clear-interval @clock + + @set = ~> + @refs.text.innerHTML = @tips[Math.floor Math.random! * @tips.length] + @update! + + @change = ~> + Velocity @refs.tip, { + opacity: 0 + } { + duration: 500ms + easing: \linear + complete: @set + } + + Velocity @refs.tip, { + opacity: 1 + } { + duration: 500ms + easing: \linear + } diff --git a/src/web/app/desktop/tags/home-widgets/user-recommendation.tag b/src/web/app/desktop/tags/home-widgets/user-recommendation.tag new file mode 100644 index 0000000000..bfb90da065 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/user-recommendation.tag @@ -0,0 +1,154 @@ +mk-user-recommendation-home-widget + p.title + i.fa.fa-users + | おすすめユーザー + button(onclick={ refresh }, title='他を見る'): i.fa.fa-refresh + div.user(if={ !loading && users.length != 0 }, each={ _user in users }) + a.avatar-anchor(href={ CONFIG.url + '/' + _user.username }) + img.avatar(src={ _user.avatar_url + '?thumbnail&size=42' }, alt='', data-user-preview={ _user.id }) + div.body + a.name(href={ CONFIG.url + '/' + _user.username }, data-user-preview={ _user.id }) { _user.name } + p.username @{ _user.username } + mk-follow-button(user={ _user }) + p.empty(if={ !loading && users.length == 0 }) + | いません! + p.loading(if={ loading }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + background #fff + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + border-bottom solid 1px #eee + + > i + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .user + padding 16px + border-bottom solid 1px #eee + + &:last-child + border-bottom none + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 42px + height 42px + margin 0 + border-radius 8px + vertical-align bottom + + > .body + float left + width calc(100% - 54px) + + > .name + margin 0 + font-size 16px + line-height 24px + color #555 + + > .username + display block + margin 0 + font-size 15px + line-height 16px + color #ccc + + > mk-follow-button + position absolute + top 16px + right 16px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \user-preview + + @users = null + @loading = true + + @limit = 3users + @page = 0 + + @on \mount ~> + @fetch! + @clock = set-interval ~> + if @users.length < @limit + @fetch true + , 60000ms + + @on \unmount ~> + clear-interval @clock + + @fetch = (quiet = false) ~> + @loading = true + @users = null + if not quiet then @update! + @api \users/recommendation do + limit: @limit + offset: @limit * @page + .then (users) ~> + @loading = false + @users = users + @update! + .catch (err, text-status) -> + console.error err + + @refresh = ~> + if @users.length < @limit + @page = 0 + else + @page++ + @fetch! diff --git a/src/web/app/desktop/tags/home.tag b/src/web/app/desktop/tags/home.tag new file mode 100644 index 0000000000..1ae856a6b2 --- /dev/null +++ b/src/web/app/desktop/tags/home.tag @@ -0,0 +1,86 @@ +mk-home + div.main + div.left@left + main + mk-timeline-home-widget@tl(if={ mode == 'timeline' }) + mk-mentions-home-widget@tl(if={ mode == 'mentions' }) + div.right@right + mk-detect-slow-internet-connection-notice + +style. + display block + + > .main + margin 0 auto + max-width 1200px + + &:after + content "" + display block + clear both + + > * + float left + + > * + display block + //border solid 1px #eaeaea + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + + &:not(:last-child) + margin-bottom 16px + + > main + padding 16px + width calc(100% - 275px * 2) + + > *:not(main) + width 275px + + > .left + padding 16px 0 16px 16px + + > .right + padding 16px 16px 16px 0 + + @media (max-width 1100px) + > *:not(main) + display none + + > main + float none + width 100% + max-width 700px + margin 0 auto + +script. + @mixin \i + @mode = @opts.mode || \timeline + + # https://github.com/riot/riot/issues/2080 + if @mode == '' then @mode = \timeline + + @home = [] + + @on \mount ~> + @refs.tl.on \loaded ~> + @trigger \loaded + + @I.data.home.for-each (widget) ~> + try + el = document.create-element \mk- + widget.name + \-home-widget + switch widget.place + | \left => @refs.left.append-child el + | \right => @refs.right.append-child el + @home.push (riot.mount el, do + id: widget.id + data: widget.data + .0) + catch e + # noop + + @on \unmount ~> + @home.for-each (widget) ~> + widget.unmount! diff --git a/src/web/app/desktop/tags/image-dialog.tag b/src/web/app/desktop/tags/image-dialog.tag new file mode 100644 index 0000000000..6a3885d7c7 --- /dev/null +++ b/src/web/app/desktop/tags/image-dialog.tag @@ -0,0 +1,73 @@ +mk-image-dialog + div.bg@bg(onclick={ close }) + img@img(src={ image.url }, alt={ image.name }, title={ image.name }, onclick={ close }) + +style. + display block + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + opacity 0 + + > .bg + display block + position fixed + z-index 1 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + + > img + position fixed + z-index 2 + top 0 + right 0 + bottom 0 + left 0 + max-width 100% + max-height 100% + margin auto + cursor zoom-out + +script. + @image = @opts.image + + @on \mount ~> + Velocity @root, { + opacity: 1 + } { + duration: 100ms + easing: \linear + } + + #Velocity @img, { + # scale: 1 + # opacity: 1 + #} { + # duration: 200ms + # easing: \ease-out + #} + + @close = ~> + Velocity @root, { + opacity: 0 + } { + duration: 100ms + easing: \linear + complete: ~> @unmount! + } + + #Velocity @img, { + # scale: 0.9 + # opacity: 0 + #} { + # duration: 200ms + # easing: \ease-in + # complete: ~> + # @unmount! + #} diff --git a/src/web/app/desktop/tags/images-viewer.tag b/src/web/app/desktop/tags/images-viewer.tag new file mode 100644 index 0000000000..a9939d67c4 --- /dev/null +++ b/src/web/app/desktop/tags/images-viewer.tag @@ -0,0 +1,43 @@ +mk-images-viewer + div.image@view(onmousemove={ mousemove }, style={ 'background-image: url(' + image.url + '?thumbnail' }, onclick={ click }) + img(src={ image.url + '?thumbnail&size=512' }, alt={ image.name }, title={ image.name }) + +style. + display block + padding 8px + overflow hidden + box-shadow 0 0 4px rgba(0, 0, 0, 0.2) + border-radius 4px + + > .image + cursor zoom-in + + > img + display block + max-height 256px + max-width 100% + margin 0 auto + + &:hover + > img + visibility hidden + + &:not(:hover) + background-image none !important + +script. + @images = @opts.images + @image = @images.0 + + @mousemove = (e) ~> + rect = @refs.view.get-bounding-client-rect! + mouse-x = e.client-x - rect.left + mouse-y = e.client-y - rect.top + xp = mouse-x / @refs.view.offset-width * 100 + yp = mouse-y / @refs.view.offset-height * 100 + @refs.view.style.background-position = xp + '% ' + yp + '%' + + @click = ~> + dialog = document.body.append-child document.create-element \mk-image-dialog + riot.mount dialog, do + image: @image diff --git a/src/web/app/desktop/tags/input-dialog.tag b/src/web/app/desktop/tags/input-dialog.tag new file mode 100644 index 0000000000..62ec4f5177 --- /dev/null +++ b/src/web/app/desktop/tags/input-dialog.tag @@ -0,0 +1,156 @@ +mk-input-dialog + mk-window@window(is-modal={ true }, width={ '500px' }) + <yield to="header"> + i.fa.fa-i-cursor + | { parent.title } + </yield> + <yield to="content"> + div.body + input@text(oninput={ parent.update }, onkeydown={ parent.on-keydown }, placeholder={ parent.placeholder }) + div.action + button.cancel(onclick={ parent.cancel }) キャンセル + button.ok(disabled={ !parent.allow-empty && refs.text.value.length == 0 }, onclick={ parent.ok }) 決定 + </yield> + +style. + display block + + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + > .body + padding 16px + + > input + display block + padding 8px + margin 0 + width 100% + max-width 100% + min-width 100% + font-size 1em + color #333 + background #fff + outline none + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + transition border-color .3s ease + + &:hover + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + color $theme-color + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + + > .action + height 72px + background lighten($theme-color, 95%) + + .ok + .cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + .ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + +script. + @done = false + + @title = @opts.title + @placeholder = @opts.placeholder + @default = @opts.default + @allow-empty = if @opts.allow-empty? then @opts.allow-empty else true + + @on \mount ~> + @text = @refs.window.refs.text + if @default? + @text.value = @default + @text.focus! + + @refs.window.on \closing ~> + if @done + @opts.on-ok @text.value + else + if @opts.on-cancel? + @opts.on-cancel! + + @refs.window.on \closed ~> + @unmount! + + @cancel = ~> + @done = false + @refs.window.close! + + @ok = ~> + if not @allow-empty and @text.value == '' then return + @done = true + @refs.window.close! + + @on-keydown = (e) ~> + if e.which == 13 # Enter + e.prevent-default! + e.stop-propagation! + @ok! diff --git a/src/web/app/desktop/tags/list-user.tag b/src/web/app/desktop/tags/list-user.tag new file mode 100644 index 0000000000..1058de22e0 --- /dev/null +++ b/src/web/app/desktop/tags/list-user.tag @@ -0,0 +1,100 @@ +mk-list-user + a.avatar-anchor(href={ CONFIG.url + '/' + user.username }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.main + header + div.left + a.name(href={ CONFIG.url + '/' + user.username }) + | { user.name } + span.username + | @{ user.username } + div.body + p.followed(if={ user.is_followed }) フォローされています + div.bio { user.bio } + mk-follow-button(user={ user }) + +style. + display block + margin 0 + padding 16px + font-size 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 58px + height 58px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 74px) + + > header + margin-bottom 2px + white-space nowrap + + &:after + content "" + display block + clear both + + > .left + float left + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .body + > .followed + display inline-block + margin 0 0 4px 0 + padding 2px 8px + vertical-align top + font-size 10px + color #71afc7 + background #eefaff + border-radius 4px + + > .bio + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.1em + color #717171 + + > mk-follow-button + position absolute + top 16px + right 16px + +script. + @user = @opts.user diff --git a/src/web/app/desktop/tags/log-window.tag b/src/web/app/desktop/tags/log-window.tag new file mode 100644 index 0000000000..6dabc4de34 --- /dev/null +++ b/src/web/app/desktop/tags/log-window.tag @@ -0,0 +1,20 @@ +mk-log-window + mk-window@window(width={ '600px' }, height={ '400px' }) + <yield to="header"> + i.fa.fa-terminal + | Log + </yield> + <yield to="content"> + mk-log + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + +script. + @on \mount ~> + @refs.window.on \closed ~> + @unmount! diff --git a/src/web/app/desktop/tags/log.tag b/src/web/app/desktop/tags/log.tag new file mode 100644 index 0000000000..20e5f8f699 --- /dev/null +++ b/src/web/app/desktop/tags/log.tag @@ -0,0 +1,62 @@ +mk-log + header + button.follow(class={ following: following }, onclick={ follow }) Follow + div.logs@logs + code(each={ logs }) + span.date { date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds() } + span.message { message } + +style. + display block + height 100% + color #fff + background #000 + + > header + height 32px + background #343a42 + + > button + line-height 32px + + > .follow + position absolute + top 0 + right 0 + + &.following + color #ff0 + + > .logs + height calc(100% - 32px) + overflow auto + + > code + display block + padding 4px 8px + + &:hover + background rgba(#fff, 0.15) + + > .date + margin-right 8px + opacity 0.5 + +script. + @mixin \log + + @following = true + + @on \mount ~> + @log-event.on \log @on-log + + @on \unmount ~> + @log-event.off \log @on-log + + @follow = ~> + @following = true + + @on-log = ~> + @update! + if @following + @refs.logs.scroll-top = @refs.logs.scroll-height diff --git a/src/web/app/desktop/tags/messaging/form.tag b/src/web/app/desktop/tags/messaging/form.tag new file mode 100644 index 0000000000..12eb0cb40f --- /dev/null +++ b/src/web/app/desktop/tags/messaging/form.tag @@ -0,0 +1,162 @@ +mk-messaging-form + textarea@text(onkeypress={ onkeypress }, onpaste={ onpaste }, placeholder='ここにメッセージを入力') + div.files + mk-uploader@uploader + button.send(onclick={ send }, disabled={ sending }, title='メッセージを送信') + i.fa.fa-paper-plane(if={ !sending }) + i.fa.fa-spinner.fa-spin(if={ sending }) + button.attach-from-local(type='button', title='PCから画像を添付する') + i.fa.fa-upload + button.attach-from-drive(type='button', title='アルバムから画像を添付する') + i.fa.fa-folder-open + input(name='file', type='file', accept='image/*') + +style. + 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 + +script. + @mixin \api + + @user = @opts.user + + @onpaste = (e) ~> + data = e.clipboard-data + items = data.items + for i from 0 to items.length - 1 + item = items[i] + switch (item.kind) + | \file => + @upload item.get-as-file! + + @onkeypress = (e) ~> + if (e.which == 10 || e.which == 13) && e.ctrl-key + @send! + + @select-file = ~> + @refs.file.click! + + @select-file-from-drive = ~> + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + event = riot.observable! + riot.mount browser, do + multiple: true + event: event + event.one \selected (files) ~> + files.for-each @add-file + + @send = ~> + @sending = true + @api \messaging/messages/create do + user_id: @user.id + text: @refs.text.value + .then (message) ~> + @clear! + .catch (err) ~> + console.error err + .then ~> + @sending = false + @update! + + @clear = ~> + @refs.text.value = '' + @files = [] + @update! diff --git a/src/web/app/desktop/tags/messaging/index.tag b/src/web/app/desktop/tags/messaging/index.tag new file mode 100644 index 0000000000..9f57500b83 --- /dev/null +++ b/src/web/app/desktop/tags/messaging/index.tag @@ -0,0 +1,302 @@ +mk-messaging + div.search + div.form + label(for='search-input') + i.fa.fa-search + input@search-input(type='search', oninput={ search }, placeholder='ユーザーを探す') + div.result + ol.users(if={ search-result.length > 0 }) + li(each={ user in search-result }) + a(onclick={ user._click }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=32' }, alt='') + span.name { user.name } + span.username @{ user.username } + div.main + div.history(if={ history.length > 0 }) + virtual(each={ history }) + a.user(data-is-me={ is_me }, data-is-read={ is_read }, onclick={ _click }): div + img.avatar(src={ (is_me ? recipient.avatar_url : user.avatar_url) + '?thumbnail&size=64' }, alt='') + header + span.name { is_me ? recipient.name : user.name } + span.username { '@' + (is_me ? recipient.username : user.username ) } + mk-time(time={ created_at }) + div.body + p.text + span.me(if={ is_me }) あなた: + | { text } + p.no-history(if={ history.length == 0 }) + | 履歴はありません。 + br + | ユーザーを検索して、いつでもメッセージを送受信できます。 + +style. + display block + + > .search + display block + position absolute + 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 + + > i + 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 12px 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 + > a + 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 + + &:hover + 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) + + > .main + padding-top 56px + + > .history + + > a + display block + padding 20px 30px + 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]) + background-image url("/_/resources/desktop/unread.svg") + background-repeat no-repeat + background-position 0 center + + &:after + content "" + display block + clear both + + > div + max-width 500px + margin 0 auto + + > 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 small + + > .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 + word-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 + +script. + @mixin \i + @mixin \api + + @search-result = [] + + @on \mount ~> + @api \messaging/history + .then (history) ~> + @is-loading = false + history.for-each (message) ~> + message.is_me = message.user_id == @I.id + message._click = ~> + if message.is_me + @trigger \navigate-user message.recipient + else + @trigger \navigate-user message.user + @history = history + @update! + .catch (err) ~> + console.error err + + @search = ~> + q = @refs.search-input.value + if q == '' + @search-result = [] + else + @api \users/search do + query: q + .then (users) ~> + users.for-each (user) ~> + user._click = ~> + @trigger \navigate-user user + @search-result = [] + @search-result = users + @update! + .catch (err) ~> + console.error err diff --git a/src/web/app/desktop/tags/messaging/message.tag b/src/web/app/desktop/tags/messaging/message.tag new file mode 100644 index 0000000000..d7a2cc32a6 --- /dev/null +++ b/src/web/app/desktop/tags/messaging/message.tag @@ -0,0 +1,227 @@ +mk-messaging-message(data-is-me={ message.is_me }) + a.avatar-anchor(href={ CONFIG.url + '/' + message.user.username }, title={ message.user.username }, target='_blank') + img.avatar(src={ message.user.avatar_url + '?thumbnail&size=64' }, alt='') + div.content-container + div.balloon + p.read(if={ message.is_me && message.is_read }) 既読 + button.delete-button(if={ message.is_me }, title='メッセージを削除') + img(src='/_/resources/desktop/messaging/delete.png', alt='Delete') + div.content(if={ !message.is_deleted }) + div@text + div.image(if={ message.file }) + img(src={ message.file.url }, alt='image', title={ message.file.name }) + div.content(if={ message.is_deleted }) + p.is-deleted このメッセージは削除されました + footer + mk-time(time={ message.created_at }) + i.fa.fa-pencil.is-edited(if={ message.is_edited }) + +style. + $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 + word-wrap break-word + font-size 1em + color rgba(0, 0, 0, 0.5) + + > [ref='text'] + display block + margin 0 + padding 8px 16px + overflow hidden + word-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) + + > .is-edited + 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 + +script. + @mixin \i + @mixin \text + + @message = @opts.message + @message.is_me = @message.user.id == @I.id + + @on \mount ~> + if @message.text? + tokens = @analyze @message.text + + @refs.text.innerHTML = @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content diff --git a/src/web/app/desktop/tags/messaging/room-window.tag b/src/web/app/desktop/tags/messaging/room-window.tag new file mode 100644 index 0000000000..673b11419a --- /dev/null +++ b/src/web/app/desktop/tags/messaging/room-window.tag @@ -0,0 +1,26 @@ +mk-messaging-room-window + mk-window@window(is-modal={ false }, width={ '500px' }, height={ '560px' }) + <yield to="header"> + i.fa.fa-comments + | メッセージ: { parent.user.name } + </yield> + <yield to="content"> + mk-messaging-room(user={ parent.user }) + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + > mk-messaging-room + height 100% + +script. + @user = @opts.user + + @on \mount ~> + @refs.window.on \closed ~> + @unmount! diff --git a/src/web/app/desktop/tags/messaging/room.tag b/src/web/app/desktop/tags/messaging/room.tag new file mode 100644 index 0000000000..ca396d7418 --- /dev/null +++ b/src/web/app/desktop/tags/messaging/room.tag @@ -0,0 +1,227 @@ +mk-messaging-room + div.stream@stream + p.initializing(if={ init }) + i.fa.fa-spinner.fa-spin + | 読み込み中 + p.empty(if={ !init && messages.length == 0 }) + i.fa.fa-info-circle + | このユーザーとまだ会話したことがありません + virtual(each={ message, i in messages }) + mk-messaging-message(message={ message }) + p.date(if={ i != messages.length - 1 && message._date != messages[i + 1]._date }) + span { messages[i + 1]._datetext } + + div.typings + footer + div@notifications + div.grippie(title='ドラッグしてフォームの広さを調整') + mk-messaging-form(user={ user }) + +style. + display block + + > .stream + position absolute + top 0 + left 0 + width 100% + height calc(100% - 100px) + overflow auto + + > .empty + width 100% + margin 0 + padding 16px 8px 8px 8px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + i + 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) + + i + 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 absolute + z-index 2 + bottom 0 + width 600px + max-width 100% + 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 + + > 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 + + > i + 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) + +script. + @mixin \i + @mixin \api + @mixin \messaging-stream + + @user = @opts.user + @init = true + @sending = false + @messages = [] + + @connection = new @MessagingStreamConnection @I, @user.id + + @on \mount ~> + @connection.event.on \message @on-message + @connection.event.on \read @on-read + + document.add-event-listener \visibilitychange @on-visibilitychange + + @api \messaging/messages do + user_id: @user.id + .then (messages) ~> + @init = false + @messages = messages.reverse! + @update! + @scroll-to-bottom! + .catch (err) ~> + console.error err + + @on \unmount ~> + @connection.event.off \message @on-message + @connection.event.off \read @on-read + @connection.close! + + document.remove-event-listener \visibilitychange @on-visibilitychange + + @on \update ~> + @messages.for-each (message) ~> + date = (new Date message.created_at).get-date! + month = (new Date message.created_at).get-month! + 1 + message._date = date + message._datetext = month + '月 ' + date + '日' + + @on-message = (message) ~> + is-bottom = @is-bottom! + + @messages.push message + if message.user_id != @I.id and not document.hidden + @connection.socket.send JSON.stringify do + type: \read + id: message.id + @update! + + if is-bottom + # Scroll to bottom + @scroll-to-bottom! + else if message.user_id != @I.id + # Notify + @notify '新しいメッセージがあります' + + @on-read = (ids) ~> + if not Array.isArray ids then ids = [ids] + ids.for-each (id) ~> + if (@messages.some (x) ~> x.id == id) + exist = (@messages.map (x) -> x.id).index-of id + @messages[exist].is_read = true + @update! + + @is-bottom = ~> + current = @refs.stream.scroll-top + @refs.stream.offset-height + max = @refs.stream.scroll-height + current > (max - 32) + + @scroll-to-bottom = ~> + @refs.stream.scroll-top = @refs.stream.scroll-height + + @notify = (message) ~> + n = document.create-element \p + n.inner-HTML = '<i class="fa fa-arrow-circle-down"></i>' + message + n.onclick = ~> + @scroll-to-bottom! + n.parent-node.remove-child n + @refs.notifications.append-child n + + set-timeout ~> + n.style.opacity = 0 + set-timeout ~> + n.parent-node.remove-child n + , 1000ms + , 4000ms + + @on-visibilitychange = ~> + if document.hidden then return + @messages.for-each (message) ~> + if message.user_id != @I.id and not message.is_read + @connection.socket.send JSON.stringify do + type: \read + id: message.id diff --git a/src/web/app/desktop/tags/messaging/window.tag b/src/web/app/desktop/tags/messaging/window.tag new file mode 100644 index 0000000000..b6979b6244 --- /dev/null +++ b/src/web/app/desktop/tags/messaging/window.tag @@ -0,0 +1,29 @@ +mk-messaging-window + mk-window@window(is-modal={ false }, width={ '500px' }, height={ '560px' }) + <yield to="header"> + i.fa.fa-comments + | メッセージ + </yield> + <yield to="content"> + mk-messaging@index + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + > mk-messaging + height 100% + +script. + @on \mount ~> + @refs.window.on \closed ~> + @unmount! + + @refs.window.refs.index.on \navigate-user (user) ~> + w = document.body.append-child document.create-element \mk-messaging-room-window + riot.mount w, do + user: user diff --git a/src/web/app/desktop/tags/notifications.tag b/src/web/app/desktop/tags/notifications.tag new file mode 100644 index 0000000000..d47815a89b --- /dev/null +++ b/src/web/app/desktop/tags/notifications.tag @@ -0,0 +1,226 @@ +mk-notifications + div.notifications(if={ notifications.length != 0 }) + virtual(each={ notification, i in notifications }) + div.notification(class={ notification.type }) + mk-time(time={ notification.created_at }) + + div.main(if={ notification.type == 'like' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-thumbs-o-up + a(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) { notification.user.name } + a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'repost' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-retweet + a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name } + a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post.repost) } + + div.main(if={ notification.type == 'quote' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-quote-left + a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'follow' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-user-plus + a(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) { notification.user.name } + + div.main(if={ notification.type == 'reply' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-reply + a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'mention' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-at + a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + p.date(if={ i != notifications.length - 1 && notification._date != notifications[i + 1]._date }) + span + i.fa.fa-angle-up + | { notification._datetext } + span + i.fa.fa-angle-down + | { notifications[i + 1]._datetext } + + p.empty(if={ notifications.length == 0 && !loading }) + | ありません! + p.loading(if={ loading }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + + > .notifications + > .notification + margin 0 + padding 16px + font-size 0.9em + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + &:last-child + border-bottom none + + > mk-time + display inline + position absolute + top 16px + right 12px + vertical-align top + color rgba(0, 0, 0, 0.6) + font-size small + + > .main + word-wrap break-word + + &:after + content "" + display block + clear both + + .avatar-anchor + display block + float left + + img + min-width 36px + min-height 36px + max-width 36px + max-height 36px + border-radius 6px + + .text + float right + width calc(100% - 36px) + padding-left 8px + + p + margin 0 + + i + margin-right 4px + + .post-preview + color rgba(0, 0, 0, 0.7) + + .post-ref + color rgba(0, 0, 0, 0.7) + + &:before, &:after + font-family FontAwesome + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &:before + content "\f10d" + + &:after + content "\f10e" + + &.like + .text p i + color #FFAC33 + + &.repost, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #555 + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.8em + color #aaa + background #fdfdfd + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + span + margin 0 16px + + i + margin-right 8px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \stream + @mixin \user-preview + @mixin \get-post-summary + + @notifications = [] + @loading = true + + @on \mount ~> + @api \i/notifications + .then (notifications) ~> + @notifications = notifications + @loading = false + @update! + .catch (err, text-status) -> + console.error err + + @stream.on \notification @on-notification + + @on \unmount ~> + @stream.off \notification @on-notification + + @on-notification = (notification) ~> + @notifications.unshift notification + @update! + + @on \update ~> + @notifications.for-each (notification) ~> + date = (new Date notification.created_at).get-date! + month = (new Date notification.created_at).get-month! + 1 + notification._date = date + notification._datetext = month + '月 ' + date + '日' diff --git a/src/web/app/desktop/tags/pages/entrance.tag b/src/web/app/desktop/tags/pages/entrance.tag new file mode 100644 index 0000000000..5e18f616a3 --- /dev/null +++ b/src/web/app/desktop/tags/pages/entrance.tag @@ -0,0 +1,77 @@ +mk-entrance + main + img(src='/_/resources/title.svg', alt='Misskey') + + mk-entrance-signin(if={ mode == 'signin' }) + mk-entrance-signup(if={ mode == 'signup' }) + div.introduction(if={ mode == 'introduction' }) + mk-introduction + button(onclick={ signin }) わかった + + mk-forkit + + footer + mk-copyright + + // ↓ https://github.com/riot/riot/issues/2134 (将来的) + style(data-disable-scope). + #wait { + right: auto; + left: 15px; + } + +style. + display block + height 100% + + > main + display block + + > img + display block + width 160px + height 170px + margin 0 auto + pointer-events none + user-select none + + > .introduction + max-width 360px + margin 0 auto + color #777 + + > mk-introduction + padding 32px + background #fff + box-shadow 0 4px 16px rgba(0, 0, 0, 0.2) + + > button + display block + margin 16px auto 0 auto + color #666 + + &:hover + text-decoration underline + + > footer + > mk-copyright + margin 0 + text-align center + line-height 64px + font-size 10px + color rgba(#000, 0.5) + +script. + @mode = \signin + + @signup = ~> + @mode = \signup + @update! + + @signin = ~> + @mode = \signin + @update! + + @introduction = ~> + @mode = \introduction + @update! diff --git a/src/web/app/desktop/tags/pages/entrance/signin.tag b/src/web/app/desktop/tags/pages/entrance/signin.tag new file mode 100644 index 0000000000..8ff39bc296 --- /dev/null +++ b/src/web/app/desktop/tags/pages/entrance/signin.tag @@ -0,0 +1,128 @@ +mk-entrance-signin + a.help(href={ CONFIG.urls.about + '/help' }, title='お困りですか?'): i.fa.fa-question + div.form + h1 + img(if={ user }, src={ user.avatar_url + '?thumbnail&size=32' }) + p { user ? user.name : 'アカウント' } + mk-signin@signin + div.divider: span or + button.signup(onclick={ parent.signup }) 新規登録 + a.introduction(onclick={ introduction }) Misskeyについて + +style. + display block + width 290px + margin 0 auto + text-align center + + &:hover + > .help + opacity 1 + + > .help + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + background transparent + opacity 0 + transition opacity 0.1s ease + + &:hover + color #444 + + &:active + color #222 + + > i + padding 14px + + > .form + padding 10px 28px 16px 28px + background #fff + box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) + + > h1 + display block + margin 0 + padding 0 + height 54px + line-height 54px + text-align center + text-transform uppercase + font-size 1em + font-weight bold + color rgba(0, 0, 0, 0.5) + border-bottom solid 1px rgba(0, 0, 0, 0.1) + + > p + display inline + margin 0 + padding 0 + + > img + display inline-block + top 10px + width 32px + height 32px + margin-right 8px + border-radius 100% + + &[src=''] + display none + + > .divider + padding 16px 0 + text-align center + + &:after + content "" + display block + position absolute + top 50% + width 100% + height 1px + border-top solid 1px rgba(0, 0, 0, 0.1) + + > * + z-index 1 + padding 0 8px + color rgba(0, 0, 0, 0.5) + background #fdfdfd + + > .signup + width 100% + line-height 56px + font-size 1em + color #fff + background $theme-color + border-radius 64px + + &:hover + background lighten($theme-color, 5%) + + &:active + background darken($theme-color, 5%) + + > .introduction + display inline-block + margin-top 16px + font-size 12px + color #666 + +script. + @on \mount ~> + @refs.signin.on \user (user) ~> + @update do + user: user + + @introduction = ~> + @parent.introduction! diff --git a/src/web/app/desktop/tags/pages/entrance/signup.tag b/src/web/app/desktop/tags/pages/entrance/signup.tag new file mode 100644 index 0000000000..1b585f7000 --- /dev/null +++ b/src/web/app/desktop/tags/pages/entrance/signup.tag @@ -0,0 +1,44 @@ +mk-entrance-signup + mk-signup + button.cancel(type='button', onclick={ parent.signin }, title='キャンセル'): i.fa.fa-times + +style. + display block + width 368px + margin 0 auto + + &:hover + > .cancel + opacity 1 + + > mk-signup + padding 18px 32px 0 32px + background #fff + box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) + + > .cancel + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + box-shadow none + background transparent + opacity 0 + transition opacity 0.1s ease + + &:hover + color #555 + + &:active + color #222 + + > i + padding 14px diff --git a/src/web/app/desktop/tags/pages/home.tag b/src/web/app/desktop/tags/pages/home.tag new file mode 100644 index 0000000000..5d419a5802 --- /dev/null +++ b/src/web/app/desktop/tags/pages/home.tag @@ -0,0 +1,51 @@ +mk-home-page + mk-ui@ui(page={ page }): mk-home@home(mode={ parent.opts.mode }) + +style. + display block + + background-position center center + background-attachment fixed + background-size cover + +script. + @mixin \i + @mixin \api + @mixin \ui-progress + @mixin \stream + @mixin \get-post-summary + + @unread-count = 0 + + @page = switch @opts.mode + | \timelie => \home + | \mentions => \mentions + | _ => \home + + @on \mount ~> + @refs.ui.refs.home.on \loaded ~> + @Progress.done! + + document.title = 'Misskey' + if @I.data.wallpaper + @api \drive/files/show do + file_id: @I.data.wallpaper + .then (file) ~> + @root.style.background-image = 'url(' + file.url + ')' + @Progress.start! + @stream.on \post @on-stream-post + document.add-event-listener \visibilitychange @window-on-visibilitychange, false + + @on \unmount ~> + @stream.off \post @on-stream-post + document.remove-event-listener \visibilitychange @window-on-visibilitychange + + @on-stream-post = (post) ~> + if document.hidden and post.user_id !== @I.id + @unread-count++ + document.title = '(' + @unread-count + ') ' + @get-post-summary post + + @window-on-visibilitychange = ~> + if !document.hidden + @unread-count = 0 + document.title = 'Misskey' diff --git a/src/web/app/desktop/tags/pages/not-found.tag b/src/web/app/desktop/tags/pages/not-found.tag new file mode 100644 index 0000000000..fe23cc6fa4 --- /dev/null +++ b/src/web/app/desktop/tags/pages/not-found.tag @@ -0,0 +1,46 @@ +mk-not-found + mk-ui + main + h1 Not Found + img(src='/_/resources/rogge.jpg', alt='') + div.mask + +style. + display block + + main + display block + width 600px + margin 32px auto + + > img + display block + width 600px + height 459px + pointer-events none + user-select none + border-radius 16px + box-shadow 0 0 16px rgba(0, 0, 0, 0.1) + + > h1 + display block + margin 0 + padding 0 + position absolute + top 260px + left 225px + transform rotate(-12deg) + z-index 2 + color #444 + font-size 24px + line-height 20px + + > .mask + position absolute + top 262px + left 217px + width 126px + height 18px + transform rotate(-12deg) + background #D6D5DA + border-radius 2px 6px 7px 6px diff --git a/src/web/app/desktop/tags/pages/post.tag b/src/web/app/desktop/tags/pages/post.tag new file mode 100644 index 0000000000..81ab9ce006 --- /dev/null +++ b/src/web/app/desktop/tags/pages/post.tag @@ -0,0 +1,25 @@ +mk-post-page + mk-ui@ui: main: mk-post-detail@detail(post={ parent.post }) + +style. + display block + + main + padding 16px + + > mk-post-detail + margin 0 auto + +script. + @mixin \ui-progress + + @post = @opts.post + + @on \mount ~> + @Progress.start! + + @refs.ui.refs.detail.on \post-fetched ~> + @Progress.set 0.5 + + @refs.ui.refs.detail.on \loaded ~> + @Progress.done! diff --git a/src/web/app/desktop/tags/pages/search.tag b/src/web/app/desktop/tags/pages/search.tag new file mode 100644 index 0000000000..a7878ddc0f --- /dev/null +++ b/src/web/app/desktop/tags/pages/search.tag @@ -0,0 +1,14 @@ +mk-search-page + mk-ui@ui: mk-search@search(query={ parent.opts.query }) + +style. + display block + +script. + @mixin \ui-progress + + @on \mount ~> + @Progress.start! + + @refs.ui.refs.search.on \loaded ~> + @Progress.done! diff --git a/src/web/app/desktop/tags/pages/user.tag b/src/web/app/desktop/tags/pages/user.tag new file mode 100644 index 0000000000..d41093c298 --- /dev/null +++ b/src/web/app/desktop/tags/pages/user.tag @@ -0,0 +1,20 @@ +mk-user-page + mk-ui@ui: mk-user@user(user={ parent.user }, page={ parent.opts.page }) + +style. + display block + +script. + @mixin \ui-progress + + @user = @opts.user + + @on \mount ~> + @Progress.start! + + @refs.ui.refs.user.on \user-fetched (user) ~> + @Progress.set 0.5 + document.title = user.name + ' | Misskey' + + @refs.ui.refs.user.on \loaded ~> + @Progress.done! diff --git a/src/web/app/desktop/tags/post-detail-sub.tag b/src/web/app/desktop/tags/post-detail-sub.tag new file mode 100644 index 0000000000..b7aa745737 --- /dev/null +++ b/src/web/app/desktop/tags/post-detail-sub.tag @@ -0,0 +1,141 @@ +mk-post-detail-sub(title={ title }) + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ post.user_id }) + div.main + header + div.left + a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }) + | { post.user.name } + span.username + | @{ post.user.username } + div.right + a.time(href={ url }) + mk-time(time={ post.created_at }) + div.body + div.text@text + div.media(if={ post.media }) + virtual(each={ file in post.media }) + img(src={ file.url + '?thumbnail&size=512' }, alt={ file.name }, title={ file.name }) + +style. + display block + margin 0 + padding 20px 32px + background #fdfdfd + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 44px + height 44px + margin 0 + border-radius 4px + vertical-align bottom + + > .main + float left + width calc(100% - 60px) + + > header + margin-bottom 4px + white-space nowrap + + &:after + content "" + display block + clear both + + > .left + float left + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .right + float right + + > .time + font-size 0.9em + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1em + color #717171 + + > mk-url-preview + margin-top 8px + + > .media + > img + display block + max-width 100% + +script. + @mixin \api + @mixin \text + @mixin \date-stringify + @mixin \user-preview + + @post = @opts.post + + @url = CONFIG.url + '/' + @post.user.username + '/' + @post.id + + @title = @date-stringify @post.created_at + + @on \mount ~> + if @post.text? + tokens = @analyze @post.text + @refs.text.innerHTML = @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + @like = ~> + if @post.is_liked + @api \posts/likes/delete do + post_id: @post.id + .then ~> + @post.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @post.id + .then ~> + @post.is_liked = true + @update! diff --git a/src/web/app/desktop/tags/post-detail.tag b/src/web/app/desktop/tags/post-detail.tag new file mode 100644 index 0000000000..e071b7c704 --- /dev/null +++ b/src/web/app/desktop/tags/post-detail.tag @@ -0,0 +1,415 @@ +mk-post-detail(title={ title }) + + div.fetching(if={ fetching }) + mk-ellipsis-icon + + div.main(if={ !fetching }) + + button.read-more(if={ p.reply_to && p.reply_to.reply_to_id && context == null }, title='会話をもっと読み込む', onclick={ load-context }, disabled={ loading-context }) + i.fa.fa-ellipsis-v(if={ !loading-context }) + i.fa.fa-spinner.fa-pulse(if={ loading-context }) + + div.context + virtual(each={ post in context }) + mk-post-detail-sub(post={ post }) + + div.reply-to(if={ p.reply_to }) + mk-post-detail-sub(post={ p.reply_to }) + + div.repost(if={ is-repost }) + p + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=32' }, alt='avatar') + i.fa.fa-retweet + a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name } + | がRepost + + article + a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username }) + img.avatar(src={ p.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ p.user.id }) + header + a.name(href={ CONFIG.url + '/' + p.user.username }, data-user-preview={ p.user.id }) + | { p.user.name } + span.username + | @{ p.user.username } + a.time(href={ url }) + mk-time(time={ p.created_at }) + div.body + div.text@text + div.media(if={ p.media }) + virtual(each={ file in p.media }) + img(src={ file.url + '?thumbnail&size=512' }, alt={ file.name }, title={ file.name }) + footer + button(onclick={ reply }, title='返信') + i.fa.fa-reply + p.count(if={ p.replies_count > 0 }) { p.replies_count } + button(onclick={ repost }, title='Repost') + i.fa.fa-retweet + p.count(if={ p.repost_count > 0 }) { p.repost_count } + button(class={ liked: p.is_liked }, onclick={ like }, title='善哉') + i.fa.fa-thumbs-o-up + p.count(if={ p.likes_count > 0 }) { p.likes_count } + button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h + div.reposts-and-likes + div.reposts(if={ reposts && reposts.length > 0 }) + header + a { p.repost_count } + p Repost + ol.users + li.user(each={ reposts }) + a.avatar-anchor(href={ CONFIG.url + '/' + user.username }, title={ user.name }, data-user-preview={ user.id }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=32' }, alt='') + div.likes(if={ likes && likes.length > 0 }) + header + a { p.likes_count } + p いいね + ol.users + li.user(each={ likes }) + a.avatar-anchor(href={ CONFIG.url + '/' + username }, title={ name }, data-user-preview={ id }) + img.avatar(src={ avatar_url + '?thumbnail&size=32' }, alt='') + + div.replies + virtual(each={ post in replies }) + mk-post-detail-sub(post={ post }) + +style. + display block + margin 0 + padding 0 + width 640px + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 8px + + > .fetching + padding 64px 0 + + > .main + + > .read-more + display block + margin 0 + padding 10px 0 + width 100% + font-size 1em + text-align center + color #999 + cursor pointer + background #fafafa + outline none + border none + border-bottom solid 1px #eef0f2 + border-radius 6px 6px 0 0 + + &:hover + background #f6f6f6 + + &:active + background #f0f0f0 + + &:disabled + color #ccc + + > .context + > * + border-bottom 1px solid #eef0f2 + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + & + article + padding-top 8px + + > .reply-to + border-bottom 1px solid #eef0f2 + + > article + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + width 60px + height 60px + + > .avatar + display block + width 60px + height 60px + margin 0 + border-radius 8px + vertical-align bottom + + > header + position absolute + top 28px + left 108px + width calc(100% - 108px) + + > .name + display inline-block + margin 0 + line-height 24px + color #777 + font-size 18px + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + display block + text-align left + margin 0 + color #ccc + + > .time + position absolute + top 0 + right 32px + font-size 1em + color #c0c0c0 + + > .body + padding 8px 0 + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.5em + color #717171 + + > mk-url-preview + margin-top 8px + + > .media + > img + display block + max-width 100% + + > footer + font-size 1.2em + + > button + margin 0 28px 0 0 + padding 8px + background transparent + border none + font-size 1em + color #ddd + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.liked + color $theme-color + + > .reposts-and-likes + display flex + justify-content center + padding 0 + margin 16px 0 + + &:empty + display none + + > .reposts + > .likes + display flex + flex 1 1 + padding 0 + border-top solid 1px #F2EFEE + + > header + flex 1 1 80px + max-width 80px + padding 8px 5px 0px 10px + + > a + display block + font-size 1.5em + line-height 1.4em + + > p + display block + margin 0 + font-size 0.7em + line-height 1em + font-weight normal + color #a0a2a5 + + > .users + display block + flex 1 1 + margin 0 + padding 10px 10px 10px 5px + list-style none + + > .user + display block + float left + margin 4px + padding 0 + + > .avatar-anchor + display:block + + > .avatar + vertical-align bottom + width 24px + height 24px + border-radius 4px + + > .reposts + .likes + margin-left 16px + + > .replies + > * + border-top 1px solid #eef0f2 + +script. + @mixin \api + @mixin \text + @mixin \user-preview + @mixin \date-stringify + @mixin \NotImplementedException + + @fetching = true + @loading-context = false + @content = null + @post = null + + @on \mount ~> + + @api \posts/show do + post_id: @opts.post + .then (post) ~> + @fetching = false + @post = post + @trigger \loaded + + @is-repost = @post.repost? + @p = if @is-repost then @post.repost else @post + + @title = @date-stringify @p.created_at + + @update! + + if @p.text? + tokens = @analyze @p.text + @refs.text.innerHTML = @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content + + # Get likes + @api \posts/likes do + post_id: @p.id + limit: 8 + .then (likes) ~> + @likes = likes + @update! + + # Get reposts + @api \posts/reposts do + post_id: @p.id + limit: 8 + .then (reposts) ~> + @reposts = reposts + @update! + + # Get replies + @api \posts/replies do + post_id: @p.id + limit: 8 + .then (replies) ~> + @replies = replies + @update! + + @update! + + @reply = ~> + form = document.body.append-child document.create-element \mk-post-form-window + riot.mount form, do + reply: @p + + @repost = ~> + form = document.body.append-child document.create-element \mk-repost-form-window + riot.mount form, do + post: @p + + @like = ~> + if @p.is_liked + @api \posts/likes/delete do + post_id: @p.id + .then ~> + @p.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @p.id + .then ~> + @p.is_liked = true + @update! + + @load-context = ~> + @loading-context = true + + # Get context + @api \posts/context do + post_id: @p.reply_to_id + .then (context) ~> + @context = context.reverse! + @loading-context = false + @update! diff --git a/src/web/app/desktop/tags/post-form-window.tag b/src/web/app/desktop/tags/post-form-window.tag new file mode 100644 index 0000000000..8727777944 --- /dev/null +++ b/src/web/app/desktop/tags/post-form-window.tag @@ -0,0 +1,60 @@ +mk-post-form-window + + mk-window@window(is-modal={ true }, colored={ true }) + + <yield to="header"> + span(if={ !parent.opts.reply }) 新規投稿 + span(if={ parent.opts.reply }) 返信 + span.files(if={ parent.files.length != 0 }) 添付: { parent.files.length }ファイル + span.uploading-files(if={ parent.uploading-files.length != 0 }) + | { parent.uploading-files.length }個のファイルをアップロード中 + mk-ellipsis + </yield> + + <yield to="content"> + div.ref(if={ parent.opts.reply }) + mk-post-preview(post={ parent.opts.reply }) + div.body + mk-post-form@form(reply={ parent.opts.reply }) + </yield> + +style. + > mk-window + + [data-yield='header'] + > .files + > .uploading-files + margin-left 8px + opacity 0.8 + + &:before + content '(' + + &:after + content ')' + + [data-yield='content'] + > .ref + > mk-post-preview + margin 16px 22px + +script. + @uploading-files = [] + @files = [] + + @on \mount ~> + @refs.window.refs.form.focus! + + @refs.window.on \closed ~> + @unmount! + + @refs.window.refs.form.on \post ~> + @refs.window.close! + + @refs.window.refs.form.on \change-uploading-files (files) ~> + @uploading-files = files + @update! + + @refs.window.refs.form.on \change-files (files) ~> + @files = files + @update! diff --git a/src/web/app/desktop/tags/post-form.tag b/src/web/app/desktop/tags/post-form.tag new file mode 100644 index 0000000000..2248587885 --- /dev/null +++ b/src/web/app/desktop/tags/post-form.tag @@ -0,0 +1,430 @@ +mk-post-form(ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }) + textarea@text(disabled={ wait }, class={ withfiles: files.length != 0 }, oninput={ update }, onkeydown={ onkeydown }, onpaste={ onpaste }, placeholder={ opts.reply ? 'この投稿への返信...' : 'いまどうしてる?' }) + div.attaches(if={ files.length != 0 }) + ul.files@attaches + li.file(each={ files }) + div.img(style='background-image: url({ url + "?thumbnail&size=64" })', title={ name }) + img.remove(onclick={ _remove }, src='/_/resources/desktop/remove.png', title='添付取り消し', alt='') + li.add(if={ files.length < 4 }, title='PCからファイルを添付', onclick={ select-file }): i.fa.fa-plus + p.remain + | 残り{ 4 - files.length } + mk-uploader@uploader + button@upload(title='PCからファイルを添付', onclick={ select-file }): i.fa.fa-upload + button@drive(title='ドライブからファイルを添付', onclick={ select-file-from-drive }): i.fa.fa-cloud + p.text-count(class={ over: refs.text.value.length > 300 }) のこり{ 300 - refs.text.value.length }文字 + button@submit(class={ wait: wait }, disabled={ wait || (refs.text.value.length == 0 && files.length == 0) }, onclick={ post }) + | { wait ? '投稿中' : opts.reply ? '返信' : '投稿' } + mk-ellipsis(if={ wait }) + input@file(type='file', accept='image/*', multiple, tabindex='-1', onchange={ change-file }) + div.dropzone(if={ draghover }) + +style. + display block + padding 16px + background lighten($theme-color, 95%) + + &:after + content "" + display block + clear both + + > .attaches + margin 0 + padding 0 + background lighten($theme-color, 98%) + border solid 1px rgba($theme-color, 0.1) + border-top none + border-radius 0 0 4px 4px + transition border-color .3s ease + + > .remain + display block + position absolute + top 8px + right 8px + margin 0 + padding 0 + color rgba($theme-color, 0.4) + + > .files + display block + margin 0 + padding 4px + list-style none + + &:after + content "" + display block + clear both + + > .file + display block + float left + margin 4px + padding 0 + cursor move + + &:hover > .remove + display block + + > .img + width 64px + height 64px + background-size cover + background-position center center + + > .remove + display none + position absolute + top -6px + right -6px + width 16px + height 16px + cursor pointer + + > .add + display block + float left + margin 4px + padding 0 + border dashed 2px rgba($theme-color, 0.2) + cursor pointer + + &:hover + border-color rgba($theme-color, 0.3) + + > i + color rgba($theme-color, 0.4) + + > i + display block + width 60px + height 60px + line-height 60px + text-align center + font-size 1.2em + color rgba($theme-color, 0.2) + + > mk-uploader + margin 8px 0 0 0 + padding 8px + border solid 1px rgba($theme-color, 0.2) + border-radius 4px + + [ref='file'] + display none + + [ref='text'] + display block + padding 12px + margin 0 + width 100% + max-width 100% + min-width 100% + min-height calc(16px + 12px + 12px) + font-size 16px + color #333 + background #fff + outline none + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + transition border-color .3s ease + + &:hover + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + color $theme-color + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &:disabled + opacity 0.5 + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + + &.withfiles + border-bottom solid 1px rgba($theme-color, 0.1) !important + border-radius 4px 4px 0 0 + + &:hover + .attaches + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + .attaches + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + .text-count + pointer-events none + display block + position absolute + bottom 16px + right 138px + margin 0 + line-height 40px + color rgba($theme-color, 0.5) + + &.over + color #ec3828 + + [ref='submit'] + display block + position absolute + bottom 16px + right 16px + cursor pointer + padding 0 + margin 0 + width 110px + height 40px + font-size 1em + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + outline none + border solid 1px lighten($theme-color, 15%) + border-radius 4px + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + &.wait + background linear-gradient( + 45deg, + darken($theme-color, 10%) 25%, + $theme-color 25%, + $theme-color 50%, + darken($theme-color, 10%) 50%, + darken($theme-color, 10%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation stripe-bg 1.5s linear infinite + opacity 0.7 + cursor wait + + @keyframes stripe-bg + from {background-position: 0 0;} + to {background-position: -64px 32px;} + + [ref='upload'] + [ref='drive'] + display inline-block + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background linear-gradient(to bottom, lighten($theme-color, 80%) 0%, lighten($theme-color, 90%) 100%) + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(0, 0, 0, 0.15) inset + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + > .dropzone + position absolute + left 0 + top 0 + width 100% + height 100% + border dashed 2px rgba($theme-color, 0.5) + pointer-events none + +script. + @mixin \api + @mixin \notify + @mixin \autocomplete + @mixin \sortable + + @wait = false + @uploadings = [] + @files = [] + @autocomplete = null + + @in-reply-to-post = @opts.reply + + # https://github.com/riot/riot/issues/2080 + if @in-reply-to-post == '' then @in-reply-to-post = null + + @on \mount ~> + @refs.uploader.on \uploaded (file) ~> + @add-file file + + @refs.uploader.on \change-uploads (uploads) ~> + @trigger \change-uploading-files uploads + + @autocomplete = new @Autocomplete @refs.text + @autocomplete.attach! + + @on \unmount ~> + @autocomplete.detach! + + @focus = ~> + @refs.text.focus! + + @clear = ~> + @refs.text.value = '' + @files = [] + @trigger \change-files + @update! + + @ondragover = (e) ~> + e.stop-propagation! + @draghover = true + # ドラッグされてきたものがファイルだったら + if e.data-transfer.effect-allowed == \all + e.data-transfer.drop-effect = \copy + else + e.data-transfer.drop-effect = \move + return false + + @ondragenter = (e) ~> + @draghover = true + + @ondragleave = (e) ~> + @draghover = false + + @ondrop = (e) ~> + e.prevent-default! + e.stop-propagation! + @draghover = false + + # ファイルだったら + if e.data-transfer.files.length > 0 + Array.prototype.for-each.call e.data-transfer.files, (file) ~> + @upload file + return false + + # データ取得 + data = e.data-transfer.get-data 'text' + if !data? + return false + + try + # パース + obj = JSON.parse data + + # (ドライブの)ファイルだったら + if obj.type == \file + @add-file obj.file + catch + # ignore + + return false + + @onkeydown = (e) ~> + if (e.which == 10 || e.which == 13) && (e.ctrl-key || e.meta-key) + @post! + + @onpaste = (e) ~> + data = e.clipboard-data + items = data.items + for i from 0 to items.length - 1 + item = items[i] + switch (item.kind) + | \file => + @upload item.get-as-file! + + @select-file = ~> + @refs.file.click! + + @select-file-from-drive = ~> + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + i = riot.mount browser, do + multiple: true + i[0].one \selected (files) ~> + files.for-each @add-file + + @change-file = ~> + files = @refs.file.files + for i from 0 to files.length - 1 + file = files.item i + @upload file + + @upload = (file) ~> + @refs.uploader.upload file + + @add-file = (file) ~> + file._remove = ~> + @files = @files.filter (x) -> x.id != file.id + @trigger \change-files @files + @update! + + @files.push file + @trigger \change-files @files + @update! + + new @Sortable @refs.attaches, do + draggable: \.file + animation: 150ms + + @post = (e) ~> + @wait = true + + files = if @files? and @files.length > 0 + then @files.map (f) -> f.id + else undefined + + @api \posts/create do + text: @refs.text.value + media_ids: files + reply_to_id: if @in-reply-to-post? then @in-reply-to-post.id else undefined + .then (data) ~> + @trigger \post + @notify if @in-reply-to-post? then '返信しました!' else '投稿しました!' + .catch (err) ~> + console.error err + @notify '投稿できませんでした' + .then ~> + @wait = false + @update! diff --git a/src/web/app/desktop/tags/post-preview.tag b/src/web/app/desktop/tags/post-preview.tag new file mode 100644 index 0000000000..f17b152801 --- /dev/null +++ b/src/web/app/desktop/tags/post-preview.tag @@ -0,0 +1,94 @@ +mk-post-preview(title={ title }) + article + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ post.user_id }) + div.main + header + a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }) + | { post.user.name } + span.username + | @{ post.user.username } + a.time(href={ CONFIG.url + '/' + post.user.username + '/' + post.id }) + mk-time(time={ post.created_at }) + div.body + mk-sub-post-content.text(post={ post }) + +style. + display block + margin 0 + padding 0 + font-size 0.9em + background #fff + + > article + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 52px + height 52px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 68px) + + > header + margin-bottom 4px + white-space nowrap + + > .name + display inline + margin 0 + padding 0 + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #d1d8da + + > .time + position absolute + top 0 + right 0 + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + +script. + @mixin \date-stringify + @mixin \user-preview + + @post = @opts.post + + @title = @date-stringify @post.created_at diff --git a/src/web/app/desktop/tags/post-status-graph.tag b/src/web/app/desktop/tags/post-status-graph.tag new file mode 100644 index 0000000000..ffb081e4f3 --- /dev/null +++ b/src/web/app/desktop/tags/post-status-graph.tag @@ -0,0 +1,72 @@ +mk-post-status-graph + canvas@canv(width={ opts.width }, height={ opts.height }) + +style. + display block + + > canvas + margin 0 auto + +script. + @mixin \api + @mixin \is-promise + + @post = null + @post-promise = if @is-promise @opts.post then @opts.post else Promise.resolve @opts.post + + @on \mount ~> + post <~ @post-promise.then + @post = post + @update! + + @api \aggregation/posts/like do + post_id: @post.id + limit: 30days + .then (likes) ~> + likes = likes.reverse! + + @api \aggregation/posts/repost do + post_id: @post.id + limit: 30days + .then (repost) ~> + repost = repost.reverse! + + @api \aggregation/posts/reply do + post_id: @post.id + limit: 30days + .then (replies) ~> + replies = replies.reverse! + + new Chart @refs.canv, do + type: \bar + data: + labels: likes.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else '' + datasets: [ + { + label: \いいね + type: \line + data: likes.map (x) ~> x.count + line-tension: 0 + border-width: 2 + fill: true + background-color: 'rgba(247, 121, 108, 0.2)' + point-background-color: \#fff + point-radius: 4 + point-border-width: 2 + border-color: \#F7796C + }, + { + label: \返信 + type: \bar + data: replies.map (x) ~> x.count + background-color: \#555 + }, + { + label: \Repost + type: \bar + data: repost.map (x) ~> x.count + background-color: \#a2d61e + } + ] + options: + responsive: false diff --git a/src/web/app/desktop/tags/progress-dialog.tag b/src/web/app/desktop/tags/progress-dialog.tag new file mode 100644 index 0000000000..7c042686e6 --- /dev/null +++ b/src/web/app/desktop/tags/progress-dialog.tag @@ -0,0 +1,92 @@ +mk-progress-dialog + mk-window@window(is-modal={ false }, can-close={ false }, width={ '500px' }) + <yield to="header"> + | { parent.title } + mk-ellipsis + </yield> + <yield to="content"> + div.body + p.init(if={ isNaN(parent.value) }) + | 待機中 + mk-ellipsis + p.percentage(if={ !isNaN(parent.value) }) { Math.floor((parent.value / parent.max) * 100) } + progress(if={ !isNaN(parent.value) && parent.value < parent.max }, value={ isNaN(parent.value) ? 0 : parent.value }, max={ parent.max }) + div.progress.waiting(if={ parent.value >= parent.max }) + </yield> + +style. + display block + + > mk-window + [data-yield='content'] + + > .body + padding 18px 24px 24px 24px + + > .init + display block + margin 0 + text-align center + color rgba(#000, 0.7) + + > .percentage + display block + margin 0 0 4px 0 + text-align center + line-height 16px + color rgba($theme-color, 0.7) + + &:after + content '%' + + > progress + > .progress + display block + margin 0 + width 100% + height 10px + background transparent + border none + border-radius 4px + overflow hidden + + &::-webkit-progress-value + background $theme-color + + &::-webkit-progress-bar + background rgba($theme-color, 0.1) + + > .progress + background linear-gradient( + 45deg, + lighten($theme-color, 30%) 25%, + $theme-color 25%, + $theme-color 50%, + lighten($theme-color, 30%) 50%, + lighten($theme-color, 30%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation progress-dialog-tag-progress-waiting 1.5s linear infinite + + @keyframes progress-dialog-tag-progress-waiting + from {background-position: 0 0;} + to {background-position: -64px 32px;} + +script. + @title = @opts.title + @value = parse-int @opts.value, 10 + @max = parse-int @opts.max, 10 + + @on \mount ~> + @refs.window.on \closed ~> + @unmount! + + @update-progress = (value, max) ~> + @value = parse-int value, 10 + @max = parse-int max, 10 + @update! + + @close = ~> + @refs.window.close! diff --git a/src/web/app/desktop/tags/repost-form-window.tag b/src/web/app/desktop/tags/repost-form-window.tag new file mode 100644 index 0000000000..40012f951f --- /dev/null +++ b/src/web/app/desktop/tags/repost-form-window.tag @@ -0,0 +1,38 @@ +mk-repost-form-window + mk-window@window(is-modal={ true }, colored={ true }) + <yield to="header"> + i.fa.fa-retweet + | この投稿をRepostしますか? + </yield> + <yield to="content"> + mk-repost-form@form(post={ parent.opts.post }) + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + +script. + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 27 # Esc + @refs.window.close! + + @on \mount ~> + @refs.window.refs.form.on \cancel ~> + @refs.window.close! + + @refs.window.refs.form.on \posted ~> + @refs.window.close! + + document.add-event-listener \keydown @on-document-keydown + + @refs.window.on \closed ~> + @unmount! + + @on \unmount ~> + document.remove-event-listener \keydown @on-document-keydown diff --git a/src/web/app/desktop/tags/repost-form.tag b/src/web/app/desktop/tags/repost-form.tag new file mode 100644 index 0000000000..37fbad251d --- /dev/null +++ b/src/web/app/desktop/tags/repost-form.tag @@ -0,0 +1,140 @@ +mk-repost-form + mk-post-preview(post={ opts.post }) + div.form(if={ quote }) + textarea@text(disabled={ wait }, placeholder='この投稿を引用...') + footer + a.quote(if={ !quote }, onclick={ onquote }) 引用する... + button.cancel(onclick={ cancel }) キャンセル + button.ok(onclick={ ok }) Repost + +style. + + > mk-post-preview + margin 16px 22px + + > .form + [ref='text'] + display block + padding 12px + margin 0 + width 100% + max-width 100% + min-width 100% + min-height calc(1em + 12px + 12px) + font-size 1em + color #333 + background #fff + outline none + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + transition border-color .3s ease + + &:hover + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + color $theme-color + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &:disabled + opacity 0.5 + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + + > div + padding 16px + + > footer + height 72px + background lighten($theme-color, 95%) + + > .quote + position absolute + bottom 16px + left 28px + line-height 40px + + button + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + > .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + > .ok + right 16px + font-weight bold + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:hover + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active + background $theme-color + border-color $theme-color + +script. + @mixin \api + @mixin \notify + + @wait = false + @quote = false + + @cancel = ~> + @trigger \cancel + + @ok = ~> + @wait = true + @api \posts/create do + repost_id: @opts.post.id + text: if @quote then @refs.text.value else undefined + .then (data) ~> + @trigger \posted + @notify 'Repostしました!' + .catch (err) ~> + console.error err + @notify 'Repostできませんでした' + .then ~> + @wait = false + @update! + + @onquote = ~> + @quote = true diff --git a/src/web/app/desktop/tags/search-posts.tag b/src/web/app/desktop/tags/search-posts.tag new file mode 100644 index 0000000000..9862ff6e4e --- /dev/null +++ b/src/web/app/desktop/tags/search-posts.tag @@ -0,0 +1,88 @@ +mk-search-posts + div.loading(if={ is-loading }) + mk-ellipsis-icon + p.empty(if={ is-empty }) + i.fa.fa-search + | 「{ query }」に関する投稿は見つかりませんでした。 + mk-timeline@timeline + <yield to="footer"> + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + </yield> + +style. + display block + background #fff + + > .loading + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + +script. + @mixin \api + @mixin \get-post-summary + + @query = @opts.query + @is-loading = true + @is-empty = false + @more-loading = false + @page = 0 + + @on \mount ~> + document.add-event-listener \keydown @on-document-keydown + window.add-event-listener \scroll @on-scroll + + @api \posts/search do + query: @query + .then (posts) ~> + @is-loading = false + @is-empty = posts.length == 0 + @update! + @refs.timeline.set-posts posts + @trigger \loaded + .catch (err) ~> + console.error err + + @on \unmount ~> + document.remove-event-listener \keydown @on-document-keydown + window.remove-event-listener \scroll @on-scroll + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 84 # t + @refs.timeline.focus! + + @more = ~> + if @more-loading or @is-loading or @timeline.posts.length == 0 + return + @more-loading = true + @update! + @api \posts/search do + query: @query + page: @page + 1 + .then (posts) ~> + @more-loading = false + @page++ + @update! + @refs.timeline.prepend-posts posts + .catch (err) ~> + console.error err + + @on-scroll = ~> + current = window.scroll-y + window.inner-height + if current > document.body.offset-height - 16 # 遊び + @more! diff --git a/src/web/app/desktop/tags/search.tag b/src/web/app/desktop/tags/search.tag new file mode 100644 index 0000000000..aec426ac79 --- /dev/null +++ b/src/web/app/desktop/tags/search.tag @@ -0,0 +1,28 @@ +mk-search + header + h1 { query } + mk-search-posts@posts(query={ query }) + +style. + display block + padding-bottom 32px + + > header + width 100% + max-width 600px + margin 0 auto + color #555 + + > mk-search-posts + max-width 600px + margin 0 auto + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + +script. + @query = @opts.query + + @on \mount ~> + @refs.posts.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/desktop/tags/select-file-from-drive-window.tag b/src/web/app/desktop/tags/select-file-from-drive-window.tag new file mode 100644 index 0000000000..5042944837 --- /dev/null +++ b/src/web/app/desktop/tags/select-file-from-drive-window.tag @@ -0,0 +1,160 @@ +mk-select-file-from-drive-window + mk-window@window(is-modal={ true }, width={ '800px' }, height={ '500px' }) + <yield to="header"> + mk-raw(content={ parent.title }) + span.count(if={ parent.multiple && parent.file.length > 0 }) ({ parent.file.length }ファイル選択中) + </yield> + <yield to="content"> + mk-drive-browser@browser(multiple={ parent.multiple }) + div + button.upload(title='PCからドライブにファイルをアップロード', onclick={ parent.upload }): i.fa.fa-upload + button.cancel(onclick={ parent.close }) キャンセル + button.ok(disabled={ parent.multiple && parent.file.length == 0 }, onclick={ parent.ok }) 決定 + </yield> + +style. + > mk-window + [data-yield='header'] + > mk-raw + > i + margin-right 4px + + .count + margin-left 8px + opacity 0.7 + + [data-yield='content'] + > mk-drive-browser + height calc(100% - 72px) + + > div + height 72px + background lighten($theme-color, 95%) + + .upload + display inline-block + position absolute + top 8px + left 16px + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background transparent + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + .ok + .cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + .ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + +script. + @file = [] + + @multiple = if @opts.multiple? then @opts.multiple else false + @title = @opts.title || '<i class="fa fa-file-o"></i>ファイルを選択' + + @on \mount ~> + @refs.window.refs.browser.on \selected (file) ~> + @file = file + @ok! + + @refs.window.refs.browser.on \change-selection (files) ~> + @file = files + @update! + + @refs.window.on \closed ~> + @unmount! + + @close = ~> + @refs.window.close! + + @upload = ~> + @refs.window.refs.browser.select-local-file! + + @ok = ~> + @trigger \selected @file + @refs.window.close! diff --git a/src/web/app/desktop/tags/set-avatar-suggestion.tag b/src/web/app/desktop/tags/set-avatar-suggestion.tag new file mode 100644 index 0000000000..68c9c310df --- /dev/null +++ b/src/web/app/desktop/tags/set-avatar-suggestion.tag @@ -0,0 +1,44 @@ +mk-set-avatar-suggestion(onclick={ set }) + p + b アバターを設定 + | してみませんか? + button(onclick={ close }): i.fa.fa-times + +style. + display block + cursor pointer + color #fff + background #a8cad0 + + &:hover + background #70abb5 + + > p + display block + margin 0 auto + padding 8px + max-width 1024px + + > a + font-weight bold + color #fff + + > button + position absolute + top 0 + right 0 + padding 8px + color #fff + +script. + @mixin \i + @mixin \update-avatar + + @set = ~> + @update-avatar @I, (i) ~> + @update-i i + + @close = (e) ~> + e.prevent-default! + e.stop-propagation! + @unmount! diff --git a/src/web/app/desktop/tags/set-banner-suggestion.tag b/src/web/app/desktop/tags/set-banner-suggestion.tag new file mode 100644 index 0000000000..bff0385803 --- /dev/null +++ b/src/web/app/desktop/tags/set-banner-suggestion.tag @@ -0,0 +1,44 @@ +mk-set-banner-suggestion(onclick={ set }) + p + b バナーを設定 + | してみませんか? + button(onclick={ close }): i.fa.fa-times + +style. + display block + cursor pointer + color #fff + background #a8cad0 + + &:hover + background #70abb5 + + > p + display block + margin 0 auto + padding 8px + max-width 1024px + + > a + font-weight bold + color #fff + + > button + position absolute + top 0 + right 0 + padding 8px + color #fff + +script. + @mixin \i + @mixin \update-banner + + @set = ~> + @update-banner @I, (i) ~> + @update-i i + + @close = (e) ~> + e.prevent-default! + e.stop-propagation! + @unmount! diff --git a/src/web/app/desktop/tags/settings-window.tag b/src/web/app/desktop/tags/settings-window.tag new file mode 100644 index 0000000000..e259848718 --- /dev/null +++ b/src/web/app/desktop/tags/settings-window.tag @@ -0,0 +1,26 @@ +mk-settings-window + mk-window@window(is-modal={ true }, width={ '700px' }, height={ '550px' }) + <yield to="header"> + i.fa.fa-cog + | 設定 + </yield> + <yield to="content"> + mk-settings + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + overflow auto + +script. + @on \mount ~> + @refs.window.on \closed ~> + @unmount! + + @close = ~> + @refs.window.close! diff --git a/src/web/app/desktop/tags/settings.tag b/src/web/app/desktop/tags/settings.tag new file mode 100644 index 0000000000..c6c0340912 --- /dev/null +++ b/src/web/app/desktop/tags/settings.tag @@ -0,0 +1,255 @@ +mk-settings + div.nav + p(class={ active: page == 'account' }, onmousedown={ set-page.bind(null, 'account') }) + i.fa.fa-fw.fa-user + | アカウント + p(class={ active: page == 'web' }, onmousedown={ set-page.bind(null, 'web') }) + i.fa.fa-fw.fa-desktop + | Web + p(class={ active: page == 'notification' }, onmousedown={ set-page.bind(null, 'notification') }) + i.fa.fa-fw.fa-bell-o + | 通知 + p(class={ active: page == 'drive' }, onmousedown={ set-page.bind(null, 'drive') }) + i.fa.fa-fw.fa-cloud + | ドライブ + p(class={ active: page == 'apps' }, onmousedown={ set-page.bind(null, 'apps') }) + i.fa.fa-fw.fa-puzzle-piece + | アプリ + p(class={ active: page == 'signin' }, onmousedown={ set-page.bind(null, 'signin') }) + i.fa.fa-fw.fa-sign-in + | ログイン履歴 + p(class={ active: page == 'password' }, onmousedown={ set-page.bind(null, 'password') }) + i.fa.fa-fw.fa-unlock-alt + | パスワード + p(class={ active: page == 'api' }, onmousedown={ set-page.bind(null, 'api') }) + i.fa.fa-fw.fa-key + | API + + div.pages + section.account(show={ page == 'account' }) + h1 アカウント + label.avatar + p アバター + img.avatar(src={ I.avatar_url + '?thumbnail&size=64' }, alt='avatar') + button.style-normal(onclick={ avatar }) 画像を選択 + label + p 名前 + input@account-name(type='text', value={ I.name }) + label + p 場所 + input@account-location(type='text', value={ I.location }) + label + p 自己紹介 + textarea@account-bio { I.bio } + button.style-primary(onclick={ update-account }) 保存 + + section.web(show={ page == 'web' }) + h1 デザイン + label + p 壁紙 + button.style-normal(onclick={ wallpaper }) 画像を選択 + section.web(show={ page == 'web' }) + h1 その他 + label.checkbox + input(type='checkbox', checked={ I.data.cache }, onclick={ update-cache }) + p 読み込みを高速化する + p API通信時に新鮮なユーザー情報をキャッシュすることでフェッチのオーバーヘッドを無くします。(実験的) + label.checkbox + input(type='checkbox', checked={ I.data.debug }, onclick={ update-debug }) + p 開発者モード + p デバッグ等の開発者モードを有効にします。 + + section.signin(show={ page == 'signin' }) + h1 ログイン履歴 + mk-signin-history + + section.api(show={ page == 'api' }) + h1 API + p + | Token: + code { I.token } + p APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。 + p アカウントを乗っ取られてしまう可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。 + p + | 万が一このトークンが漏れたりその可能性がある場合は + button.regenerate(onclick={ regenerate-token }) トークンを再生成 + | できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します) + +style. + display block + + input:not([type]) + input[type='text'] + input[type='password'] + input[type='email'] + textarea + padding 8px + width 100% + font-size 16px + color #55595c + border solid 1px #dadada + border-radius 4px + + &:hover + border-color #aeaeae + + &:focus + border-color #aeaeae + + > .nav + position absolute + top 0 + left 0 + width 200px + height 100% + padding 16px 0 0 0 + border-right solid 1px #ddd + + > p + display block + padding 10px 16px + margin 0 + color #666 + cursor pointer + + -ms-user-select none + -moz-user-select none + -webkit-user-select none + user-select none + + transition margin-left 0.2s ease + + > i + margin-right 4px + + &:hover + color #555 + + &.active + margin-left 8px + color $theme-color !important + + > .pages + position absolute + top 0 + left 200px + width calc(100% - 200px) + + > section + padding 32px + + // & + section + // margin-top 16px + + h1 + display block + margin 0 + padding 0 0 8px 0 + font-size 1em + color #555 + border-bottom solid 1px #eee + + label + display block + margin 16px 0 + + &:after + content "" + display block + clear both + + > p + margin 0 0 8px 0 + font-weight bold + color #373a3c + + &.checkbox + > input + position absolute + top 0 + left 0 + + &:checked + p + color $theme-color + + > p + width calc(100% - 32px) + margin 0 0 0 32px + font-weight bold + + &:last-child + font-weight normal + color #999 + + &.account + > .general + > .avatar + > img + display block + float left + width 64px + height 64px + border-radius 4px + + > button + float left + margin-left 8px + + &.api + code + padding 4px + background #eee + + .regenerate + display inline + color $theme-color + + &:hover + text-decoration underline + +script. + @mixin \i + @mixin \api + @mixin \dialog + @mixin \update-avatar + @mixin \update-wallpaper + + @page = \account + + @set-page = (page) ~> + @page = page + + @avatar = ~> + @update-avatar @I, (i) ~> + @update-i i + + @wallpaper = ~> + @update-wallpaper @I, (i) ~> + @update-i i + + @update-account = ~> + @api \i/update do + name: @refs.account-name.value + location: @refs.account-location.value + bio: @refs.account-bio.value + .then (i) ~> + @update-i i + alert \ok + .catch (err) ~> + console.error err + + @update-cache = ~> + @I.data.cache = !@I.data.cache + @api \i/appdata/set do + data: JSON.stringify do + cache: @I.data.cache + .then ~> + @update-i! + + @update-debug = ~> + @I.data.debug = !@I.data.debug + @api \i/appdata/set do + data: JSON.stringify do + debug: @I.data.debug + .then ~> + @update-i! diff --git a/src/web/app/desktop/tags/signin-history.tag b/src/web/app/desktop/tags/signin-history.tag new file mode 100644 index 0000000000..311f8bfeda --- /dev/null +++ b/src/web/app/desktop/tags/signin-history.tag @@ -0,0 +1,73 @@ +mk-signin-history + div.records(if={ history.length != 0 }) + div(each={ history }) + mk-time(time={ created_at }) + header + i.fa.fa-check(if={ success }) + i.fa.fa-times(if={ !success }) + span.ip { ip } + pre: code { JSON.stringify(headers, null, ' ') } + +style. + display block + + > .records + > div + padding 16px 0 0 0 + border-bottom solid 1px #eee + + > header + + > i + margin-right 8px + + &.fa-check + color #0fda82 + + &.fa-times + color #ff3100 + + > .ip + display inline-block + color #444 + background #f8f8f8 + + > mk-time + position absolute + top 16px + right 0 + color #777 + + > pre + overflow auto + max-height 100px + + > code + white-space pre-wrap + word-break break-all + color #4a535a + +script. + @mixin \api + @mixin \stream + + @history = [] + @fetching = true + + @on \mount ~> + @api \i/signin_history + .then (history) ~> + @history = history + @fetching = false + @update! + .catch (err) ~> + console.error err + + @stream.on \signin @on-signin + + @on \unmount ~> + @stream.off \signin @on-signin + + @on-signin = (signin) ~> + @history.unshift signin + @update! diff --git a/src/web/app/desktop/tags/stream-indicator.tag b/src/web/app/desktop/tags/stream-indicator.tag new file mode 100644 index 0000000000..2eb5889ca6 --- /dev/null +++ b/src/web/app/desktop/tags/stream-indicator.tag @@ -0,0 +1,59 @@ +mk-stream-indicator + p(if={ state == 'initializing' }) + i.fa.fa-spinner.fa-spin + span + | 接続中 + mk-ellipsis + p(if={ state == 'reconnecting' }) + i.fa.fa-spinner.fa-spin + span + | 切断されました 接続中 + mk-ellipsis + p(if={ state == 'connected' }) + i.fa.fa-check + span 接続完了 + +style. + 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) + + > p + display block + margin 0 + + > i + margin-right 0.25em + +script. + @mixin \stream + + @on \before-mount ~> + @state = @get-stream-state! + + if @state == \connected + @root.style.opacity = 0 + + @stream-state-ev.on \connected ~> + @state = @get-stream-state! + @update! + set-timeout ~> + Velocity @root, { + opacity: 0 + } 200ms \linear + , 1000ms + + @stream-state-ev.on \closed ~> + @state = @get-stream-state! + @update! + Velocity @root, { + opacity: 1 + } 0ms diff --git a/src/web/app/desktop/tags/sub-post-content.tag b/src/web/app/desktop/tags/sub-post-content.tag new file mode 100644 index 0000000000..976a6f398f --- /dev/null +++ b/src/web/app/desktop/tags/sub-post-content.tag @@ -0,0 +1,37 @@ +mk-sub-post-content + div.body + a.reply(if={ post.reply_to_id }): i.fa.fa-reply + span@text + a.quote(if={ post.repost_id }, href={ '/post:' + post.repost_id }) RP: ... + details(if={ post.media }) + summary ({ post.media.length }枚の画像) + mk-images-viewer(images={ post.media }) + +style. + display block + word-wrap break-word + + > .body + > .reply + margin-right 6px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + +script. + @mixin \text + @mixin \user-preview + + @post = @opts.post + + @on \mount ~> + if @post.text? + tokens = @analyze @post.text + @refs.text.innerHTML = @compile tokens, false + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e diff --git a/src/web/app/desktop/tags/timeline-post-sub.tag b/src/web/app/desktop/tags/timeline-post-sub.tag new file mode 100644 index 0000000000..39b1ad7f71 --- /dev/null +++ b/src/web/app/desktop/tags/timeline-post-sub.tag @@ -0,0 +1,95 @@ +mk-timeline-post-sub(title={ title }) + article + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ post.user_id }) + div.main + header + a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }) + | { post.user.name } + span.username + | @{ post.user.username } + a.created-at(href={ CONFIG.url + '/' + post.user.username + '/' + post.id }) + mk-time(time={ post.created_at }) + div.body + mk-sub-post-content.text(post={ post }) + +script. + @mixin \date-stringify + @mixin \user-preview + + @post = @opts.post + + @title = @date-stringify @post.created_at + +style. + display block + margin 0 + padding 0 + font-size 0.9em + + > article + padding 16px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 14px 0 0 + + > .avatar + display block + width 52px + height 52px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 66px) + + > header + margin-bottom 4px + white-space nowrap + line-height 21px + + > .name + display inline + margin 0 + padding 0 + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #d1d8da + + > .created-at + position absolute + top 0 + right 0 + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 diff --git a/src/web/app/desktop/tags/timeline-post.tag b/src/web/app/desktop/tags/timeline-post.tag new file mode 100644 index 0000000000..e23cd6306e --- /dev/null +++ b/src/web/app/desktop/tags/timeline-post.tag @@ -0,0 +1,376 @@ +mk-timeline-post(tabindex='-1', title={ title }, onkeydown={ on-key-down }) + + div.reply-to(if={ p.reply_to }) + mk-timeline-post-sub(post={ p.reply_to }) + + div.repost(if={ is-repost }) + p + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=32' }, alt='avatar') + i.fa.fa-retweet + a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }) { post.user.name } + | がRepost + mk-time(time={ post.created_at }) + + article + a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username }) + img.avatar(src={ p.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ p.user.id }) + div.main + header + a.name(href={ CONFIG.url + '/' + p.user.username }, data-user-preview={ p.user.id }) + | { p.user.name } + span.username + | @{ p.user.username } + a.created-at(href={ url }) + mk-time(time={ p.created_at }) + div.body + div.text + a.reply(if={ p.reply_to }): i.fa.fa-reply + span@text + a.quote(if={ p.repost != null }) RP: + div.media(if={ p.media }) + mk-images-viewer(images={ p.media }) + div.repost(if={ p.repost }) + i.fa.fa-quote-right.fa-flip-horizontal + mk-post-preview.repost(post={ p.repost }) + footer + button(onclick={ reply }, title='返信') + i.fa.fa-reply + p.count(if={ p.replies_count > 0 }) { p.replies_count } + button(onclick={ repost }, title='Repost') + i.fa.fa-retweet + p.count(if={ p.repost_count > 0 }) { p.repost_count } + button(class={ liked: p.is_liked }, onclick={ like }, title='善哉') + i.fa.fa-thumbs-o-up + p.count(if={ p.likes_count > 0 }) { p.likes_count } + button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h + button(onclick={ toggle-detail }, title='詳細') + i.fa.fa-caret-down(if={ !is-detail-opened }) + i.fa.fa-caret-up(if={ is-detail-opened }) + div.detail(if={ is-detail-opened }) + mk-post-status-graph(width='462', height='130', post={ p }) + +style. + display block + margin 0 + padding 0 + background #fff + + &:focus + z-index 1 + + &:after + content "" + pointer-events none + position absolute + top 2px + right 2px + bottom 2px + left 2px + border 2px solid rgba($theme-color, 0.3) + border-radius 4px + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + line-height 28px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + > mk-time + position absolute + top 16px + right 32px + font-size 0.9em + line-height 28px + + & + article + padding-top 8px + + > .reply-to + padding 0 16px + background rgba(0, 0, 0, 0.0125) + + > mk-post-preview + background transparent + + > article + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 58px + height 58px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 74px) + + > header + margin-bottom 4px + white-space nowrap + line-height 24px + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .created-at + position absolute + top 0 + right 0 + font-size 0.9em + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.1em + color #717171 + + mk-url-preview + margin-top 8px + + > .reply + margin-right 8px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + + > .media + > img + display block + max-width 100% + + > .repost + margin 8px 0 + + > i:first-child + position absolute + top -8px + left -8px + z-index 1 + color #c0dac6 + font-size 28px + background #fff + + > mk-post-preview + padding 16px + border dashed 1px #c0dac6 + border-radius 8px + + > footer + > button + margin 0 28px 0 0 + padding 0 8px + line-height 32px + font-size 1em + color #ddd + background transparent + border none + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.liked + color $theme-color + + &:last-child + position absolute + right 0 + margin 0 + + > .detail + padding-top 4px + background rgba(0, 0, 0, 0.0125) + +style(theme='dark'). + background #0D0D0D + + > article + + &:hover + > .main > footer > button + color #eee + + > .main + > header + > .left + > .name + color #9e9c98 + + > .username + color #41403f + + > .right + > .time + color #4e4d4b + + > .body + > .text + color #9e9c98 + + > footer + > button + color #9e9c98 + + &:hover + color #fff + + > .count + color #eee + +script. + @mixin \api + @mixin \text + @mixin \date-stringify + @mixin \user-preview + @mixin \NotImplementedException + + @post = @opts.post + @is-repost = @post.repost? and !@post.text? + @p = if @is-repost then @post.repost else @post + + @title = @date-stringify @p.created_at + + @url = CONFIG.url + '/' + @p.user.username + '/' + @p.id + @is-detail-opened = false + + @on \mount ~> + if @p.text? + tokens = if @p._highlight? + then @analyze @p._highlight + else @analyze @p.text + + @refs.text.innerHTML = if @p._highlight? + then @compile tokens, true, false + else @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content + + @reply = ~> + form = document.body.append-child document.create-element \mk-post-form-window + riot.mount form, do + reply: @p + + @repost = ~> + form = document.body.append-child document.create-element \mk-repost-form-window + riot.mount form, do + post: @p + + @like = ~> + if @p.is_liked + @api \posts/likes/delete do + post_id: @p.id + .then ~> + @p.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @p.id + .then ~> + @p.is_liked = true + @update! + + @toggle-detail = ~> + @is-detail-opened = !@is-detail-opened + @update! + + @on-key-down = (e) ~> + should-be-cancel = true + switch + | e.which == 38 or e.which == 74 or (e.which == 9 and e.shift-key) => # ↑, j or Shift+Tab + focus @root, (e) -> e.previous-element-sibling + | e.which == 40 or e.which == 75 or e.which == 9 => # ↓, k or Tab + focus @root, (e) -> e.next-element-sibling + | e.which == 69 => # e + @repost! + | e.which == 70 or e.which == 76 => # f or l + @like! + | e.which == 82 => # r + @reply! + | _ => + should-be-cancel = false + + if should-be-cancel + e.prevent-default! + + function focus(el, fn) + target = fn el + if target? + if target.has-attribute \tabindex + target.focus! + else + focus target, fn diff --git a/src/web/app/desktop/tags/timeline.tag b/src/web/app/desktop/tags/timeline.tag new file mode 100644 index 0000000000..dfd1b7c14e --- /dev/null +++ b/src/web/app/desktop/tags/timeline.tag @@ -0,0 +1,86 @@ +mk-timeline + virtual(each={ post, i in posts }) + mk-timeline-post(post={ post }) + p.date(if={ i != posts.length - 1 && post._date != posts[i + 1]._date }) + span + i.fa.fa-angle-up + | { post._datetext } + span + i.fa.fa-angle-down + | { posts[i + 1]._datetext } + footer(data-yield='footer') + | <yield from="footer"/> + +style. + display block + + > mk-timeline-post + border-bottom solid 1px #eaeaea + + &:first-child + border-top-left-radius 4px + border-top-right-radius 4px + + &:last-of-type + border-bottom none + + > .date + display block + margin 0 + line-height 32px + font-size 14px + text-align center + color #aaa + background #fdfdfd + border-bottom solid 1px #eaeaea + + span + margin 0 16px + + i + margin-right 8px + + > footer + padding 16px + text-align center + color #ccc + border-top solid 1px #eaeaea + border-bottom-left-radius 4px + border-bottom-right-radius 4px + +style(theme='dark'). + > mk-timeline-post + border-bottom-color #222221 + +script. + @posts = [] + + @set-posts = (posts) ~> + @posts = posts + @update! + + @prepend-posts = (posts) ~> + posts.for-each (post) ~> + @posts.push post + @update! + + @add-post = (post) ~> + @posts.unshift post + @update! + + @clear = ~> + @posts = [] + @update! + + @focus = ~> + @root.children.0.focus! + + @on \update ~> + @posts.for-each (post) ~> + date = (new Date post.created_at).get-date! + month = (new Date post.created_at).get-month! + 1 + post._date = date + post._datetext = month + '月 ' + date + '日' + + @tail = ~> + @posts[@posts.length - 1] diff --git a/src/web/app/desktop/tags/ui-header-account.tag b/src/web/app/desktop/tags/ui-header-account.tag new file mode 100644 index 0000000000..ffb1eeec00 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-account.tag @@ -0,0 +1,219 @@ +mk-ui-header-account + button.header(data-active={ is-open.toString() }, onclick={ toggle }) + span.username + | { I.username } + i.fa.fa-angle-down(if={ !is-open }) + i.fa.fa-angle-up(if={ is-open }) + img.avatar(src={ I.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.menu(if={ is-open }) + ul + li: a(href={ '/' + I.username }) + i.fa.fa-user + | プロフィール + i.fa.fa-angle-right + li(onclick={ drive }): p + i.fa.fa-cloud + | ドライブ + i.fa.fa-angle-right + li: a(href='/i>mentions') + i.fa.fa-at + | あなた宛て + i.fa.fa-angle-right + ul + li(onclick={ settings }): p + i.fa.fa-cog + | 設定 + i.fa.fa-angle-right + ul + li(onclick={ signout }): p + i(class='fa fa-power-off') + | サインアウト + i.fa.fa-angle-right + +style. + display block + float left + + > .header + display block + margin 0 + padding 0 + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + color darken(#9eaba8, 20%) + + &:active + color darken(#9eaba8, 30%) + + &[data-active='true'] + color darken(#9eaba8, 20%) + + > .avatar + $saturate = 150% + filter saturate($saturate) + -webkit-filter saturate($saturate) + -moz-filter saturate($saturate) + -ms-filter saturate($saturate) + + > .username + display block + float left + margin 0 12px 0 16px + max-width 16em + line-height 48px + font-weight bold + font-family Meiryo, sans-serif + text-decoration none + + i + margin-left 8px + + > .avatar + display block + float left + min-width 32px + max-width 32px + min-height 32px + max-height 32px + margin 8px 8px 8px 0 + border-radius 4px + transition filter 100ms ease + + > .menu + display block + position absolute + top 56px + right -2px + width 230px + font-size 0.8em + background #fff + border-radius 4px + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + + &:before + content "" + pointer-events none + display block + position absolute + top -28px + right 12px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-left solid 14px transparent + + &:after + content "" + pointer-events none + display block + position absolute + top -27px + right 12px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px #fff + border-left solid 14px transparent + + ul + display block + margin 10px 0 + padding 0 + list-style none + + & + ul + padding-top 10px + border-top solid 1px #eee + + > li + display block + margin 0 + padding 0 + + > a + > p + display block + z-index 1 + padding 0 28px + margin 0 + line-height 40px + color #868C8C + cursor pointer + + * + pointer-events none + + > i:first-of-type + margin-right 6px + + > i:last-of-type + display block + position absolute + top 0 + right 8px + z-index 1 + padding 0 20px + font-size 1.2em + line-height 40px + + &:hover, &:active + text-decoration none + background $theme-color + color $theme-color-foreground + +script. + @mixin \i + @mixin \signout + + @is-open = false + + @on \before-unmount ~> + @close! + + @toggle = ~> + if @is-open + @close! + else + @open! + + @open = ~> + @is-open = true + @update! + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.add-event-listener \mousedown @mousedown + + @close = ~> + @is-open = false + @update! + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.remove-event-listener \mousedown @mousedown + + @mousedown = (e) ~> + e.prevent-default! + if (!contains @root, e.target) and (@root != e.target) + @close! + return false + + @drive = ~> + @close! + riot.mount document.body.append-child document.create-element \mk-drive-browser-window + + @settings = ~> + @close! + riot.mount document.body.append-child document.create-element \mk-settings-window + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/ui-header-clock.tag b/src/web/app/desktop/tags/ui-header-clock.tag new file mode 100644 index 0000000000..987907a684 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-clock.tag @@ -0,0 +1,82 @@ +mk-ui-header-clock + div.header + time@time + div.content + mk-analog-clock + +style. + display inline-block + overflow visible + + > .header + padding 0 12px + text-align center + font-size 0.5em + + &, * + cursor: default + + &:hover + background #899492 + + & + .content + visibility visible + + > time + color #fff !important + + * + color #fff !important + + &:after + content "" + display block + clear both + + > time + display table-cell + vertical-align middle + height 48px + color #9eaba8 + + > .yyyymmdd + opacity 0.7 + + > .content + visibility hidden + display block + position absolute + top auto + right 0 + z-index 3 + margin 0 + padding 0 + width 256px + background #899492 + +script. + @draw = ~> + now = new Date! + + yyyy = now.get-full-year! + mm = (\0 + (now.get-month! + 1)).slice -2 + dd = (\0 + now.get-date!).slice -2 + yyyymmdd = "<span class='yyyymmdd'>#yyyy/#mm/#dd</span>" + + hh = (\0 + now.get-hours!).slice -2 + mm = (\0 + now.get-minutes!).slice -2 + hhmm = "<span class='hhmm'>#hh:#mm</span>" + + if now.get-seconds! % 2 == 0 + hhmm .= replace \: '<span style=\'visibility:visible\'>:</span>' + else + hhmm .= replace \: '<span style=\'visibility:hidden\'>:</span>' + + @refs.time.innerHTML = "#yyyymmdd<br>#hhmm" + + @on \mount ~> + @draw! + @clock = set-interval @draw, 1000ms + + @on \unmount ~> + clear-interval @clock diff --git a/src/web/app/desktop/tags/ui-header-nav.tag b/src/web/app/desktop/tags/ui-header-nav.tag new file mode 100644 index 0000000000..153c3137b4 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-nav.tag @@ -0,0 +1,113 @@ +mk-ui-header-nav: ul(if={ SIGNIN }) + li.home(class={ active: page == 'home' }): a(href={ CONFIG.url }) + i.fa.fa-home + p ホーム + li.messaging: a(onclick={ messaging }) + i.fa.fa-comments + p メッセージ + i.fa.fa-circle(if={ has-unread-messaging-messages }) + li.info: a(href='https://twitter.com/misskey_xyz', target='_blank') + i.fa.fa-info + p お知らせ + li.tv: a(href='https://misskey.tk', target='_blank') + i.fa.fa-television + p MisskeyTV™ + +style. + display inline-block + margin 0 + padding 0 + line-height 3rem + vertical-align top + + > ul + display inline-block + margin 0 + padding 0 + vertical-align top + line-height 3rem + list-style none + + > li + display inline-block + vertical-align top + height 48px + line-height 48px + + &.active + > a + border-bottom solid 3px $theme-color + + > a + display inline-block + z-index 1 + height 100% + padding 0 24px + font-size 1em + font-variant small-caps + color #9eaba8 + text-decoration none + transition none + cursor pointer + + * + pointer-events none + + &:hover + color darken(#9eaba8, 20%) + text-decoration none + + > i:first-child + margin-right 8px + + > i:last-child + margin-left 5px + vertical-align super + font-size 10px + color $theme-color + + @media (max-width 1100px) + margin-left -5px + + > p + display inline + margin 0 + + @media (max-width 1100px) + display none + + @media (max-width 700px) + padding 0 12px + +script. + @mixin \i + @mixin \api + @mixin \stream + + @page = @opts.page + + @on \mount ~> + @stream.on \read_all_messaging_messages @on-read-all-messaging-messages + @stream.on \unread_messaging_message @on-unread-messaging-message + + # Fetch count of unread messaging messages + @api \messaging/unread + .then (count) ~> + if count.count > 0 + @has-unread-messaging-messages = true + @update! + + @on \unmount ~> + @stream.off \read_all_messaging_messages @on-read-all-messaging-messages + @stream.off \unread_messaging_message @on-unread-messaging-message + + @on-read-all-messaging-messages = ~> + @has-unread-messaging-messages = false + @update! + + @on-unread-messaging-message = ~> + @has-unread-messaging-messages = true + @update! + + @messaging = ~> + riot.mount document.body.append-child document.create-element \mk-messaging-window diff --git a/src/web/app/desktop/tags/ui-header-notifications.tag b/src/web/app/desktop/tags/ui-header-notifications.tag new file mode 100644 index 0000000000..495aad5004 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-notifications.tag @@ -0,0 +1,111 @@ +mk-ui-header-notifications + button.header(data-active={ is-open }, onclick={ toggle }) + i.fa.fa-bell-o + div.notifications(if={ is-open }) + mk-notifications + +style. + display block + float left + + > .header + display block + margin 0 + padding 0 + width 32px + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + color darken(#9eaba8, 20%) + + &:active + color darken(#9eaba8, 30%) + + &[data-active='true'] + color darken(#9eaba8, 20%) + + > i + font-size 1.2em + line-height 48px + + > .notifications + display block + position absolute + top 56px + right -72px + width 300px + background #fff + border-radius 4px + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + + &:before + content "" + pointer-events none + display block + position absolute + top -28px + right 74px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-left solid 14px transparent + + &:after + content "" + pointer-events none + display block + position absolute + top -27px + right 74px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px #fff + border-left solid 14px transparent + + > mk-notifications + max-height 350px + font-size 1rem + overflow auto + +script. + @is-open = false + + @toggle = ~> + if @is-open + @close! + else + @open! + + @open = ~> + @is-open = true + @update! + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.add-event-listener \mousedown @mousedown + + @close = ~> + @is-open = false + @update! + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.remove-event-listener \mousedown @mousedown + + @mousedown = (e) ~> + e.prevent-default! + if (!contains @root, e.target) and (@root != e.target) + @close! + return false + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/ui-header-post-button.tag b/src/web/app/desktop/tags/ui-header-post-button.tag new file mode 100644 index 0000000000..558c987619 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-post-button.tag @@ -0,0 +1,39 @@ +mk-ui-header-post-button + button(onclick={ post }, title='新規投稿') + i.fa.fa-pencil-square-o + +style. + display inline-block + padding 8px + height 100% + vertical-align top + + > button + display inline-block + margin 0 + padding 0 10px + height 100% + font-size 1.2em + font-weight normal + text-decoration none + color $theme-color-foreground + background $theme-color !important + outline none + border none + border-radius 2px + transition background 0.1s ease + cursor pointer + + * + pointer-events none + + &:hover + background lighten($theme-color, 10%) !important + + &:active + background darken($theme-color, 10%) !important + transition background 0s ease + +script. + @post = (e) ~> + @parent.parent.open-post-form! diff --git a/src/web/app/desktop/tags/ui-header-search.tag b/src/web/app/desktop/tags/ui-header-search.tag new file mode 100644 index 0000000000..24e4e44989 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-search.tag @@ -0,0 +1,37 @@ +mk-ui-header-search + form.search(onsubmit={ onsubmit }) + input@q(type='search', placeholder!=' 検索') + div.result + +style. + + > form + display block + float left + + > input + user-select text + cursor auto + margin 0 + padding 6px 18px + width 14em + height 48px + font-size 1em + line-height calc(48px - 12px) + background transparent + outline none + //border solid 1px #ddd + border none + border-radius 0 + transition color 0.5s ease, border 0.5s ease + font-family FontAwesome, sans-serif + + &::-webkit-input-placeholder + color #9eaba8 + +script. + @mixin \page + + @onsubmit = (e) ~> + e.prevent-default! + @page '/search:' + @refs.q.value diff --git a/src/web/app/desktop/tags/ui-header.tag b/src/web/app/desktop/tags/ui-header.tag new file mode 100644 index 0000000000..b02817cd84 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header.tag @@ -0,0 +1,85 @@ +mk-ui-header + mk-donation(if={ SIGNIN && !I.data.no_donation }) + mk-special-message + div.main + div.backdrop + div.main: div.container + div.left + mk-ui-header-nav(page={ opts.page }) + div.right + mk-ui-header-search + mk-ui-header-account(if={ SIGNIN }) + mk-ui-header-notifications(if={ SIGNIN }) + mk-ui-header-post-button(if={ SIGNIN }) + mk-ui-header-clock + +style. + display block + position fixed + top 0 + z-index 1024 + width 100% + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + > .main + + > .backdrop + position absolute + top 0 + z-index 1023 + width 100% + height 48px + backdrop-filter blur(12px) + //background-color rgba(255, 255, 255, 0.75) + background #fff + + &:after + content "" + display block + width 100% + height 48px + background-image url(/_/resources/desktop/header-logo.svg) + background-size 64px + background-position center + background-repeat no-repeat + + > .main + z-index 1024 + margin 0 + padding 0 + background-clip content-box + font-size 0.9rem + user-select none + + > .container + width 100% + max-width 1300px + margin 0 auto + + &:after + content "" + display block + clear both + + > .left + float left + height 3rem + + > .right + float right + height 48px + + @media (max-width 1100px) + > mk-ui-header-search + display none + +style(theme='dark'). + box-shadow 0 1px 0 #222221 + + > .main + + > .backdrop + background #0D0D0D + +script. + @mixin \i diff --git a/src/web/app/desktop/tags/ui-notification.tag b/src/web/app/desktop/tags/ui-notification.tag new file mode 100644 index 0000000000..6e5f948b88 --- /dev/null +++ b/src/web/app/desktop/tags/ui-notification.tag @@ -0,0 +1,41 @@ +mk-ui-notification + p { opts.message } + +style. + display block + position fixed + z-index 10000 + top -64px + left 0 + right 0 + margin 0 auto + width 500px + color rgba(#000, 0.6) + background rgba(#fff, 0.9) + border-radius 0 0 8px 8px + box-shadow 0 2px 4px rgba(#000, 0.2) + + > p + margin 0 + line-height 64px + text-align center + +script. + @on \mount ~> + Velocity @root, { + top: \0px + } { + duration: 500ms + easing: \ease-out + } + + set-timeout ~> + Velocity @root, { + top: \-64px + } { + duration: 500ms + easing: \ease-out + complete: ~> + @unmount! + } + , 6000ms diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag new file mode 100644 index 0000000000..6bced1f9e4 --- /dev/null +++ b/src/web/app/desktop/tags/ui.tag @@ -0,0 +1,37 @@ +mk-ui + div.global@global + mk-ui-header@header(page={ opts.page }) + + mk-set-avatar-suggestion(if={ SIGNIN && I.avatar_id == null }) + mk-set-banner-suggestion(if={ SIGNIN && I.banner_id == null }) + + div.content + <yield /> + + mk-stream-indicator + +style. + display block + +script. + @mixin \i + + @open-post-form = ~> + riot.mount document.body.append-child document.create-element \mk-post-form-window + + @set-root-layout = ~> + @root.style.padding-top = @refs.header.root.client-height + \px + + @on \mount ~> + @set-root-layout! + document.add-event-listener \keydown @onkeydown + + @on \unmount ~> + document.remove-event-listener \keydown @onkeydown + + @onkeydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 80 or e.which == 78 # p or n + e.prevent-default! + @open-post-form! diff --git a/src/web/app/desktop/tags/user-followers-window.tag b/src/web/app/desktop/tags/user-followers-window.tag new file mode 100644 index 0000000000..d18b04446c --- /dev/null +++ b/src/web/app/desktop/tags/user-followers-window.tag @@ -0,0 +1,22 @@ +mk-user-followers-window + mk-window(is-modal={ false }, width={ '400px' }, height={ '550px' }) + <yield to="header"> + img(src={ parent.user.avatar_url + '?thumbnail&size=64' }, alt='') + | { parent.user.name }のフォロワー + </yield> + <yield to="content"> + mk-user-followers(user={ parent.user }) + </yield> + +style. + > mk-window + [data-yield='header'] + > img + display inline-block + vertical-align bottom + height calc(100% - 10px) + margin 5px + border-radius 4px + +script. + @user = @opts.user diff --git a/src/web/app/desktop/tags/user-followers.tag b/src/web/app/desktop/tags/user-followers.tag new file mode 100644 index 0000000000..52f9f43836 --- /dev/null +++ b/src/web/app/desktop/tags/user-followers.tag @@ -0,0 +1,19 @@ +mk-user-followers + mk-users-list(fetch={ fetch }, count={ user.followers_count }, you-know-count={ user.followers_you_know_count }, no-users={ 'フォロワーはいないようです。' }) + +style. + display block + height 100% + +script. + @mixin \api + + @user = @opts.user + + @fetch = (iknow, limit, cursor, cb) ~> + @api \users/followers do + user_id: @user.id + iknow: iknow + limit: limit + cursor: if cursor? then cursor else undefined + .then cb diff --git a/src/web/app/desktop/tags/user-following-window.tag b/src/web/app/desktop/tags/user-following-window.tag new file mode 100644 index 0000000000..91f94f08d3 --- /dev/null +++ b/src/web/app/desktop/tags/user-following-window.tag @@ -0,0 +1,22 @@ +mk-user-following-window + mk-window(is-modal={ false }, width={ '400px' }, height={ '550px' }) + <yield to="header"> + img(src={ parent.user.avatar_url + '?thumbnail&size=64' }, alt='') + | { parent.user.name }のフォロー + </yield> + <yield to="content"> + mk-user-following(user={ parent.user }) + </yield> + +style. + > mk-window + [data-yield='header'] + > img + display inline-block + vertical-align bottom + height calc(100% - 10px) + margin 5px + border-radius 4px + +script. + @user = @opts.user diff --git a/src/web/app/desktop/tags/user-following.tag b/src/web/app/desktop/tags/user-following.tag new file mode 100644 index 0000000000..0a39f2e4b8 --- /dev/null +++ b/src/web/app/desktop/tags/user-following.tag @@ -0,0 +1,19 @@ +mk-user-following + mk-users-list(fetch={ fetch }, count={ user.following_count }, you-know-count={ user.following_you_know_count }, no-users={ 'フォロー中のユーザーはいないようです。' }) + +style. + display block + height 100% + +script. + @mixin \api + + @user = @opts.user + + @fetch = (iknow, limit, cursor, cb) ~> + @api \users/following do + user_id: @user.id + iknow: iknow + limit: limit + cursor: if cursor? then cursor else undefined + .then cb diff --git a/src/web/app/desktop/tags/user-friends-graph.tag b/src/web/app/desktop/tags/user-friends-graph.tag new file mode 100644 index 0000000000..47c3a15613 --- /dev/null +++ b/src/web/app/desktop/tags/user-friends-graph.tag @@ -0,0 +1,64 @@ +mk-user-friends-graph + canvas@canv(width='750', height='250') + +style. + display block + width 750px + height 250px + +script. + @mixin \api + @mixin \is-promise + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + + @on \mount ~> + user <~ @user-promise.then + @user = user + @update! + + @api \aggregation/users/followers do + user_id: @user.id + limit: 30days + .then (followers) ~> + followers = followers.reverse! + + @api \aggregation/users/following do + user_id: @user.id + limit: 30days + .then (following) ~> + following = following.reverse! + + new Chart @refs.canv, do + type: \line + data: + labels: following.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else '' + datasets: [ + { + label: \フォロー + data: following.map (x) ~> x.count + line-tension: 0 + border-width: 2 + fill: true + background-color: 'rgba(127, 221, 64, 0.2)' + point-background-color: \#fff + point-radius: 4 + point-border-width: 2 + border-color: \#7fdd40 + }, + { + label: \フォロワー + data: followers.map (x) ~> x.count + line-tension: 0 + border-width: 2 + fill: true + background-color: 'rgba(255, 99, 132, 0.2)' + point-background-color: \#fff + point-radius: 4 + point-border-width: 2 + border-color: \#FF6384 + } + ] + options: + responsive: false diff --git a/src/web/app/desktop/tags/user-graphs.tag b/src/web/app/desktop/tags/user-graphs.tag new file mode 100644 index 0000000000..f7f0fcd5e0 --- /dev/null +++ b/src/web/app/desktop/tags/user-graphs.tag @@ -0,0 +1,36 @@ +mk-user-graphs + section + h1 投稿 + mk-user-posts-graph(user={ opts.user }) + + section + h1 フォロー/フォロワー + mk-user-friends-graph(user={ opts.user }) + + section + h1 いいね + mk-user-likes-graph(user={ opts.user }) + +style. + display block + + > section + margin 16px 0 + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + + > h1 + margin 0 0 8px 0 + padding 0 16px + line-height 40px + font-size 1em + color #666 + border-bottom solid 1px #eee + + > *:not(h1) + margin 0 auto 16px auto + +script. + @on \mount ~> + @trigger \loaded diff --git a/src/web/app/desktop/tags/user-header.tag b/src/web/app/desktop/tags/user-header.tag new file mode 100644 index 0000000000..5abd79ff1c --- /dev/null +++ b/src/web/app/desktop/tags/user-header.tag @@ -0,0 +1,143 @@ +mk-user-header(data-is-dark-background={ user.banner_url != null }) + div.banner@banner(style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' }, onclick={ on-update-banner }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=150' }, alt='avatar') + div.title + p.name(href={ CONFIG.url + '/' + user.username }) { user.name } + p.username @{ user.username } + p.location(if={ user.location }) + i.fa.fa-map-marker + | { user.location } + footer + a(href={ '/' + user.username }) 投稿 + a(href={ '/' + user.username + '/media' }) メディア + a(href={ '/' + user.username + '/graphs' }) グラフ + button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h + +style. + $footer-height = 58px + + display block + background #fff + + &[data-is-dark-background] + > .banner + background-color #383838 + + > .title + color #fff + background linear-gradient(transparent, rgba(0, 0, 0, 0.7)) + + > .name + text-shadow 0 0 8px #000 + + > .banner + height 280px + background-color #f5f5f5 + background-size cover + background-position center + + > .avatar + display block + position absolute + bottom 16px + left 16px + z-index 2 + width 150px + height 150px + margin 0 + border solid 3px #fff + border-radius 8px + box-shadow 1px 1px 3px rgba(0, 0, 0, 0.2) + + > .title + position absolute + bottom $footer-height + left 0 + width 100% + padding 0 0 8px 195px + color #656565 + font-family '游ゴシック', 'YuGothic', 'ヒラギノ角ゴ ProN W3', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'メイリオ', sans-serif + + > .name + display block + margin 0 + line-height 40px + font-weight bold + font-size 2em + + > .username + > .location + display inline-block + margin 0 16px 0 0 + line-height 20px + opacity 0.8 + + > i + margin-right 4px + + > footer + z-index 1 + height $footer-height + padding-left 195px + background #fff + + > a + display inline-block + margin 0 + width 100px + line-height $footer-height + color #555 + + > button + display block + position absolute + top 0 + right 0 + margin 8px + padding 0 + width $footer-height - 16px + line-height $footer-height - 16px - 2px + font-size 1.2em + color #777 + border solid 1px #eee + border-radius 4px + + &:hover + color #555 + border solid 1px #ddd + +script. + @mixin \i + @mixin \update-banner + @mixin \NotImplementedException + + @user = @opts.user + + @on \mount ~> + window.add-event-listener \load @scroll + window.add-event-listener \scroll @scroll + window.add-event-listener \resize @scroll + + @on \unmount ~> + window.remove-event-listener \load @scroll + window.remove-event-listener \scroll @scroll + window.remove-event-listener \resize @scroll + + @scroll = ~> + top = window.scroll-y + height = 280px + + pos = 50 - ((top / height) * 50) + @refs.banner.style.background-position = 'center ' + pos + '%' + + blur = top / 32 + if blur <= 10 + @refs.banner.style.filter = 'blur(' + blur + 'px)' + + @on-update-banner = ~> + if not @SIGNIN or @I.id != @user.id + return + + @update-banner @I, (i) ~> + @user.banner_url = i.banner_url + @update! diff --git a/src/web/app/desktop/tags/user-home.tag b/src/web/app/desktop/tags/user-home.tag new file mode 100644 index 0000000000..4bf0260ff6 --- /dev/null +++ b/src/web/app/desktop/tags/user-home.tag @@ -0,0 +1,40 @@ +mk-user-home + div.side + mk-user-profile(user={ user }) + mk-user-photos(user={ user }) + main + mk-user-timeline@tl(user={ user }) + +style. + display flex + justify-content center + + > * + > * + display block + //border solid 1px #eaeaea + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + + &:not(:last-child) + margin-bottom 16px + + > main + flex 1 1 560px + max-width 560px + margin 0 + padding 16px 0 16px 16px + + > .side + flex 1 1 270px + max-width 270px + margin 0 + padding 16px 0 16px 0 + +script. + @user = @opts.user + + @on \mount ~> + @refs.tl.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/desktop/tags/user-likes-graph.tag b/src/web/app/desktop/tags/user-likes-graph.tag new file mode 100644 index 0000000000..e9d1428713 --- /dev/null +++ b/src/web/app/desktop/tags/user-likes-graph.tag @@ -0,0 +1,39 @@ +mk-user-likes-graph + canvas@canv(width='750', height='250') + +style. + display block + width 750px + height 250px + +script. + @mixin \api + @mixin \is-promise + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + + @on \mount ~> + user <~ @user-promise.then + @user = user + @update! + + @api \aggregation/users/like do + user_id: @user.id + limit: 30days + .then (likes) ~> + likes = likes.reverse! + + new Chart @refs.canv, do + type: \bar + data: + labels: likes.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else '' + datasets: [ + { + label: \いいねした数 + data: likes.map (x) ~> x.count + background-color: \#F7796C + } + ] + options: + responsive: false diff --git a/src/web/app/desktop/tags/user-photos.tag b/src/web/app/desktop/tags/user-photos.tag new file mode 100644 index 0000000000..61a840ee61 --- /dev/null +++ b/src/web/app/desktop/tags/user-photos.tag @@ -0,0 +1,85 @@ +mk-user-photos + p.title + i.fa.fa-camera + | フォト + p.initializing(if={ initializing }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + div.stream(if={ !initializing && images.length > 0 }) + virtual(each={ image in images }) + div.img(style={ 'background-image: url(' + image.url + '?thumbnail&size=256)' }) + p.empty(if={ !initializing && images.length == 0 }) + | 写真はありません + +style. + display block + background #fff + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > .stream + display -webkit-flex + display -moz-flex + display -ms-flex + display flex + justify-content center + flex-wrap wrap + padding 8px + + > .img + flex 1 1 33% + width 33% + height 80px + background-position center center + background-size cover + background-clip content-box + border solid 2px transparent + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \is-promise + + @images = [] + @initializing = true + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @update! + + @api \users/posts do + user_id: @user.id + with_media: true + limit: 9posts + .then (posts) ~> + @initializing = false + posts.for-each (post) ~> + post.media.for-each (image) ~> + if @images.length < 9 + @images.push image + @update! diff --git a/src/web/app/desktop/tags/user-posts-graph.tag b/src/web/app/desktop/tags/user-posts-graph.tag new file mode 100644 index 0000000000..75f4ac4a67 --- /dev/null +++ b/src/web/app/desktop/tags/user-posts-graph.tag @@ -0,0 +1,68 @@ +mk-user-posts-graph + canvas@canv(width='750', height='250') + +style. + display block + width 750px + height 250px + +script. + @mixin \api + @mixin \is-promise + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + + @on \mount ~> + user <~ @user-promise.then + @user = user + @update! + + @api \aggregation/users/post do + user_id: @user.id + limit: 30days + .then (data) ~> + data = data.reverse! + new Chart @refs.canv, do + type: \line + data: + labels: data.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else '' + datasets: [ + { + label: \投稿 + data: data.map (x) ~> x.posts + line-tension: 0 + point-radius: 0 + background-color: \#555 + border-color: \transparent + }, + { + label: \Repost + data: data.map (x) ~> x.reposts + line-tension: 0 + point-radius: 0 + background-color: \#a2d61e + border-color: \transparent + }, + { + label: \返信 + data: data.map (x) ~> x.replies + line-tension: 0 + point-radius: 0 + background-color: \#F7796C + border-color: \transparent + } + ] + options: + responsive: false + scales: + x-axes: [ + { + stacked: true + } + ] + y-axes: [ + { + stacked: true + } + ] diff --git a/src/web/app/desktop/tags/user-preview.tag b/src/web/app/desktop/tags/user-preview.tag new file mode 100644 index 0000000000..f299e6236e --- /dev/null +++ b/src/web/app/desktop/tags/user-preview.tag @@ -0,0 +1,143 @@ +mk-user-preview + virtual(if={ user != null }) + div.banner(style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=512)' : '' }) + a.avatar(href={ CONFIG.url + '/' + user.username }, target='_blank'): img(src={ user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.title + p.name { user.name } + p.username @{ user.username } + div.bio { user.bio } + div.status + div + p 投稿 + a { user.posts_count } + div + p フォロー + a { user.following_count } + div + p フォロワー + a { user.followers_count } + mk-follow-button(if={ SIGNIN && user.id != I.id }, user={ user-promise }) + +style. + display block + position absolute + z-index 2048 + width 250px + background #fff + background-clip content-box + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + overflow hidden + + // https://github.com/riot/riot/issues/2081 + > virtual + display block + position relative + + > .banner + height 84px + background-color #f5f5f5 + background-size cover + background-position center + + > .avatar + display block + position absolute + top 62px + left 13px + + > img + display block + width 58px + height 58px + margin 0 + border solid 3px #fff + border-radius 8px + + > .title + display block + padding 8px 0 8px 85px + + > .name + display block + margin 0 + font-weight bold + line-height 16px + color #656565 + + > .username + display block + margin 0 + line-height 16px + font-size 0.8em + color #999 + + > .bio + padding 0 16px + font-size 0.7em + color #555 + + > .status + padding 8px 16px + + > div + display inline-block + width 33% + + > p + margin 0 + font-size 0.7em + color #aaa + + > a + font-size 1em + color $theme-color + + > mk-follow-button + position absolute + top 92px + right 8px + +script. + @mixin \i + @mixin \api + + @u = @opts.user + @user = null + @user-promise = + if typeof @u == \string + new Promise (resolve, reject) ~> + @api \users/show do + user_id: if @u.0 == \@ then undefined else @u + username: if @u.0 == \@ then @u.substr 1 else undefined + .then (user) ~> + resolve user + else + Promise.resolve @u + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @update! + + Velocity @root, { + opacity: 0 + 'margin-top': \-8px + } 0ms + Velocity @root, { + opacity: 1 + 'margin-top': 0 + } { + duration: 200ms + easing: \ease-out + } + + @close = ~> + Velocity @root, { + opacity: 0 + 'margin-top': \-8px + } { + duration: 200ms + easing: \ease-out + complete: ~> @unmount! + } diff --git a/src/web/app/desktop/tags/user-profile.tag b/src/web/app/desktop/tags/user-profile.tag new file mode 100644 index 0000000000..195aefcdf7 --- /dev/null +++ b/src/web/app/desktop/tags/user-profile.tag @@ -0,0 +1,72 @@ +mk-user-profile + div.friend-form(if={ SIGNIN && I.id != user.id }) + mk-big-follow-button(user={ user }) + p.followed(if={ user.is_followed }) フォローされています + div.bio(if={ user.bio != '' }) { user.bio } + div.friends + p.following + i.fa.fa-angle-right + a(onclick={ show-following }) { user.following_count } + | 人を + b フォロー + p.followers + i.fa.fa-angle-right + a(onclick={ show-followers }) { user.followers_count } + | 人の + b フォロワー + +style. + display block + background #fff + + > *:first-child + border-top none !important + + > .friend-form + padding 16px + border-top solid 1px #eee + + > mk-big-follow-button + width 100% + + > .followed + margin 12px 0 0 0 + padding 0 + text-align center + line-height 24px + font-size 0.8em + color #71afc7 + background #eefaff + border-radius 4px + + > .bio + padding 16px + color #555 + border-top solid 1px #eee + + > .friends + padding 16px + color #555 + border-top solid 1px #eee + + > p + margin 8px 0 + + > i + margin-left 8px + margin-right 8px + +script. + @mixin \i + + @user = @opts.user + + @show-following = ~> + window = document.body.append-child document.create-element \mk-user-following-window + riot.mount window, do + user: @user + + @show-followers = ~> + window = document.body.append-child document.create-element \mk-user-followers-window + riot.mount window, do + user: @user diff --git a/src/web/app/desktop/tags/user-timeline.tag b/src/web/app/desktop/tags/user-timeline.tag new file mode 100644 index 0000000000..ced90e2e84 --- /dev/null +++ b/src/web/app/desktop/tags/user-timeline.tag @@ -0,0 +1,142 @@ +mk-user-timeline + header + span(data-is-active={ mode == 'default' }, onclick={ set-mode.bind(this, 'default') }) 投稿 + span(data-is-active={ mode == 'with-replies' }, onclick={ set-mode.bind(this, 'with-replies') }) 投稿と返信 + div.loading(if={ is-loading }) + mk-ellipsis-icon + p.empty(if={ is-empty }) + i.fa.fa-comments-o + | このユーザーはまだ何も投稿していないようです。 + mk-timeline@timeline + <yield to="footer"> + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + </yield> + +style. + display block + background #fff + + > header + padding 8px 16px + border-bottom solid 1px #eee + + > span + margin-right 16px + line-height 27px + font-size 18px + color #555 + + &:not([data-is-active]) + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + > .loading + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + +script. + @mixin \api + @mixin \is-promise + @mixin \get-post-summary + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + @is-loading = true + @is-empty = false + @more-loading = false + @unread-count = 0 + @mode = \default + + @on \mount ~> + document.add-event-listener \visibilitychange @window-on-visibilitychange, false + document.add-event-listener \keydown @on-document-keydown + window.add-event-listener \scroll @on-scroll + + @user-promise.then (user) ~> + @user = user + @update! + + @fetch ~> + @trigger \loaded + + @on \unmount ~> + document.remove-event-listener \visibilitychange @window-on-visibilitychange + document.remove-event-listener \keydown @on-document-keydown + window.remove-event-listener \scroll @on-scroll + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 84 # t + @refs.timeline.focus! + + @fetch = (cb) ~> + @api \users/posts do + user_id: @user.id + with_replies: @mode == \with-replies + .then (posts) ~> + @is-loading = false + @is-empty = posts.length == 0 + @update! + @refs.timeline.set-posts posts + if cb? then cb! + .catch (err) ~> + console.error err + if cb? then cb! + + @more = ~> + if @more-loading or @is-loading or @refs.timeline.posts.length == 0 + return + @more-loading = true + @update! + @api \users/posts do + user_id: @user.id + with_replies: @mode == \with-replies + max_id: @refs.timeline.tail!.id + .then (posts) ~> + @more-loading = false + @update! + @refs.timeline.prepend-posts posts + .catch (err) ~> + console.error err + + @on-stream-post = (post) ~> + @is-empty = false + @update! + @refs.timeline.add-post post + + if document.hidden + @unread-count++ + document.title = '(' + @unread-count + ') ' + @get-post-summary post + + @window-on-visibilitychange = ~> + if !document.hidden + @unread-count = 0 + document.title = 'Misskey' + + @on-scroll = ~> + current = window.scroll-y + window.inner-height + if current > document.body.offset-height - 16 # 遊び + @more! + + @set-mode = (mode) ~> + @update do + mode: mode + @fetch! diff --git a/src/web/app/desktop/tags/user.tag b/src/web/app/desktop/tags/user.tag new file mode 100644 index 0000000000..4d022e68c4 --- /dev/null +++ b/src/web/app/desktop/tags/user.tag @@ -0,0 +1,45 @@ +mk-user + div.user(if={ !fetching }) + header + mk-user-header(user={ user }) + div.body + mk-user-home(if={ page == 'home' }, user={ user }) + mk-user-graphs(if={ page == 'graphs' }, user={ user }) + +style. + display block + background #fff + + > .user + > header + max-width 560px + 270px + margin 0 auto + padding 0 16px + + > mk-user-header + border solid 1px rgba(0, 0, 0, 0.075) + border-top none + border-radius 0 0 6px 6px + overflow hidden + + > .body + max-width 560px + 270px + margin 0 auto + padding 0 16px + +script. + @mixin \api + + @username = @opts.user + @page = if @opts.page? then @opts.page else \home + @fetching = true + @user = null + + @on \mount ~> + @api \users/show do + username: @username + .then (user) ~> + @fetching = false + @user = user + @update! + @trigger \loaded diff --git a/src/web/app/desktop/tags/users-list.tag b/src/web/app/desktop/tags/users-list.tag new file mode 100644 index 0000000000..9ae96eed9f --- /dev/null +++ b/src/web/app/desktop/tags/users-list.tag @@ -0,0 +1,139 @@ +mk-users-list + nav: div + span(data-is-active={ mode == 'all' }, onclick={ set-mode.bind(this, 'all') }) + | すべて + span { opts.count } + // ↓ https://github.com/riot/riot/issues/2080 + span(if={ SIGNIN && opts.you-know-count != '' }, data-is-active={ mode == 'iknow' }, onclick={ set-mode.bind(this, 'iknow') }) + | 知り合い + span { opts.you-know-count } + + div.users(if={ !fetching && users.length != 0 }) + div(each={ users }): mk-list-user(user={ this }) + + button.more(if={ !fetching && next != null }, onclick={ more }, disabled={ more-fetching }) + span(if={ !more-fetching }) もっと + span(if={ more-fetching }) + | 読み込み中 + mk-ellipsis + + p.no(if={ !fetching && users.length == 0 }) + | { opts.no-users } + p.fetching(if={ fetching }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + height 100% + background #fff + + > nav + z-index 1 + box-shadow 0 1px 0 rgba(#000, 0.1) + + > div + display flex + justify-content center + margin 0 auto + max-width 600px + + > span + display block + flex 1 1 + text-align center + line-height 52px + font-size 14px + color #657786 + border-bottom solid 2px transparent + cursor pointer + + * + pointer-events none + + &[data-is-active] + font-weight bold + color $theme-color + border-color $theme-color + cursor default + + > span + display inline-block + margin-left 4px + padding 2px 5px + font-size 12px + line-height 1 + color #888 + background #eee + border-radius 20px + + > .users + height calc(100% - 54px) + overflow auto + + > * + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + > * + max-width 600px + margin 0 auto + + > .no + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \i + + @limit = 30users + @mode = \all + + @fetching = true + @more-fetching = false + + @on \mount ~> + @fetch ~> + @trigger \loaded + + @fetch = (cb) ~> + @fetching = true + @update! + obj <~ @opts.fetch do + @mode == \iknow + @limit + null + @users = obj.users + @next = obj.next + @fetching = false + @update! + if cb? then cb! + + @more = ~> + @more-fetching = true + @update! + obj <~ @opts.fetch do + @mode == \iknow + @limit + @cursor + @users = @users.concat obj.users + @next = obj.next + @more-fetching = false + @update! + + @set-mode = (mode) ~> + @update do + mode: mode + + @fetch! diff --git a/src/web/app/desktop/tags/window.tag b/src/web/app/desktop/tags/window.tag new file mode 100644 index 0000000000..9732a6c552 --- /dev/null +++ b/src/web/app/desktop/tags/window.tag @@ -0,0 +1,515 @@ +mk-window(data-flexible={ is-flexible }, data-colored={ opts.colored }, ondragover={ ondragover }) + div.bg@bg(show={ is-modal }, onclick={ bg-click }) + div.main@main(tabindex='-1', data-is-modal={ is-modal }, onmousedown={ on-body-mousedown }, onkeydown={ on-keydown }) + div.body + header@header(onmousedown={ on-header-mousedown }) + h1(data-yield='header') + | <yield from="header"/> + button.close(if={ can-close }, onmousedown={ repel-move }, onclick={ close }, title='閉じる'): i.fa.fa-times + div.content(data-yield='content') + | <yield from="content"/> + div.handle.top(if={ can-resize }, onmousedown={ on-top-handle-mousedown }) + div.handle.right(if={ can-resize }, onmousedown={ on-right-handle-mousedown }) + div.handle.bottom(if={ can-resize }, onmousedown={ on-bottom-handle-mousedown }) + div.handle.left(if={ can-resize }, onmousedown={ on-left-handle-mousedown }) + div.handle.top-left(if={ can-resize }, onmousedown={ on-top-left-handle-mousedown }) + div.handle.top-right(if={ can-resize }, onmousedown={ on-top-right-handle-mousedown }) + div.handle.bottom-right(if={ can-resize }, onmousedown={ on-bottom-right-handle-mousedown }) + div.handle.bottom-left(if={ can-resize }, onmousedown={ on-bottom-left-handle-mousedown }) + +style. + display block + + > .bg + display block + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + opacity 0 + pointer-events none + + > .main + display block + position fixed + z-index 2048 + top 15% + left 0 + margin 0 + opacity 0 + pointer-events none + + &:focus + &:not([data-is-modal]) + > .body + box-shadow 0 0 0px 1px rgba($theme-color, 0.5), 0 2px 6px 0 rgba(0, 0, 0, 0.2) + + > .handle + $size = 8px + + position absolute + + &.top + top -($size) + left 0 + width 100% + height $size + cursor ns-resize + + &.right + top 0 + right -($size) + width $size + height 100% + cursor ew-resize + + &.bottom + bottom -($size) + left 0 + width 100% + height $size + cursor ns-resize + + &.left + top 0 + left -($size) + width $size + height 100% + cursor ew-resize + + &.top-left + top -($size) + left -($size) + width $size * 2 + height $size * 2 + cursor nwse-resize + + &.top-right + top -($size) + right -($size) + width $size * 2 + height $size * 2 + cursor nesw-resize + + &.bottom-right + bottom -($size) + right -($size) + width $size * 2 + height $size * 2 + cursor nwse-resize + + &.bottom-left + bottom -($size) + left -($size) + width $size * 2 + height $size * 2 + cursor nesw-resize + + > .body + height 100% + overflow hidden + background #fff + border-radius 6px + box-shadow 0 2px 6px 0 rgba(0, 0, 0, 0.2) + + > header + z-index 128 + overflow hidden + cursor move + background #fff + border-radius 6px 6px 0 0 + box-shadow 0 1px 0 rgba(#000, 0.1) + + &, * + user-select none + + > h1 + pointer-events none + display block + margin 0 + height 40px + text-align center + font-size 1em + line-height 40px + font-weight normal + color #666 + + > .close + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color rgba(#000, 0.4) + border none + outline none + background transparent + + &:hover + color rgba(#000, 0.6) + + &:active + color darken(#000, 30%) + + > i + padding 0 + width 40px + line-height 40px + + > .content + height 100% + + &:not([flexible]) + > .main > .body > .content + height calc(100% - 40px) + + &[data-colored] + + > .main > .body + + > header + box-shadow 0 1px 0 rgba($theme-color, 0.1) + + > h1 + color #d0b4ac + + > .close + color rgba($theme-color, 0.4) + + &:hover + color rgba($theme-color, 0.6) + + &:active + color darken($theme-color, 30%) + +script. + @min-height = 40px + @min-width = 200px + + @is-modal = if @opts.is-modal? then @opts.is-modal else false + @can-close = if @opts.can-close? then @opts.can-close else true + @is-flexible = !@opts.height? + @can-resize = not @is-flexible + + @on \mount ~> + @refs.main.style.width = @opts.width || \530px + @refs.main.style.height = @opts.height || \auto + + @refs.main.style.top = \15% + @refs.main.style.left = (window.inner-width / 2) - (@refs.main.offset-width / 2) + \px + + @refs.header.add-event-listener \contextmenu (e) ~> + e.prevent-default! + + window.add-event-listener \resize @on-browser-resize + + @open! + + @on \unmount ~> + window.remove-event-listener \resize @on-browser-resize + + @on-browser-resize = ~> + position = @refs.main.get-bounding-client-rect! + browser-width = window.inner-width + browser-height = window.inner-height + window-width = @refs.main.offset-width + window-height = @refs.main.offset-height + + if position.left < 0 + @refs.main.style.left = 0 + + if position.top < 0 + @refs.main.style.top = 0 + + if position.left + window-width > browser-width + @refs.main.style.left = browser-width - window-width + \px + + if position.top + window-height > browser-height + @refs.main.style.top = browser-height - window-height + \px + + @open = ~> + @trigger \opening + + @top! + + if @is-modal + @refs.bg.style.pointer-events = \auto + Velocity @refs.bg, \finish true + Velocity @refs.bg, { + opacity: 1 + } { + queue: false + duration: 100ms + easing: \linear + } + + @refs.main.style.pointer-events = \auto + Velocity @refs.main, \finish true + Velocity @refs.main, {scale: 1.1} 0ms + Velocity @refs.main, { + opacity: 1 + scale: 1 + } { + queue: false + duration: 200ms + easing: \ease-out + } + + #@refs.main.focus! + + set-timeout ~> + @trigger \opened + , 300ms + + @close = ~> + @trigger \closing + + if @is-modal + @refs.bg.style.pointer-events = \none + Velocity @refs.bg, \finish true + Velocity @refs.bg, { + opacity: 0 + } { + queue: false + duration: 300ms + easing: \linear + } + + @refs.main.style.pointer-events = \none + Velocity @refs.main, \finish true + Velocity @refs.main, { + opacity: 0 + scale: 0.8 + } { + queue: false + duration: 300ms + easing: [ 0.5, -0.5, 1, 0.5 ] + } + + set-timeout ~> + @trigger \closed + , 300ms + + # 最前面へ移動します + @top = ~> + z = 0 + + ws = document.query-selector-all \mk-window + ws.for-each (w) !~> + if w == @root then return + m = w.query-selector ':scope > .main' + mz = Number(document.default-view.get-computed-style m, null .z-index) + if mz > z then z := mz + + if z > 0 + @refs.main.style.z-index = z + 1 + if @is-modal then @refs.bg.style.z-index = z + 1 + + @repel-move = (e) ~> + e.stop-propagation! + return true + + @bg-click = ~> + if @can-close + @close! + + @on-body-mousedown = (e) ~> + @top! + true + + # ヘッダー掴み時 + @on-header-mousedown = (e) ~> + e.prevent-default! + + if not contains @refs.main, document.active-element + @refs.main.focus! + + position = @refs.main.get-bounding-client-rect! + + click-x = e.client-x + click-y = e.client-y + move-base-x = click-x - position.left + move-base-y = click-y - position.top + browser-width = window.inner-width + browser-height = window.inner-height + window-width = @refs.main.offset-width + window-height = @refs.main.offset-height + + # 動かした時 + drag-listen (me) ~> + move-left = me.client-x - move-base-x + move-top = me.client-y - move-base-y + + # 上はみ出し + if move-top < 0 + move-top = 0 + + # 左はみ出し + if move-left < 0 + move-left = 0 + + # 下はみ出し + if move-top + window-height > browser-height + move-top = browser-height - window-height + + # 右はみ出し + if move-left + window-width > browser-width + move-left = browser-width - window-width + + @refs.main.style.left = move-left + \px + @refs.main.style.top = move-top + \px + + # 上ハンドル掴み時 + @on-top-handle-mousedown = (e) ~> + e.prevent-default! + + base = e.client-y + height = parse-int((get-computed-style @refs.main, '').height, 10) + top = parse-int((get-computed-style @refs.main, '').top, 10) + + # 動かした時 + drag-listen (me) ~> + move = me.client-y - base + if top + move > 0 + if height + -move > @min-height + @apply-transform-height height + -move + @apply-transform-top top + move + else # 最小の高さより小さくなろうとした時 + @apply-transform-height @min-height + @apply-transform-top top + (height - @min-height) + else # 上のはみ出し時 + @apply-transform-height top + height + @apply-transform-top 0 + + # 右ハンドル掴み時 + @on-right-handle-mousedown = (e) ~> + e.prevent-default! + + base = e.client-x + width = parse-int((get-computed-style @refs.main, '').width, 10) + left = parse-int((get-computed-style @refs.main, '').left, 10) + browser-width = window.inner-width + + # 動かした時 + drag-listen (me) ~> + move = me.client-x - base + if left + width + move < browser-width + if width + move > @min-width + @apply-transform-width width + move + else # 最小の幅より小さくなろうとした時 + @apply-transform-width @min-width + else # 右のはみ出し時 + @apply-transform-width browser-width - left + + # 下ハンドル掴み時 + @on-bottom-handle-mousedown = (e) ~> + e.prevent-default! + + base = e.client-y + height = parse-int((get-computed-style @refs.main, '').height, 10) + top = parse-int((get-computed-style @refs.main, '').top, 10) + browser-height = window.inner-height + + # 動かした時 + drag-listen (me) ~> + move = me.client-y - base + if top + height + move < browser-height + if height + move > @min-height + @apply-transform-height height + move + else # 最小の高さより小さくなろうとした時 + @apply-transform-height @min-height + else # 下のはみ出し時 + @apply-transform-height browser-height - top + + # 左ハンドル掴み時 + @on-left-handle-mousedown = (e) ~> + e.prevent-default! + + base = e.client-x + width = parse-int((get-computed-style @refs.main, '').width, 10) + left = parse-int((get-computed-style @refs.main, '').left, 10) + + # 動かした時 + drag-listen (me) ~> + move = me.client-x - base + if left + move > 0 + if width + -move > @min-width + @apply-transform-width width + -move + @apply-transform-left left + move + else # 最小の幅より小さくなろうとした時 + @apply-transform-width @min-width + @apply-transform-left left + (width - @min-width) + else # 左のはみ出し時 + @apply-transform-width left + width + @apply-transform-left 0 + + # 左上ハンドル掴み時 + @on-top-left-handle-mousedown = (e) ~> + @on-top-handle-mousedown e + @on-left-handle-mousedown e + + # 右上ハンドル掴み時 + @on-top-right-handle-mousedown = (e) ~> + @on-top-handle-mousedown e + @on-right-handle-mousedown e + + # 右下ハンドル掴み時 + @on-bottom-right-handle-mousedown = (e) ~> + @on-bottom-handle-mousedown e + @on-right-handle-mousedown e + + # 左下ハンドル掴み時 + @on-bottom-left-handle-mousedown = (e) ~> + @on-bottom-handle-mousedown e + @on-left-handle-mousedown e + + # 高さを適用 + @apply-transform-height = (height) ~> + @refs.main.style.height = height + \px + + # 幅を適用 + @apply-transform-width = (width) ~> + @refs.main.style.width = width + \px + + # Y座標を適用 + @apply-transform-top = (top) ~> + @refs.main.style.top = top + \px + + # X座標を適用 + @apply-transform-left = (left) ~> + @refs.main.style.left = left + \px + + function drag-listen fn + window.add-event-listener \mousemove fn + window.add-event-listener \mouseleave drag-clear.bind null fn + window.add-event-listener \mouseup drag-clear.bind null fn + + function drag-clear fn + window.remove-event-listener \mousemove fn + window.remove-event-listener \mouseleave drag-clear + window.remove-event-listener \mouseup drag-clear + + @ondragover = (e) ~> + e.data-transfer.drop-effect = \none + + @on-keydown = (e) ~> + if e.which == 27 # Esc + if @can-close + e.prevent-default! + e.stop-propagation! + @close! + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/dev/router.ls b/src/web/app/dev/router.ls new file mode 100644 index 0000000000..ac408b36ed --- /dev/null +++ b/src/web/app/dev/router.ls @@ -0,0 +1,51 @@ +# Router +#================================ + +route = require \page +page = null + +module.exports = (me) ~> + + # Routing + #-------------------------------- + + route \/ index + route \/apps apps + route \/app/new new-app + route \/app/:app app + route \* not-found + + # Handlers + #-------------------------------- + + function index + mount document.create-element \mk-index + + function apps + mount document.create-element \mk-apps-page + + function new-app + mount document.create-element \mk-new-app-page + + function app ctx + document.create-element \mk-app-page + ..set-attribute \app ctx.params.app + .. |> mount + + function not-found + mount document.create-element \mk-not-found + + # Exec + #-------------------------------- + + route! + +# Mount +#================================ + +riot = require \riot + +function mount content + if page? then page.unmount! + body = document.get-element-by-id \app + page := riot.mount body.append-child content .0 diff --git a/src/web/app/dev/script.js b/src/web/app/dev/script.js new file mode 100644 index 0000000000..407f4e84c0 --- /dev/null +++ b/src/web/app/dev/script.js @@ -0,0 +1,15 @@ +/** + * Developer Center + */ + +require('./tags.ls'); +const boot = require('../boot.ls'); +const route = require('./router.ls'); + +/** + * Boot + */ +boot(me => { + // Start routing + route(me); +}); diff --git a/src/web/app/dev/style.styl b/src/web/app/dev/style.styl new file mode 100644 index 0000000000..a7e51b8943 --- /dev/null +++ b/src/web/app/dev/style.styl @@ -0,0 +1,10 @@ +@import "../base" + +html + background-color #fff + +#init + background #100f0f + + > p + color $theme-color diff --git a/src/web/app/dev/tags.ls b/src/web/app/dev/tags.ls new file mode 100644 index 0000000000..7783402634 --- /dev/null +++ b/src/web/app/dev/tags.ls @@ -0,0 +1,5 @@ +require './tags/pages/index.tag' +require './tags/pages/apps.tag' +require './tags/pages/app.tag' +require './tags/pages/new-app.tag' +require './tags/new-app-form.tag' diff --git a/src/web/app/dev/tags/new-app-form.tag b/src/web/app/dev/tags/new-app-form.tag new file mode 100644 index 0000000000..443bf2bfff --- /dev/null +++ b/src/web/app/dev/tags/new-app-form.tag @@ -0,0 +1,260 @@ +mk-new-app-form + form(onsubmit={ onsubmit }, autocomplete='off') + section.name: label + p.caption + | アプリケーション名 + input@name( + type='text' + placeholder='ex) Misskey for iOS' + autocomplete='off' + required) + + section.nid: label + p.caption + | Named ID + input@nid( + type='text' + pattern='^[a-zA-Z0-9\-]{3,30}$' + placeholder='ex) misskey-for-ios' + autocomplete='off' + required + onkeyup={ on-change-nid }) + + p.info(if={ nid-state == 'wait' }, style='color:#999') + i.fa.fa-fw.fa-spinner.fa-pulse + | 確認しています... + p.info(if={ nid-state == 'ok' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | 利用できます + p.info(if={ nid-state == 'unavailable' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 既に利用されています + p.info(if={ nid-state == 'error' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 通信エラー + p.info(if={ nid-state == 'invalid-format' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | a~z、A~Z、0~9、-(ハイフン)が使えます + p.info(if={ nid-state == 'min-range' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 3文字以上でお願いします! + p.info(if={ nid-state == 'max-range' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 30文字以内でお願いします + + section.description: label + p.caption + | アプリの概要 + textarea@description( + placeholder='ex) Misskey iOSクライアント。' + autocomplete='off' + required) + + section.callback: label + p.caption + | コールバックURL (オプション) + input@cb( + type='url' + placeholder='ex) https://your.app.example.com/callback.php' + autocomplete='off') + + section.permission + p.caption + | 権限 + div@permission + label + input(type='checkbox', value='account-read') + p アカウントの情報を見る。 + label + input(type='checkbox', value='account-write') + p アカウントの情報を操作する。 + label + input(type='checkbox', value='post-write') + p 投稿する。 + label + input(type='checkbox', value='like-write') + p いいねしたりいいね解除する。 + label + input(type='checkbox', value='following-write') + p フォローしたりフォロー解除する。 + label + input(type='checkbox', value='drive-read') + p ドライブを見る。 + label + input(type='checkbox', value='drive-write') + p ドライブを操作する。 + label + input(type='checkbox', value='notification-read') + p 通知を見る。 + label + input(type='checkbox', value='notification-write') + p 通知を操作する。 + p + i.fa.fa-exclamation-triangle + | アプリ作成後も変更できますが、新たな権限を付与する場合、その時点で関連付けられているユーザーキーはすべて無効になります。 + + button(onclick={ onsubmit }) + | アプリ作成 + +style. + display block + overflow hidden + + > form + + section + display block + margin 16px 0 + + .caption + margin 0 0 4px 0 + color #616161 + font-size 0.95em + + > i + margin-right 0.25em + color #96adac + + .info + display block + margin 4px 0 + font-size 0.8em + + > i + margin-right 0.3em + + section.permission + div + padding 8px 0 + max-height 160px + overflow auto + background #fff + border solid 1px #cecece + border-radius 4px + + label + display block + padding 0 12px + line-height 32px + cursor pointer + + &:hover + > p + color #999 + + [type='checkbox']:checked + p + color #000 + + [type='checkbox'] + margin-right 4px + + [type='checkbox']:checked + p + color #111 + + > p + display inline + color #aaa + user-select none + + > p:last-child + margin 6px + font-size 0.8em + color #999 + + > i + margin-right 4px + + [type=text] + [type=url] + textarea + user-select text + display inline-block + cursor auto + padding 8px 12px + margin 0 + width 100% + font-size 1em + color #333 + background #fff + outline none + border solid 1px #cecece + border-radius 4px + + &:hover + border-color #bbb + + &:focus + border-color $theme-color + + &:disabled + opacity 0.5 + + > button + margin 20px 0 32px 0 + width 100% + font-size 1em + color #111 + border-radius 3px + +script. + @mixin \api + + @nid-state = null + + @on-change-nid = ~> + nid = @refs.nid.value + + if nid == '' + @nid-state = null + @update! + return + + err = switch + | not nid.match /^[a-zA-Z0-9\-]+$/ => \invalid-format + | nid.length < 3chars => \min-range + | nid.length > 30chars => \max-range + | _ => null + + if err? + @nid-state = err + @update! + else + @nid-state = \wait + @update! + + @api \app/name_id/available do + name_id: nid + .then (result) ~> + if result.available + @nid-state = \ok + else + @nid-state = \unavailable + @update! + .catch (err) ~> + @nid-state = \error + @update! + + @onsubmit = ~> + name = @refs.name.value + nid = @refs.nid.value + description = @refs.description.value + cb = @refs.cb.value + permission = [] + + @refs.permission.query-selector-all \input .for-each (el) ~> + if el.checked then permission.push el.value + + locker = document.body.append-child document.create-element \mk-locker + + @api \app/create do + name: name + name_id: nid + description: description + callback_url: cb + permission: permission.join \, + .then ~> + location.href = '/apps' + .catch ~> + alert 'アプリの作成に失敗しました。再度お試しください。' + + locker.parent-node.remove-child locker diff --git a/src/web/app/dev/tags/pages/app.tag b/src/web/app/dev/tags/pages/app.tag new file mode 100644 index 0000000000..aa9ba68f3f --- /dev/null +++ b/src/web/app/dev/tags/pages/app.tag @@ -0,0 +1,24 @@ +mk-app-page + p(if={ fetching }) 読み込み中 + main(if={ !fetching }) + header + h1 { app.name } + div.body + p App Secret + input(value={ app.secret }, readonly) + +style. + display block + +script. + @mixin \api + + @fetching = true + + @on \mount ~> + @api \app/show do + app_id: @opts.app + .then (app) ~> + @app = app + @fetching = false + @update! diff --git a/src/web/app/dev/tags/pages/apps.tag b/src/web/app/dev/tags/pages/apps.tag new file mode 100644 index 0000000000..f46a9d3282 --- /dev/null +++ b/src/web/app/dev/tags/pages/apps.tag @@ -0,0 +1,26 @@ +mk-apps-page + h1 アプリを管理 + a(href='/app/new') アプリ作成 + div.apps + p(if={ fetching }) 読み込み中 + virtual(if={ !fetching }) + p(if={ apps.length == 0 }) アプリなし + ul(if={ apps.length > 0 }) + li(each={ app in apps }) + a(href={ '/app/' + app.id }) + p.name { app.name } + +style. + display block + +script. + @mixin \api + + @fetching = true + + @on \mount ~> + @api \my/apps + .then (apps) ~> + @fetching = false + @apps = apps + @update! diff --git a/src/web/app/dev/tags/pages/index.tag b/src/web/app/dev/tags/pages/index.tag new file mode 100644 index 0000000000..7bc57fbb00 --- /dev/null +++ b/src/web/app/dev/tags/pages/index.tag @@ -0,0 +1,5 @@ +mk-index + a(href='/apps') アプリ + +style. + display block diff --git a/src/web/app/dev/tags/pages/new-app.tag b/src/web/app/dev/tags/pages/new-app.tag new file mode 100644 index 0000000000..8c19e39f4b --- /dev/null +++ b/src/web/app/dev/tags/pages/new-app.tag @@ -0,0 +1,33 @@ +mk-new-app-page + main + header + h1 新しいアプリを作成 + p MisskeyのAPIを利用したアプリケーションを作成できます。 + mk-new-app-form + +style. + display block + padding 64px 0 + + > main + width 100% + max-width 700px + margin 0 auto + + > header + margin 0 0 16px 0 + padding 0 0 16px 0 + border-bottom solid 1px #282827 + + > h1 + margin 0 0 12px 0 + padding 0 + line-height 32px + font-size 32px + font-weight normal + color #000 + + > p + margin 0 + line-height 16px + color #9a9894 diff --git a/src/web/app/dev/view.pug b/src/web/app/dev/view.pug new file mode 100644 index 0000000000..aea2f2adb8 --- /dev/null +++ b/src/web/app/dev/view.pug @@ -0,0 +1,5 @@ +extends ../base + +block head + link(rel='stylesheet', href='/_/resources/dev/style.css') + script(src='/_/resources/dev/script.js', async, defer) diff --git a/src/web/app/init.styl b/src/web/app/init.styl new file mode 100644 index 0000000000..972997725d --- /dev/null +++ b/src/web/app/init.styl @@ -0,0 +1,56 @@ +@charset 'utf-8' + +html + font-family sans-serif + +body > noscript > div + position fixed + z-index 32768 + top 0 + left 0 + width 100% + height 100% + text-align center + background #fff + + > p + display block + margin 32px + font-size 2em + color #555 + +#init + position fixed + z-index 16384 + top 0 + left 0 + width 100% + height 100% + text-align center + background #fff + cursor wait + + > p + display block + user-select none + margin 32px + font-size 4em + color #555 + + > span + animation init 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 init + 0%, 80%, 100% + opacity 1 + 40% + opacity 0 diff --git a/src/web/app/mobile/mixins.ls b/src/web/app/mobile/mixins.ls new file mode 100644 index 0000000000..902774f91a --- /dev/null +++ b/src/web/app/mobile/mixins.ls @@ -0,0 +1,19 @@ +riot = require \riot + +module.exports = (me) ~> + if me? + (require './scripts/stream.ls') me + + require './scripts/ui.ls' + + riot.mixin \open-post-form do + open-post-form: (opts) -> + app = document.get-element-by-id \app + app.style.display = \none + form = document.body.append-child document.create-element \mk-post-form + form = riot.mount form, opts .0 + form.on \cancel recover + form.on \post recover + + function recover + app.style.display = \block diff --git a/src/web/app/mobile/router.ls b/src/web/app/mobile/router.ls new file mode 100644 index 0000000000..33ae3e82da --- /dev/null +++ b/src/web/app/mobile/router.ls @@ -0,0 +1,110 @@ +# Router +#================================ + +riot = require \riot +route = require \page +page = null + +module.exports = (me) ~> + + # Routing + #-------------------------------- + + route \/ index + route \/i/notifications notifications + route \/i/drive drive + route \/i/drive/folder/:folder drive + route \/i/drive/file/:file drive + route \/post/new new-post + route \/post::post post + route \/search::query search + route \/:user user.bind null \posts + route \/:user/graphs user.bind null \graphs + route \/:user/followers user-followers + route \/:user/following user-following + route \/:user/:post post + route \* not-found + + # Handlers + #-------------------------------- + + # / + function index + if me? then home! else entrance! + + # ホーム + function home + mount document.create-element \mk-home-page + + # 玄関 + function entrance + mount document.create-element \mk-entrance + + # 通知 + function notifications + mount document.create-element \mk-notifications-page + + # 新規投稿 + function new-post + mount document.create-element \mk-new-post-page + + # 検索 + function search ctx + document.create-element \mk-search-page + ..set-attribute \query ctx.params.query + .. |> mount + + # ユーザー + function user page, ctx + document.create-element \mk-user-page + ..set-attribute \user ctx.params.user + ..set-attribute \page page + .. |> mount + + # フォロー一覧 + function user-following ctx + document.create-element \mk-user-following-page + ..set-attribute \user ctx.params.user + .. |> mount + + # フォロワー一覧 + function user-followers ctx + document.create-element \mk-user-followers-page + ..set-attribute \user ctx.params.user + .. |> mount + + # 投稿詳細ページ + function post ctx + document.create-element \mk-post-page + ..set-attribute \post ctx.params.post + .. |> mount + + # ドライブ + function drive ctx + p = document.create-element \mk-drive-page + if ctx.params.folder then p.set-attribute \folder ctx.params.folder + if ctx.params.file then p.set-attribute \file ctx.params.file + mount p + + # not found + function not-found + mount document.create-element \mk-not-found + + # Register mixin + #-------------------------------- + + riot.mixin \page do + page: route + + # Exec + #-------------------------------- + + route! + +# Mount +#================================ + +function mount content + if page? then page.unmount! + body = document.get-element-by-id \app + page := riot.mount body.append-child content .0 diff --git a/src/web/app/mobile/script.js b/src/web/app/mobile/script.js new file mode 100644 index 0000000000..1c269a57d9 --- /dev/null +++ b/src/web/app/mobile/script.js @@ -0,0 +1,20 @@ +/** + * Mobile Client + */ + +require('./tags.ls'); +require('./scripts/sp-slidemenu.js'); +const boot = require('../boot.ls'); +const mixins = require('./mixins.ls'); +const route = require('./router.ls'); + +/** + * Boot + */ +boot(me => { + // Register mixins + mixins(me); + + // Start routing + route(me); +}); diff --git a/src/web/app/mobile/scripts/sp-slidemenu.js b/src/web/app/mobile/scripts/sp-slidemenu.js new file mode 100644 index 0000000000..f2dcae9cef --- /dev/null +++ b/src/web/app/mobile/scripts/sp-slidemenu.js @@ -0,0 +1,839 @@ +/** + * sp-slidemenu.js + * + * @version 0.1.0 + * @url https://github.com/be-hase/sp-slidemenu + * + * Copyright 2013 be-hase.com, Inc. + * Licensed under the MIT License: + * http://www.opensource.org/licenses/mit-license.php + */ + +/** + * CUSTOMIZED BY SYUILO + */ + +; (function(window, document, undefined) { + "use strict"; + var div, PREFIX, support, gestureStart, EVENTS, ANIME_SPEED, SLIDE_STATUS, SCROLL_STATUS, THRESHOLD, EVENT_MOE_TIME, rclass, ITEM_CLICK_CLASS_NAME; + div = document.createElement('div'); + PREFIX = ['webkit', 'moz', 'o', 'ms']; + support = SpSlidemenu.support = {}; + support.transform3d = hasProp([ + 'perspectiveProperty', + 'WebkitPerspective', + 'MozPerspective', + 'OPerspective', + 'msPerspective' + ]); + support.transform = hasProp([ + 'transformProperty', + 'WebkitTransform', + 'MozTransform', + 'OTransform', + 'msTransform' + ]); + support.transition = hasProp([ + 'transitionProperty', + 'WebkitTransitionProperty', + 'MozTransitionProperty', + 'OTransitionProperty', + 'msTransitionProperty' + ]); + support.addEventListener = 'addEventListener' in window; + support.msPointer = window.navigator.msPointerEnabled; + support.cssAnimation = (support.transform3d || support.transform) && support.transition; + support.touch = 'ontouchend' in window; + EVENTS = { + start: { + touch: 'touchstart', + mouse: 'mousedown' + }, + move: { + touch: 'touchmove', + mouse: 'mousemove' + }, + end: { + touch: 'touchend', + mouse: 'mouseup' + } + }; + gestureStart = false; + if (support.addEventListener) { + document.addEventListener('gesturestart', function() { + gestureStart = true; + }); + document.addEventListener('gestureend', function() { + gestureStart = false; + }); + } + ANIME_SPEED = { + slider: 200, + scrollOverBack: 400 + }; + SLIDE_STATUS = { + close: 0, + open: 1, + progress: 2 + }; + THRESHOLD = 10; + EVENT_MOE_TIME = 50; + rclass = /[\t\r\n\f]/g; + ITEM_CLICK_CLASS_NAME = 'menu-item'; + /* + [MEMO] + SpSlidemenu properties which is not function is ... + -- element -- + element: main + element: slidemenu + element: button + element: slidemenuBody + element: slidemenuContent + element: slidemenuHeader + -- options -- + bool: disableCssAnimation + bool: disabled3d + -- animation -- + bool: useCssAnimation + bool: use3d + -- slide -- + int: slideWidth + string: htmlOverflowX + string: bodyOverflowX + int: buttonStartPageX + int: buttonStartPageY + -- scroll -- + bool: scrollTouchStarted + bool: scrollMoveReady + int: scrollStartPageX + int: scrollStartPageY + int: scrollBasePageY + int: scrollTimeForVelocity + int: scrollCurrentY + int: scrollMoveEventCnt + int: scrollAnimationTimer + int: scrollOverTimer + int: scrollMaxY + */ + function SpSlidemenu(main, slidemenu, button, options) { + if (this instanceof SpSlidemenu) { + return this.init(main, slidemenu, button, options); + } else { + return new SpSlidemenu(main, slidemenu, button, options); + } + } + SpSlidemenu.prototype.init = function(main, slidemenu, button, options) { + var _this = this; + // find and set element. + _this.setElement(main, slidemenu, button); + if (!_this.main || !_this.slidemenu || !_this.button || !_this.slidemenuBody || !_this.slidemenuContent) { + throw new Error('Element not found. Please set correctly.'); + } + // options + options = options || {}; + _this.disableCssAnimation = (options.disableCssAnimation === undefined) ? false : options.disableCssAnimation; + _this.disable3d = (options.disable3d === undefined) ? false : options.disable3d; + _this.direction = 'left'; + if (options.direction === 'right') { + _this.direction = 'right'; + } + // animation + _this.useCssAnimation = support.cssAnimation; + if (_this.disableCssAnimation === true) { + _this.useCssAnimation = false; + } + _this.use3d = support.transform3d; + if (_this.disable3d === true) { + _this.use3d = false; + } + // slide + _this.slideWidth = (getDimentions(_this.slidemenu)).width; + _this.main.SpSlidemenuStatus = SLIDE_STATUS.close; + _this.htmlOverflowX = ''; + _this.bodyOverflowX = ''; + // scroll + _this.scrollCurrentY = 0; + _this.scrollAnimationTimer = false; + _this.scrollOverTimer = false; + // set default style. + _this.setDefaultStyle(); + // bind some method for callback. + _this.bindMethods(); + // add event + addTouchEvent('start', _this.button, _this.buttonTouchStart, false); + addTouchEvent('move', _this.button, blockEvent, false); + addTouchEvent('end', _this.button, _this.buttonTouchEnd, false); + addTouchEvent('start', _this.slidemenuContent, _this.scrollTouchStart, false); + addTouchEvent('move', _this.slidemenuContent, _this.scrollTouchMove, false); + addTouchEvent('end', _this.slidemenuContent, _this.scrollTouchEnd, false); + _this.slidemenuContent.addEventListener('click', _this.itemClick, false); + // window size change + window.addEventListener('resize', debounce(_this.setHeight, 100), false); + return _this; + }; + SpSlidemenu.prototype.bindMethods = function() { + var _this, funcs; + _this = this; + funcs = [ + 'setHeight', + 'slideOpen', 'slideOpenEnd', 'slideClose', 'slideCloseEnd', + 'buttonTouchStart', 'buttonTouchEnd', 'mainTouchStart', + 'scrollTouchStart', 'scrollTouchMove', 'scrollTouchEnd', 'scrollInertiaMove', 'scrollOverBack', 'scrollOver', + 'itemClick' + ]; + funcs.forEach(function(func) { + _this[func] = bind(_this[func], _this); + }); + }; + SpSlidemenu.prototype.setElement = function(main, slidemenu, button) { + var _this = this; + _this.main = main; + if (typeof main === 'string') { + _this.main = document.querySelector(main); + } + _this.slidemenu = slidemenu; + if (typeof slidemenu === 'string') { + _this.slidemenu = document.querySelector(slidemenu); + } + _this.button = button; + if (typeof button === 'string') { + _this.button = document.querySelector(button); + } + if (!_this.slidemenu) { + return; + } + _this.slidemenuBody = _this.slidemenu.querySelector('.body'); + _this.slidemenuContent = _this.slidemenu.querySelector('.content'); + _this.slidemenuHeader = _this.slidemenu.querySelector('.header'); + }; + SpSlidemenu.prototype.setDefaultStyle = function() { + var _this = this; + if (support.msPointer) { + _this.slidemenuContent.style.msTouchAction = 'none'; + } + _this.setHeight(); + if (_this.useCssAnimation) { + setStyles(_this.main, { + transitionProperty: getCSSName('transform'), + transitionTimingFunction: 'ease-in-out', + transitionDuration: ANIME_SPEED.slider + 'ms', + transitionDelay: '0ms', + transform: _this.getTranslateX(0) + }); + setStyles(_this.slidemenu, { + transitionProperty: 'visibility', + transitionTimingFunction: 'linear', + transitionDuration: '0ms', + transitionDelay: ANIME_SPEED.slider + 'ms' + }); + setStyles(_this.slidemenuContent, { + transitionProperty: getCSSName('transform'), + transitionTimingFunction: 'ease-in-out', + transitionDuration: '0ms', + transitionDelay: '0ms', + transform: _this.getTranslateY(0) + }); + } else { + setStyles(_this.main, { + position: 'relative', + left: '0px' + }); + setStyles(_this.slidemenuContent, { + top: '0px' + }); + } + }; + SpSlidemenu.prototype.setHeight = function(event) { + var _this, browserHeight; + _this = this; + browserHeight = getBrowserHeight(); + setStyles(_this.main, { + minHeight: browserHeight + 'px' + }); + setStyles(_this.slidemenu, { + height: browserHeight + 'px' + }); + }; + SpSlidemenu.prototype.buttonTouchStart = function(event) { + var _this = this; + event.preventDefault(); + event.stopPropagation(); + switch (_this.main.SpSlidemenuStatus) { + case SLIDE_STATUS.progress: + break; + case SLIDE_STATUS.open: + case SLIDE_STATUS.close: + _this.buttonStartPageX = getPage(event, 'pageX'); + _this.buttonStartPageY = getPage(event, 'pageY'); + break; + } + }; + SpSlidemenu.prototype.buttonTouchEnd = function(event) { + var _this = this; + event.preventDefault(); + event.stopPropagation(); + if (_this.shouldTrigerNext(event)) { + switch (_this.main.SpSlidemenuStatus) { + case SLIDE_STATUS.progress: + break; + case SLIDE_STATUS.open: + _this.slideClose(event); + break; + case SLIDE_STATUS.close: + _this.slideOpen(event); + break; + } + } + }; + SpSlidemenu.prototype.mainTouchStart = function(event) { + var _this = this; + event.preventDefault(); + event.stopPropagation(); + _this.slideClose(event); + }; + SpSlidemenu.prototype.shouldTrigerNext = function(event) { + var _this = this, + buttonEndPageX = getPage(event, 'pageX'), + buttonEndPageY = getPage(event, 'pageY'), + deltaX = Math.abs(buttonEndPageX - _this.buttonStartPageX), + deltaY = Math.abs(buttonEndPageY - _this.buttonStartPageY); + return deltaX < 20 && deltaY < 20; + }; + SpSlidemenu.prototype.slideOpen = function(event) { + var _this = this, toX; + + /// Misskey Original + document.body.setAttribute('data-nav-open', 'true'); + + if (_this.direction === 'left') { + toX = _this.slideWidth; + } else { + toX = -_this.slideWidth; + } + _this.main.SpSlidemenuStatus = SLIDE_STATUS.progress; + //set event + addTouchEvent('move', document, blockEvent, false); + // change style + _this.htmlOverflowX = document.documentElement.style['overflowX']; + _this.bodyOverflowX = document.body.style['overflowX']; + document.documentElement.style['overflowX'] = document.body.style['overflowX'] = 'hidden'; + if (_this.useCssAnimation) { + setStyles(_this.main, { + transform: _this.getTranslateX(toX) + }); + setStyles(_this.slidemenu, { + transitionProperty: 'z-index', + visibility: 'visible', + zIndex: '1' + }); + } else { + animate(_this.main, _this.direction, toX, ANIME_SPEED.slider); + setStyles(_this.slidemenu, { + visibility: 'visible' + }); + } + // set callback + setTimeout(_this.slideOpenEnd, ANIME_SPEED.slider + EVENT_MOE_TIME); + }; + SpSlidemenu.prototype.slideOpenEnd = function() { + var _this = this; + _this.main.SpSlidemenuStatus = SLIDE_STATUS.open; + // change style + if (_this.useCssAnimation) { + } else { + setStyles(_this.slidemenu, { + zIndex: '1' + }); + } + // add event + addTouchEvent('start', _this.main, _this.mainTouchStart, false); + }; + SpSlidemenu.prototype.slideClose = function(event) { + var _this = this; + _this.main.SpSlidemenuStatus = SLIDE_STATUS.progress; + + /// Misskey Original + document.body.setAttribute('data-nav-open', 'false'); + + //event + removeTouchEvent('start', _this.main, _this.mainTouchStart, false); + // change style + if (_this.useCssAnimation) { + setStyles(_this.main, { + transform: _this.getTranslateX(0) + }); + setStyles(_this.slidemenu, { + transitionProperty: 'visibility', + visibility: 'hidden', + zIndex: '-1' + }); + } else { + animate(_this.main, _this.direction, 0, ANIME_SPEED.slider); + setStyles(_this.slidemenu, { + zIndex: '-1' + }); + } + // set callback + setTimeout(_this.slideCloseEnd, ANIME_SPEED.slider + EVENT_MOE_TIME); + }; + SpSlidemenu.prototype.slideCloseEnd = function() { + var _this = this; + _this.main.SpSlidemenuStatus = SLIDE_STATUS.close; + // change style + document.documentElement.style['overflowX'] = _this.htmlOverflowX; + document.body.style['overflowX'] = _this.bodyOverflowX; + if (_this.useCssAnimation) { + } else { + setStyles(_this.slidemenu, { + visibility: 'hidden' + }); + } + // set event + removeTouchEvent('move', document, blockEvent, false); + }; + SpSlidemenu.prototype.scrollTouchStart = function(event) { + var _this = this; + if (gestureStart) { + return; + } + if (_this.scrollOverTimer !== false) { + clearTimeout(_this.scrollOverTimer); + } + _this.scrollCurrentY = _this.getScrollCurrentY(); + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'ease-in-out', + transitionDuration: '0ms', + transform: _this.getTranslateY(_this.scrollCurrentY) + }); + } else { + _this.stopScrollAnimate(); + setStyles(_this.slidemenuContent, { + top: _this.scrollCurrentY + 'px' + }); + } + _this.scrollOverTimer = false; + _this.scrollAnimationTimer = false; + _this.scrollTouchStarted = true; + _this.scrollMoveReady = false; + _this.scrollMoveEventCnt = 0; + _this.scrollMaxY = _this.calcMaxY(); + _this.scrollStartPageX = getPage(event, 'pageX'); + _this.scrollStartPageY = getPage(event, 'pageY'); + _this.scrollBasePageY = _this.scrollStartPageY; + _this.scrollTimeForVelocity = event.timeStamp; + _this.scrollPageYForVelocity = _this.scrollStartPageY; + _this.slidemenuContent.removeEventListener('click', blockEvent, true); + }; + SpSlidemenu.prototype.scrollTouchMove = function(event) { + var _this, pageX, pageY, distY, newY, deltaX, deltaY; + _this = this; + if (!_this.scrollTouchStarted || gestureStart) { + return; + } + pageX = getPage(event, 'pageX'); + pageY = getPage(event, 'pageY'); + if (_this.scrollMoveReady) { + event.preventDefault(); + event.stopPropagation(); + distY = pageY - _this.scrollBasePageY; + newY = _this.scrollCurrentY + distY; + if (newY > 0 || newY < _this.scrollMaxY) { + newY = Math.round(_this.scrollCurrentY + distY / 3); + } + _this.scrollSetY(newY); + if (_this.scrollMoveEventCnt % THRESHOLD === 0) { + _this.scrollPageYForVelocity = pageY; + _this.scrollTimeForVelocity = event.timeStamp; + } + _this.scrollMoveEventCnt++; + } else { + deltaX = Math.abs(pageX - _this.scrollStartPageX); + deltaY = Math.abs(pageY - _this.scrollStartPageY); + if (deltaX > 5 || deltaY > 5) { + _this.scrollMoveReady = true; + _this.slidemenuContent.addEventListener('click', blockEvent, true); + } + } + _this.scrollBasePageY = pageY; + }; + SpSlidemenu.prototype.scrollTouchEnd = function(event) { + var _this, speed, deltaY, deltaTime; + _this = this; + if (!_this.scrollTouchStarted) { + return; + } + _this.scrollTouchStarted = false; + _this.scrollMaxY = _this.calcMaxY(); + if (_this.scrollCurrentY > 0 || _this.scrollCurrentY < _this.scrollMaxY) { + _this.scrollOverBack(); + return; + } + deltaY = getPage(event, 'pageY') - _this.scrollPageYForVelocity; + deltaTime = event.timeStamp - _this.scrollTimeForVelocity; + speed = deltaY / deltaTime; + if (Math.abs(speed) >= 0.01) { + _this.scrollInertia(speed); + } + }; + SpSlidemenu.prototype.scrollInertia = function(speed) { + var _this, directionToTop, maxTo, distanceMaxTo, stopTime, canMove, to, duration, speedAtboundary, nextTo; + _this = this; + if (speed > 0) { + directionToTop = true; + maxTo = 0; + } else { + directionToTop = false; + maxTo = _this.scrollMaxY; + } + distanceMaxTo = Math.abs(_this.scrollCurrentY - maxTo); + speed = Math.abs(750 * speed); + if (speed > 1000) { + speed = 1000; + } + stopTime = speed / 500; + canMove = (speed * stopTime) - ((500 * Math.pow(stopTime, 2)) / 2); + if (canMove <= distanceMaxTo) { + if (directionToTop) { + to = _this.scrollCurrentY + canMove; + } else { + to = _this.scrollCurrentY - canMove; + } + duration = stopTime * 1000; + _this.scrollInertiaMove(to, duration, false); + } else { + to = maxTo; + speedAtboundary = Math.sqrt((2 * 500 * distanceMaxTo) + Math.pow(speed, 2)); + duration = (speedAtboundary - speed) / 500 * 1000; + _this.scrollInertiaMove(to, duration, true, speedAtboundary, directionToTop); + } + }; + SpSlidemenu.prototype.scrollInertiaMove = function(to, duration, isOver, speed, directionToTop) { + var _this = this, stopTime, canMove; + _this.scrollCurrentY = to; + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'cubic-bezier(0.33, 0.66, 0.66, 1)', + transitionDuration: duration + 'ms', + transform: _this.getTranslateY(to) + }); + } else { + _this.scrollAnimate(to, duration); + } + if (!isOver) { + return; + } + stopTime = speed / 7500; + canMove = (speed * stopTime) - ((7500 * Math.pow(stopTime, 2)) / 2); + if (directionToTop) { + to = _this.scrollCurrentY + canMove; + } else { + to = _this.scrollCurrentY - canMove; + } + duration = stopTime * 1000; + _this.scrollOver(to, duration); + }; + SpSlidemenu.prototype.scrollOver = function(to, duration) { + var _this; + _this = this; + _this.scrollCurrentY = to; + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'cubic-bezier(0.33, 0.66, 0.66, 1)', + transitionDuration: duration + 'ms', + transform: _this.getTranslateY(to) + }); + } else { + _this.scrollAnimate(to, duration); + } + _this.scrollOverTimer = setTimeout(_this.scrollOverBack, duration); + }; + SpSlidemenu.prototype.scrollOverBack = function() { + var _this, to; + _this = this; + if (_this.scrollCurrentY >= 0) { + to = 0; + } else { + to = _this.scrollMaxY; + } + _this.scrollCurrentY = to; + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'ease-out', + transitionDuration: ANIME_SPEED.scrollOverBack + 'ms', + transform: _this.getTranslateY(to) + }); + } else { + _this.scrollAnimate(to, ANIME_SPEED.scrollOverBack); + } + }; + SpSlidemenu.prototype.scrollSetY = function(y) { + var _this = this; + _this.scrollCurrentY = y; + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'ease-in-out', + transitionDuration: '0ms', + transform: _this.getTranslateY(y) + }); + } else { + _this.slidemenuContent.style.top = y + 'px'; + } + }; + SpSlidemenu.prototype.scrollAnimate = function(to, transitionDuration) { + var _this = this; + _this.stopScrollAnimate(); + _this.scrollAnimationTimer = animate(_this.slidemenuContent, 'top', to, transitionDuration); + }; + SpSlidemenu.prototype.stopScrollAnimate = function() { + var _this = this; + if (_this.scrollAnimationTimer !== false) { + clearInterval(_this.scrollAnimationTimer); + } + }; + SpSlidemenu.prototype.itemClick = function(event) { + var elem = event.target || event.srcElement; + if (hasClass(elem, ITEM_CLICK_CLASS_NAME)) { + this.slideClose(); + } + }; + SpSlidemenu.prototype.calcMaxY = function(x) { + var _this, contentHeight, bodyHeight, headerHeight; + _this = this; + contentHeight = _this.slidemenuContent.offsetHeight; + bodyHeight = _this.slidemenuBody.offsetHeight; + headerHeight = 0; + if (_this.slidemenuHeader) { + headerHeight = _this.slidemenuHeader.offsetHeight; + } + if (contentHeight > bodyHeight) { + return -(contentHeight - bodyHeight + headerHeight); + } else { + return 0; + } + }; + SpSlidemenu.prototype.getScrollCurrentY = function() { + var ret = 0; + if (this.useCssAnimation) { + getStyle(window.getComputedStyle(this.slidemenuContent, ''), 'transform').split(',').forEach(function(value) { + var number = parseInt(value, 10); + if (!isNaN(number) && number !== 0 && number !== 1) { + ret = number; + } + }); + } else { + var number = parseInt(getStyle(window.getComputedStyle(this.slidemenuContent, ''), 'top'), 10); + if (!isNaN(number) && number !== 0 && number !== 1) { + ret = number; + } + } + return ret; + }; + SpSlidemenu.prototype.getTranslateX = function(x) { + var _this = this; + return _this.use3d ? 'translate3d(' + x + 'px, 0px, 0px)' : 'translate(' + x + 'px, 0px)'; + }; + SpSlidemenu.prototype.getTranslateY = function(y) { + var _this = this; + return _this.use3d ? 'translate3d(0px, ' + y + 'px, 0px)' : 'translate(0px, ' + y + 'px)'; + }; + //Utility Function + function hasProp(props) { + return some(props, function(prop) { + return div.style[prop] !== undefined; + }); + } + function upperCaseFirst(str) { + return str.charAt(0).toUpperCase() + str.substr(1); + } + function some(ary, callback) { + var i, len; + for (i = 0, len = ary.length; i < len; i++) { + if (callback(ary[i], i)) { + return true; + } + } + return false; + } + function setStyle(elem, prop, val) { + var style = elem.style; + if (!setStyle.cache) { + setStyle.cache = {}; + } + if (setStyle.cache[prop] !== undefined) { + style[setStyle.cache[prop]] = val; + return; + } + if (style[prop] !== undefined) { + setStyle.cache[prop] = prop; + style[prop] = val; + return; + } + some(PREFIX, function(_prefix) { + var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop); + if (style[_prop] !== undefined) { + //setStyle.cache[prop] = _prop; + style[_prop] = val; + return true; + } + }); + } + function setStyles(elem, styles) { + var style, prop; + for (prop in styles) { + if (styles.hasOwnProperty(prop)) { + setStyle(elem, prop, styles[prop]); + } + } + } + function getStyle(style, prop) { + var ret; + if (style[prop] !== undefined) { + return style[prop]; + } + some(PREFIX, function(_prefix) { + var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop); + if (style[_prop] !== undefined) { + ret = style[_prop]; + return true; + } + }); + return ret; + } + function getCSSName(prop) { + var ret; + if (!getCSSName.cache) { + getCSSName.cache = {}; + } + if (getCSSName.cache[prop] !== undefined) { + return getCSSName.cache[prop]; + } + if (div.style[prop] !== undefined) { + getCSSName.cache[prop] = prop; + return prop; + } + some(PREFIX, function(_prefix) { + var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop); + if (div.style[_prop] !== undefined) { + ret = '-' + _prefix + '-' + prop; + return true; + } + }); + getCSSName.cache[prop] = ret; + return ret; + } + function bind(func, context) { + var nativeBind, slice, args; + nativeBind = Function.prototype.bind; + slice = Array.prototype.slice; + if (func.bind === nativeBind && nativeBind) { + return nativeBind.apply(func, slice.call(arguments, 1)); + } + args = slice.call(arguments, 2); + return function() { + return func.apply(context, args.concat(slice.call(arguments))); + }; + } + function blockEvent(event) { + event.preventDefault(); + event.stopPropagation(); + } + function getDimentions(element) { + var previous, key, properties, result; + previous = {}; + properties = { + position: 'absolute', + visibility: 'hidden', + display: 'block' + }; + for (key in properties) { + previous[key] = element.style[key]; + element.style[key] = properties[key]; + } + result = { + width: element.offsetWidth, + height: element.offsetHeight + }; + for (key in properties) { + element.style[key] = previous[key]; + } + return result; + } + function getPage(event, page) { + return event.changedTouches ? event.changedTouches[0][page] : event[page]; + } + function addTouchEvent(eventType, element, listener, useCapture) { + useCapture = useCapture || false; + if (support.touch) { + element.addEventListener(EVENTS[eventType].touch, listener, { passive: useCapture }); + } else { + element.addEventListener(EVENTS[eventType].mouse, listener, { passive: useCapture }); + } + } + function removeTouchEvent(eventType, element, listener, useCapture) { + useCapture = useCapture || false; + if (support.touch) { + element.removeEventListener(EVENTS[eventType].touch, listener, useCapture); + } else { + element.removeEventListener(EVENTS[eventType].mouse, listener, useCapture); + } + } + function hasClass(elem, className) { + className = " " + className + " "; + if (elem.nodeType === 1 && (" " + elem.className + " ").replace(rclass, " ").indexOf(className) >= 0) { + return true; + } + return false; + } + function animate(elem, prop, to, transitionDuration) { + var begin, from, duration, easing, timer; + begin = +new Date(); + from = parseInt(elem.style[prop], 10); + to = parseInt(to, 10); + duration = parseInt(transitionDuration, 10); + easing = function(time, duration) { + return -(time /= duration) * (time - 2); + }; + timer = setInterval(function() { + var time, pos, now; + time = new Date() - begin; + if (time > duration) { + clearInterval(timer); + now = to; + } else { + pos = easing(time, duration); + now = pos * (to - from) + from; + } + elem.style[prop] = now + 'px'; + }, 10); + return timer; + } + function getBrowserHeight() { + if (window.innerHeight) { + return window.innerHeight; + } + else if (document.documentElement && document.documentElement.clientHeight !== 0) { + return document.documentElement.clientHeight; + } + else if (document.body) { + return document.body.clientHeight; + } + return 0; + } + function debounce(func, wait, immediate) { + var timeout, result; + return function() { + var context = this, args = arguments; + var later = function() { + timeout = null; + if (!immediate) result = func.apply(context, args); + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) result = func.apply(context, args); + return result; + }; + } + window.SpSlidemenu = SpSlidemenu; +})(window, window.document); diff --git a/src/web/app/mobile/scripts/stream.ls b/src/web/app/mobile/scripts/stream.ls new file mode 100644 index 0000000000..b7810b49ae --- /dev/null +++ b/src/web/app/mobile/scripts/stream.ls @@ -0,0 +1,13 @@ +# Stream +#================================ + +stream = require '../../common/scripts/stream.ls' +riot = require \riot + +module.exports = (me) ~> + s = stream me + + riot.mixin \stream do + stream: s.event + get-stream-state: s.get-state + stream-state-ev: s.state-ev diff --git a/src/web/app/mobile/scripts/ui.ls b/src/web/app/mobile/scripts/ui.ls new file mode 100644 index 0000000000..aa94a8b052 --- /dev/null +++ b/src/web/app/mobile/scripts/ui.ls @@ -0,0 +1,6 @@ +riot = require \riot + +ui = riot.observable! + +riot.mixin \ui do + ui: ui diff --git a/src/web/app/mobile/style.styl b/src/web/app/mobile/style.styl new file mode 100644 index 0000000000..bc7859844a --- /dev/null +++ b/src/web/app/mobile/style.styl @@ -0,0 +1,12 @@ +@import "../base" + +body[data-nav-open='true'] + #hamburger + > i + -webkit-transform rotate(-90deg) + transform rotate(-90deg) + +#wait + top auto + bottom 15px + left 15px diff --git a/src/web/app/mobile/tags.ls b/src/web/app/mobile/tags.ls new file mode 100644 index 0000000000..5805cb6b28 --- /dev/null +++ b/src/web/app/mobile/tags.ls @@ -0,0 +1,44 @@ +require './tags/ui.tag' +require './tags/ui-header.tag' +require './tags/ui-nav.tag' +require './tags/stream-indicator.tag' +require './tags/page/entrance.tag' +require './tags/page/entrance/signin.tag' +require './tags/page/entrance/signup.tag' +require './tags/page/home.tag' +require './tags/page/drive.tag' +require './tags/page/notifications.tag' +require './tags/page/user.tag' +require './tags/page/user-followers.tag' +require './tags/page/user-following.tag' +require './tags/page/post.tag' +require './tags/page/new-post.tag' +require './tags/page/search.tag' +require './tags/home.tag' +require './tags/home-timeline.tag' +require './tags/timeline.tag' +require './tags/timeline-post.tag' +require './tags/timeline-post-sub.tag' +require './tags/post-preview.tag' +require './tags/sub-post-content.tag' +require './tags/images-viewer.tag' +require './tags/drive.tag' +require './tags/drive-selector.tag' +require './tags/drive/file.tag' +require './tags/drive/folder.tag' +require './tags/drive/file-viewer.tag' +require './tags/post-form.tag' +require './tags/notification.tag' +require './tags/notifications.tag' +require './tags/notify.tag' +require './tags/notification-preview.tag' +require './tags/search.tag' +require './tags/search-posts.tag' +require './tags/post-detail.tag' +require './tags/user.tag' +require './tags/user-timeline.tag' +require './tags/follow-button.tag' +require './tags/user-preview.tag' +require './tags/users-list.tag' +require './tags/user-following.tag' +require './tags/user-followers.tag' diff --git a/src/web/app/mobile/tags/drive-selector.tag b/src/web/app/mobile/tags/drive-selector.tag new file mode 100644 index 0000000000..442299026e --- /dev/null +++ b/src/web/app/mobile/tags/drive-selector.tag @@ -0,0 +1,75 @@ +mk-drive-selector + div.body + header + h1 + | ファイルを選択 + span.count(if={ files.length > 0 }) ({ files.length }) + button.close(onclick={ cancel }): i.fa.fa-times + button.ok(onclick={ ok }): i.fa.fa-check + mk-drive@browser(select={ true }, multiple={ opts.multiple }) + +style. + display block + + > .body + position fixed + z-index 2048 + top 0 + left 0 + right 0 + margin 0 auto + width 100% + max-width 500px + height 100% + overflow hidden + background #fff + box-shadow 0 0 16px rgba(#000, 0.3) + + > header + border-bottom solid 1px #eee + + > h1 + margin 0 + padding 0 + text-align center + line-height 42px + font-size 1em + font-weight normal + + > .count + margin-left 4px + opacity 0.5 + + > .close + position absolute + top 0 + left 0 + line-height 42px + width 42px + + > .ok + position absolute + top 0 + right 0 + line-height 42px + width 42px + + > mk-drive + height calc(100% - 42px) + overflow scroll + +script. + @files = [] + + @on \mount ~> + @refs.browser.on \change-selected (files) ~> + @files = files + @update! + + @cancel = ~> + @trigger \canceled + @unmount! + + @ok = ~> + @trigger \selected @files + @unmount! diff --git a/src/web/app/mobile/tags/drive.tag b/src/web/app/mobile/tags/drive.tag new file mode 100644 index 0000000000..fcc78d1e68 --- /dev/null +++ b/src/web/app/mobile/tags/drive.tag @@ -0,0 +1,338 @@ +mk-drive + nav + p(onclick={ go-root }) + i.fa.fa-cloud + | ドライブ + virtual(each={ folder in hierarchy-folders }) + span: i.fa.fa-angle-right + p(onclick={ _move }) { folder.name } + span(if={ folder != null }): i.fa.fa-angle-right + p(if={ folder != null }) { folder.name } + div.browser(if={ file == null }, class={ loading: loading }) + div.folders(if={ folders.length > 0 }) + virtual(each={ folder in folders }) + mk-drive-folder(folder={ folder }) + p(if={ more-folders }) + | もっと読み込む + div.files(if={ files.length > 0 }) + virtual(each={ file in files }) + mk-drive-file(file={ file }) + p(if={ more-files }) + | もっと読み込む + div.empty(if={ files.length == 0 && folders.length == 0 && !loading }) + p(if={ !folder == null }) + | ドライブには何もありません。 + p(if={ folder != null }) + | このフォルダーは空です + div.loading(if={ loading }). + <div class="spinner"> + <div class="dot1"></div> + <div class="dot2"></div> + </div> + mk-drive-file-viewer(if={ file != null }, file={ file }) + +style. + display block + background #fff + + > nav + display block + width 100% + padding 10px 12px + overflow auto + white-space nowrap + font-size 0.9em + color #555 + background #fff + border-bottom solid 1px #dfdfdf + + > p + display inline + margin 0 + padding 0 + + &:last-child + font-weight bold + + > i + margin-right 4px + + > span + margin 0 8px + opacity 0.5 + + > .browser + &.loading + opacity 0.5 + + > .folders + > mk-drive-folder + border-bottom solid 1px #eee + + > .files + > mk-drive-file + border-bottom solid 1px #eee + + > .empty + padding 16px + text-align center + color #999 + pointer-events none + + > p + margin 0 + + > .loading + .spinner + margin 100px auto + width 40px + height 40px + text-align center + + animation sk-rotate 2.0s infinite linear + + .dot1, .dot2 + width 60% + height 60% + display inline-block + position absolute + top 0 + background-color rgba(0, 0, 0, 0.3) + border-radius 100% + + animation sk-bounce 2.0s infinite ease-in-out + + .dot2 + top auto + bottom 0 + animation-delay -1.0s + + @keyframes sk-rotate { 100% { transform: rotate(360deg); }} + + @keyframes sk-bounce { + 0%, 100% { + transform: scale(0.0); + } 50% { + transform: scale(1.0); + } + } + +script. + @mixin \api + @mixin \stream + + @files = [] + @folders = [] + @hierarchy-folders = [] + @selected-files = [] + + # 現在の階層(フォルダ) + # * null でルートを表す + @folder = null + + @file = null + + @is-select-mode = @opts.select? and @opts.select + @multiple = if @opts.multiple? then @opts.multiple else false + + @on \mount ~> + @stream.on \drive_file_created @on-stream-drive-file-created + @stream.on \drive_file_updated @on-stream-drive-file-updated + @stream.on \drive_folder_created @on-stream-drive-folder-created + @stream.on \drive_folder_updated @on-stream-drive-folder-updated + + # Riotのバグでnullを渡しても""になる + # https://github.com/riot/riot/issues/2080 + #if @opts.folder? + if @opts.folder? and @opts.folder != '' + @cd @opts.folder + else + @load! + + @on \unmount ~> + @stream.off \drive_file_created @on-stream-drive-file-created + @stream.off \drive_file_updated @on-stream-drive-file-updated + @stream.off \drive_folder_created @on-stream-drive-folder-created + @stream.off \drive_folder_updated @on-stream-drive-folder-updated + + @on-stream-drive-file-created = (file) ~> + @add-file file, true + + @on-stream-drive-file-updated = (file) ~> + current = if @folder? then @folder.id else null + if current != file.folder_id + @remove-file file + else + @add-file file, true + + @on-stream-drive-folder-created = (folder) ~> + @add-folder folder, true + + @on-stream-drive-folder-updated = (folder) ~> + current = if @folder? then @folder.id else null + if current != folder.parent_id + @remove-folder folder + else + @add-folder folder, true + + @_move = (ev) ~> + @move ev.item.folder + + @move = (target-folder) ~> + @cd target-folder, true + + @cd = (target-folder, is-move) ~> + if target-folder? and typeof target-folder == \object + target-folder = target-folder.id + + if target-folder == null + @go-root! + return + + @loading = true + @update! + + @api \drive/folders/show do + folder_id: target-folder + .then (folder) ~> + @folder = folder + @hierarchy-folders = [] + + x = (f) ~> + @hierarchy-folders.unshift f + if f.parent? + x f.parent + + if folder.parent? + x folder.parent + + @update! + if is-move then @trigger \move @folder + @trigger \cd @folder + @load! + .catch (err, text-status) -> + console.error err + + @add-folder = (folder, unshift = false) ~> + current = if @folder? then @folder.id else null + if current != folder.parent_id + return + + if (@folders.some (f) ~> f.id == folder.id) + return + + if unshift + @folders.unshift folder + else + @folders.push folder + + @update! + + @add-file = (file, unshift = false) ~> + current = if @folder? then @folder.id else null + if current != file.folder_id + return + + if (@files.some (f) ~> f.id == file.id) + exist = (@files.map (f) -> f.id).index-of file.id + @files[exist] = file + @update! + return + + if unshift + @files.unshift file + else + @files.push file + + @update! + + @remove-folder = (folder) ~> + if typeof folder == \object + folder = folder.id + @folders = @folders.filter (f) -> f.id != folder + @update! + + @remove-file = (file) ~> + if typeof file == \object + file = file.id + @files = @files.filter (f) -> f.id != file + @update! + + @go-root = ~> + if @folder != null + @folder = null + @hierarchy-folders = [] + @update! + @trigger \move-root + @load! + + @load = ~> + @folders = [] + @files = [] + @more-folders = false + @more-files = false + @loading = true + @update! + + @trigger \begin-load + + load-folders = null + load-files = null + + folders-max = 20 + files-max = 20 + + # フォルダ一覧取得 + @api \drive/folders do + folder_id: if @folder? then @folder.id else null + limit: folders-max + 1 + .then (folders) ~> + if folders.length == folders-max + 1 + @more-folders = true + folders.pop! + load-folders := folders + complete! + .catch (err, text-status) ~> + console.error err + + # ファイル一覧取得 + @api \drive/files do + folder_id: if @folder? then @folder.id else null + limit: files-max + 1 + .then (files) ~> + if files.length == files-max + 1 + @more-files = true + files.pop! + load-files := files + complete! + .catch (err, text-status) ~> + console.error err + + flag = false + complete = ~> + if flag + load-folders.for-each (folder) ~> + @add-folder folder + load-files.for-each (file) ~> + @add-file file + @loading = false + @update! + + @trigger \loaded + else + flag := true + @trigger \load-mid + + @choose-file = (file) ~> + if @is-select-mode + exist = @selected-files.some (f) ~> f.id == file.id + if exist + @selected-files = (@selected-files.filter (f) ~> f.id != file.id) + else + @selected-files.push file + @update! + @trigger \change-selected @selected-files + else + @file = file + @update! + @trigger \open-file @file diff --git a/src/web/app/mobile/tags/drive/file-viewer.tag b/src/web/app/mobile/tags/drive/file-viewer.tag new file mode 100644 index 0000000000..8ce89a06f4 --- /dev/null +++ b/src/web/app/mobile/tags/drive/file-viewer.tag @@ -0,0 +1,8 @@ +mk-drive-file-viewer + p.name { file.name } + +style. + display block + +script. + @file = @opts.file diff --git a/src/web/app/mobile/tags/drive/file.tag b/src/web/app/mobile/tags/drive/file.tag new file mode 100644 index 0000000000..ec271441a5 --- /dev/null +++ b/src/web/app/mobile/tags/drive/file.tag @@ -0,0 +1,130 @@ +mk-drive-file(onclick={ onclick }, data-is-selected={ is-selected }) + div.container + div.thumbnail(style={ 'background-image: url(' + file.url + '?thumbnail&size=128)' }) + div.body + p.name { file.name } + // + if file.tags.length > 0 + ul.tags + each tag in file.tags + li.tag(style={background: tag.color, color: contrast(tag.color)})= tag.name + footer + p.type + mk-file-type-icon(file={ file }) + | { file.type } + p.separator + p.data-size { bytes-to-size(file.datasize) } + p.separator + p.created-at + i.fa.fa-clock-o + mk-time(time={ file.created_at }) + +style. + display block + + &, * + user-select none + + * + pointer-events none + + > .container + max-width 500px + margin 0 auto + padding 16px + + &:after + content "" + display block + clear both + + > .thumbnail + display block + float left + width 64px + height 64px + background-size cover + background-position center center + + > .body + display block + float left + width calc(100% - 74px) + margin-left 10px + + > .name + display block + margin 0 + padding 0 + font-size 0.9em + font-weight bold + color #555 + text-overflow ellipsis + word-wrap break-word + + > .tags + display block + margin 4px 0 0 0 + padding 0 + list-style none + font-size 0.5em + + > .tag + display inline-block + margin 0 5px 0 0 + padding 1px 5px + border-radius 2px + + > footer + display block + margin 4px 0 0 0 + font-size 0.7em + + > .separator + display inline + margin 0 + padding 0 4px + color #CDCDCD + + > .type + display inline + margin 0 + padding 0 + color #9D9D9D + + > mk-file-type-icon + margin-right 4px + + > .data-size + display inline + margin 0 + padding 0 + color #9D9D9D + + > .created-at + display inline + margin 0 + padding 0 + color #BDBDBD + + > i + margin-right 2px + + &[data-is-selected] + background $theme-color + + &, * + color #fff !important + +script. + @mixin \bytes-to-size + + @browser = @parent + @file = @opts.file + @is-selected = @browser.selected-files.some (f) ~> f.id == @file.id + + @browser.on \change-selected (selects) ~> + @is-selected = selects.some (f) ~> f.id == @file.id + + @onclick = ~> + @browser.choose-file @file diff --git a/src/web/app/mobile/tags/drive/folder.tag b/src/web/app/mobile/tags/drive/folder.tag new file mode 100644 index 0000000000..ef3a72ea93 --- /dev/null +++ b/src/web/app/mobile/tags/drive/folder.tag @@ -0,0 +1,45 @@ +mk-drive-folder(onclick={ onclick }) + div.container + p.name + i.fa.fa-folder + | { folder.name } + i.fa.fa-angle-right + +style. + display block + color #777 + + &, * + user-select none + + * + pointer-events none + + > .container + max-width 500px + margin 0 auto + padding 16px + + > .name + display block + margin 0 + padding 0 + + > i + margin-right 6px + + > i + position absolute + top 0 + bottom 0 + right 8px + margin auto 0 auto 0 + width 1em + height 1em + +script. + @browser = @parent + @folder = @opts.folder + + @onclick = ~> + @browser.move @folder diff --git a/src/web/app/mobile/tags/follow-button.tag b/src/web/app/mobile/tags/follow-button.tag new file mode 100644 index 0000000000..7cedbbee88 --- /dev/null +++ b/src/web/app/mobile/tags/follow-button.tag @@ -0,0 +1,108 @@ +mk-follow-button + button(if={ !init }, class={ wait: wait, follow: !user.is_following, unfollow: user.is_following }, + onclick={ onclick }, + disabled={ wait }) + i.fa.fa-minus(if={ !wait && user.is_following }) + i.fa.fa-plus(if={ !wait && !user.is_following }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ wait }) + | { user.is_following ? 'フォロー解除' : 'フォロー' } + div.init(if={ init }): i.fa.fa-spinner.fa-pulse.fa-fw + +style. + display block + + > button + > .init + display block + user-select none + cursor pointer + padding 0 16px + margin 0 + height inherit + font-size 16px + outline none + border solid 1px $theme-color + border-radius 4px + + * + pointer-events none + + &.follow + color $theme-color + background transparent + + &:hover + background rgba($theme-color, 0.1) + + &:active + background rgba($theme-color, 0.2) + + &.unfollow + color $theme-color-foreground + background $theme-color + + &.wait + cursor wait !important + opacity 0.7 + + &.init + cursor wait !important + opacity 0.7 + + > i + margin-right 4px + +script. + @mixin \api + @mixin \is-promise + @mixin \stream + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + @init = true + @wait = false + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @init = false + @update! + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + @on \unmount ~> + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + @on-stream-follow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @on-stream-unfollow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @onclick = ~> + @wait = true + if @user.is_following + @api \following/delete do + user_id: @user.id + .then ~> + @user.is_following = false + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! + else + @api \following/create do + user_id: @user.id + .then ~> + @user.is_following = true + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! diff --git a/src/web/app/mobile/tags/home-timeline.tag b/src/web/app/mobile/tags/home-timeline.tag new file mode 100644 index 0000000000..1754bb2b07 --- /dev/null +++ b/src/web/app/mobile/tags/home-timeline.tag @@ -0,0 +1,40 @@ +mk-home-timeline + mk-timeline@timeline(init={ init }, more={ more }, empty={ '表示する投稿がありません。誰かしらをフォローするなどしましょう。' }) + +style. + display block + +script. + @mixin \api + @mixin \stream + + @init = new Promise (res, rej) ~> + @api \posts/timeline + .then (posts) ~> + res posts + @trigger \loaded + + @on \mount ~> + @stream.on \post @on-stream-post + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + @on \unmount ~> + @stream.off \post @on-stream-post + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + @more = ~> + @api \posts/timeline do + max_id: @refs.timeline.tail!.id + + @on-stream-post = (post) ~> + @is-empty = false + @update! + @refs.timeline.add-post post + + @on-stream-follow = ~> + @fetch! + + @on-stream-unfollow = ~> + @fetch! diff --git a/src/web/app/mobile/tags/home.tag b/src/web/app/mobile/tags/home.tag new file mode 100644 index 0000000000..ebcf8f0bb2 --- /dev/null +++ b/src/web/app/mobile/tags/home.tag @@ -0,0 +1,17 @@ +mk-home + mk-home-timeline@tl + +style. + display block + + > mk-home-timeline + max-width 600px + margin 0 auto + + @media (min-width 500px) + padding 16px + +script. + @on \mount ~> + @refs.tl.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/mobile/tags/images-viewer.tag b/src/web/app/mobile/tags/images-viewer.tag new file mode 100644 index 0000000000..f9d774a124 --- /dev/null +++ b/src/web/app/mobile/tags/images-viewer.tag @@ -0,0 +1,25 @@ +mk-images-viewer + div.image@view(onclick={ click }) + img@img(src={ image.url + '?thumbnail&size=512' }, alt={ image.name }, title={ image.name }) + +style. + display block + padding 8px + overflow hidden + box-shadow 0 0 4px rgba(0, 0, 0, 0.2) + border-radius 4px + + > .image + + > img + display block + max-height 256px + max-width 100% + margin 0 auto + +script. + @images = @opts.images + @image = @images.0 + + @click = ~> + window.open @image.url diff --git a/src/web/app/mobile/tags/notification-preview.tag b/src/web/app/mobile/tags/notification-preview.tag new file mode 100644 index 0000000000..ee936df7ab --- /dev/null +++ b/src/web/app/mobile/tags/notification-preview.tag @@ -0,0 +1,117 @@ +mk-notification-preview(class={ notification.type }) + div.main(if={ notification.type == 'like' }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-thumbs-o-up + | { notification.user.name } + p.post-ref { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'repost' }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-retweet + | { notification.post.user.name } + p.post-ref { get-post-summary(notification.post.repost) } + + div.main(if={ notification.type == 'quote' }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-quote-left + | { notification.post.user.name } + p.post-preview { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'follow' }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-user-plus + | { notification.user.name } + + div.main(if={ notification.type == 'reply' }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-reply + | { notification.post.user.name } + p.post-preview { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'mention' }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-at + | { notification.post.user.name } + p.post-preview { get-post-summary(notification.post) } + +style. + display block + margin 0 + padding 8px + color #fff + + > .main + word-wrap break-word + + &:after + content "" + display block + clear both + + img + display block + float left + min-width 36px + min-height 36px + max-width 36px + max-height 36px + border-radius 6px + + .text + float right + width calc(100% - 36px) + padding-left 8px + + p + margin 0 + + i + margin-right 4px + + .post-ref + + &:before, &:after + font-family FontAwesome + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &:before + content "\f10d" + + &:after + content "\f10e" + + &.like + .text p i + color #FFAC33 + + &.repost, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #fff + +script. + @mixin \get-post-summary + @notification = @opts.notification diff --git a/src/web/app/mobile/tags/notification.tag b/src/web/app/mobile/tags/notification.tag new file mode 100644 index 0000000000..afcc7441b4 --- /dev/null +++ b/src/web/app/mobile/tags/notification.tag @@ -0,0 +1,142 @@ +mk-notification(class={ notification.type }) + mk-time(time={ notification.created_at }) + + div.main(if={ notification.type == 'like' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-thumbs-o-up + a(href={ CONFIG.url + '/' + notification.user.username }) { notification.user.name } + a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'repost' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-retweet + a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name } + a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post.repost) } + + div.main(if={ notification.type == 'quote' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-quote-left + a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'follow' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-user-plus + a(href={ CONFIG.url + '/' + notification.user.username }) { notification.user.name } + + div.main(if={ notification.type == 'reply' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-reply + a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'mention' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-at + a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + +style. + display block + margin 0 + padding 16px + + > mk-time + display inline + position absolute + top 16px + right 12px + vertical-align top + color rgba(0, 0, 0, 0.6) + font-size 12px + + > .main + word-wrap break-word + + &:after + content "" + display block + clear both + + .avatar-anchor + display block + float left + + img + min-width 36px + min-height 36px + max-width 36px + max-height 36px + border-radius 6px + + .text + float right + width calc(100% - 36px) + padding-left 8px + + p + margin 0 + + i + margin-right 4px + + .post-preview + color rgba(0, 0, 0, 0.7) + + .post-ref + color rgba(0, 0, 0, 0.7) + + &:before, &:after + font-family FontAwesome + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &:before + content "\f10d" + + &:after + content "\f10e" + + &.like + .text p i + color #FFAC33 + + &.repost, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #555 + + .post-preview + color rgba(0, 0, 0, 0.7) + +script. + @mixin \get-post-summary + @notification = @opts.notification diff --git a/src/web/app/mobile/tags/notifications.tag b/src/web/app/mobile/tags/notifications.tag new file mode 100644 index 0000000000..7510d59967 --- /dev/null +++ b/src/web/app/mobile/tags/notifications.tag @@ -0,0 +1,98 @@ +mk-notifications + div.notifications(if={ notifications.length != 0 }) + virtual(each={ notification, i in notifications }) + mk-notification(notification={ notification }) + + p.date(if={ i != notifications.length - 1 && notification._date != notifications[i + 1]._date }) + span + i.fa.fa-angle-up + | { notification._datetext } + span + i.fa.fa-angle-down + | { notifications[i + 1]._datetext } + + p.empty(if={ notifications.length == 0 && !loading }) + | ありません! + p.loading(if={ loading }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + background #fff + + > .notifications + margin 0 auto + max-width 500px + + > mk-notification + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + &:last-child + border-bottom none + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.8em + color #aaa + background #fdfdfd + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + span + margin 0 16px + + i + margin-right 8px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \stream + @mixin \get-post-summary + + @notifications = [] + @loading = true + + @on \mount ~> + @api \i/notifications + .then (notifications) ~> + @notifications = notifications + @loading = false + @update! + @trigger \loaded + .catch (err, text-status) -> + console.error err + + @stream.on \notification @on-notification + + @on \unmount ~> + @stream.off \notification @on-notification + + @on-notification = (notification) ~> + @notifications.unshift notification + @update! + + @on \update ~> + @notifications.for-each (notification) ~> + date = (new Date notification.created_at).get-date! + month = (new Date notification.created_at).get-month! + 1 + notification._date = date + notification._datetext = month + '月 ' + date + '日' diff --git a/src/web/app/mobile/tags/notify.tag b/src/web/app/mobile/tags/notify.tag new file mode 100644 index 0000000000..9dd93ccf25 --- /dev/null +++ b/src/web/app/mobile/tags/notify.tag @@ -0,0 +1,35 @@ +mk-notify + mk-notification-preview(notification={ opts.notification }) + +style. + display block + position fixed + z-index 1024 + bottom -64px + left 0 + width 100% + height 64px + pointer-events none + -webkit-backdrop-filter blur(2px) + backdrop-filter blur(2px) + background-color rgba(#000, 0.5) + +script. + @on \mount ~> + Velocity @root, { + bottom: \0px + } { + duration: 500ms + easing: \ease-out + } + + set-timeout ~> + Velocity @root, { + bottom: \-64px + } { + duration: 500ms + easing: \ease-out + complete: ~> + @unmount! + } + , 6000ms diff --git a/src/web/app/mobile/tags/page/drive.tag b/src/web/app/mobile/tags/page/drive.tag new file mode 100644 index 0000000000..9bef7e6648 --- /dev/null +++ b/src/web/app/mobile/tags/page/drive.tag @@ -0,0 +1,46 @@ +mk-drive-page + mk-ui@ui: mk-drive@browser(folder={ parent.opts.folder }, file={ parent.opts.file }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + + @on \mount ~> + document.title = 'Misskey Drive' + @ui.trigger \title '<i class="fa fa-cloud"></i>ドライブ' + + @refs.ui.refs.browser.on \begin-load ~> + @Progress.start! + + @refs.ui.refs.browser.on \loaded-mid ~> + @Progress.set 0.5 + + @refs.ui.refs.browser.on \loaded ~> + @Progress.done! + + @refs.ui.refs.browser.on \move-root ~> + @ui.trigger \title '<i class="fa fa-cloud"></i>ドライブ' + + # Rewrite URL + history.push-state null null '/i/drive' + + @refs.ui.refs.browser.on \cd (folder) ~> + # TODO: escape html characters in folder.name + @ui.trigger \title '<i class="fa fa-folder-open"></i>' + folder.name + + @refs.ui.refs.browser.on \move (folder) ~> + # Rewrite URL + history.push-state null null '/i/drive/folder/' + folder.id + + @refs.ui.refs.browser.on \open-file (file) ~> + # TODO: escape html characters in file.name + @ui.trigger \title '<mk-file-type-icon class="icon"></mk-file-type-icon>' + file.name + + # Rewrite URL + history.push-state null null '/i/drive/file/' + file.id + + riot.mount \mk-file-type-icon do + file: file diff --git a/src/web/app/mobile/tags/page/entrance.tag b/src/web/app/mobile/tags/page/entrance.tag new file mode 100644 index 0000000000..67d8bc9bbf --- /dev/null +++ b/src/web/app/mobile/tags/page/entrance.tag @@ -0,0 +1,57 @@ +mk-entrance + main + img(src='/_/resources/title.svg', alt='Misskey') + + mk-entrance-signin(if={ mode == 'signin' }) + mk-entrance-signup(if={ mode == 'signup' }) + div.introduction(if={ mode == 'introduction' }) + mk-introduction + button(onclick={ signin }) わかった + + footer + mk-copyright + +style. + display block + height 100% + + > main + display block + + > img + display block + width 130px + height 120px + margin 0 auto + + > .introduction + max-width 300px + margin 0 auto + color #666 + + > button + display block + margin 16px auto 0 auto + + > footer + > mk-copyright + margin 0 + text-align center + line-height 64px + font-size 10px + color rgba(#000, 0.5) + +script. + @mode = \signin + + @signup = ~> + @mode = \signup + @update! + + @signin = ~> + @mode = \signin + @update! + + @introduction = ~> + @mode = \introduction + @update! diff --git a/src/web/app/mobile/tags/page/entrance/signin.tag b/src/web/app/mobile/tags/page/entrance/signin.tag new file mode 100644 index 0000000000..484c414e8e --- /dev/null +++ b/src/web/app/mobile/tags/page/entrance/signin.tag @@ -0,0 +1,45 @@ +mk-entrance-signin + mk-signin + div.divider: span or + button.signup(onclick={ parent.signup }) 新規登録 + a.introduction(onclick={ parent.introduction }) Misskeyについて + +style. + display block + margin 0 auto + padding 0 8px + max-width 350px + text-align center + + > .signup + padding 16px + width 100% + font-size 1em + color #fff + background $theme-color + border-radius 3px + + > .divider + padding 16px 0 + text-align center + + &:after + content "" + display block + position absolute + top 50% + width 100% + height 1px + border-top solid 1px rgba(0, 0, 0, 0.1) + + > * + z-index 1 + padding 0 8px + color rgba(0, 0, 0, 0.5) + background #fdfdfd + + > .introduction + display inline-block + margin-top 16px + font-size 12px + color #666 diff --git a/src/web/app/mobile/tags/page/entrance/signup.tag b/src/web/app/mobile/tags/page/entrance/signup.tag new file mode 100644 index 0000000000..a28f85e634 --- /dev/null +++ b/src/web/app/mobile/tags/page/entrance/signup.tag @@ -0,0 +1,35 @@ +mk-entrance-signup + mk-signup + button.cancel(type='button', onclick={ parent.signin }, title='キャンセル'): i.fa.fa-times + +style. + display block + margin 0 auto + padding 0 8px + max-width 350px + + > .cancel + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + box-shadow none + background transparent + transition opacity 0.1s ease + + &:hover + color #555 + + &:active + color #222 + + > i + padding 14px diff --git a/src/web/app/mobile/tags/page/home.tag b/src/web/app/mobile/tags/page/home.tag new file mode 100644 index 0000000000..c8d7729652 --- /dev/null +++ b/src/web/app/mobile/tags/page/home.tag @@ -0,0 +1,40 @@ +mk-home-page + mk-ui@ui: mk-home@home + +style. + display block + +script. + @mixin \i + @mixin \ui + @mixin \ui-progress + @mixin \stream + @mixin \get-post-summary + + @unread-count = 0 + + @on \mount ~> + document.title = 'Misskey' + @ui.trigger \title '<i class="fa fa-home"></i>ホーム' + + @Progress.start! + + @stream.on \post @on-stream-post + document.add-event-listener \visibilitychange @window-on-visibilitychange, false + + @refs.ui.refs.home.on \loaded ~> + @Progress.done! + + @on \unmount ~> + @stream.off \post @on-stream-post + document.remove-event-listener \visibilitychange @window-on-visibilitychange + + @on-stream-post = (post) ~> + if document.hidden and post.user_id !== @I.id + @unread-count++ + document.title = '(' + @unread-count + ') ' + @get-post-summary post + + @window-on-visibilitychange = ~> + if !document.hidden + @unread-count = 0 + document.title = 'Misskey' diff --git a/src/web/app/mobile/tags/page/new-post.tag b/src/web/app/mobile/tags/page/new-post.tag new file mode 100644 index 0000000000..21e00fc1f9 --- /dev/null +++ b/src/web/app/mobile/tags/page/new-post.tag @@ -0,0 +1,5 @@ +mk-new-post-page + mk-post-form@form + +style. + display block diff --git a/src/web/app/mobile/tags/page/notifications.tag b/src/web/app/mobile/tags/page/notifications.tag new file mode 100644 index 0000000000..9fb34dcd75 --- /dev/null +++ b/src/web/app/mobile/tags/page/notifications.tag @@ -0,0 +1,18 @@ +mk-notifications-page + mk-ui@ui: mk-notifications@notifications + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + + @on \mount ~> + document.title = 'Misskey | 通知' + @ui.trigger \title '<i class="fa fa-bell-o"></i>通知' + + @Progress.start! + + @refs.ui.refs.notifications.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/post.tag b/src/web/app/mobile/tags/page/post.tag new file mode 100644 index 0000000000..1dc74d267a --- /dev/null +++ b/src/web/app/mobile/tags/page/post.tag @@ -0,0 +1,31 @@ +mk-post-page + mk-ui@ui: main: mk-post-detail@post(post={ parent.post }) + +style. + display block + + main + background #fff + + > mk-post-detail + width 100% + max-width 500px + margin 0 auto + +script. + @mixin \ui + @mixin \ui-progress + + @post = @opts.post + + @on \mount ~> + document.title = 'Misskey' + @ui.trigger \title '<i class="fa fa-sticky-note-o"></i>投稿' + + @Progress.start! + + @refs.ui.refs.post.on \post-fetched ~> + @Progress.set 0.5 + + @refs.ui.refs.post.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/search.tag b/src/web/app/mobile/tags/page/search.tag new file mode 100644 index 0000000000..20de271f73 --- /dev/null +++ b/src/web/app/mobile/tags/page/search.tag @@ -0,0 +1,19 @@ +mk-search-page + mk-ui@ui: mk-search@search(query={ parent.opts.query }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + + @on \mount ~> + document.title = '検索: ' + @opts.query + ' | Misskey' + # TODO: クエリをHTMLエスケープ + @ui.trigger \title '<i class="fa fa-search"></i>' + @opts.query + + @Progress.start! + + @refs.ui.refs.search.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/user-followers.tag b/src/web/app/mobile/tags/page/user-followers.tag new file mode 100644 index 0000000000..e7e9a6fd1e --- /dev/null +++ b/src/web/app/mobile/tags/page/user-followers.tag @@ -0,0 +1,31 @@ +mk-user-followers-page + mk-ui@ui: mk-user-followers@list(if={ !parent.fetching }, user={ parent.user }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + @mixin \api + + @fetching = true + @user = null + + @on \mount ~> + @Progress.start! + + @api \users/show do + username: @opts.user + .then (user) ~> + @user = user + @fetching = false + + document.title = user.name + 'のフォロワー | Misskey' + # TODO: ユーザー名をエスケープ + @ui.trigger \title '<img src="' + user.avatar_url + '?thumbnail&size=64">' + user.name + 'のフォロー' + + @update! + + @refs.ui.refs.list.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/user-following.tag b/src/web/app/mobile/tags/page/user-following.tag new file mode 100644 index 0000000000..a74ba97b72 --- /dev/null +++ b/src/web/app/mobile/tags/page/user-following.tag @@ -0,0 +1,31 @@ +mk-user-following-page + mk-ui@ui: mk-user-following@list(if={ !parent.fetching }, user={ parent.user }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + @mixin \api + + @fetching = true + @user = null + + @on \mount ~> + @Progress.start! + + @api \users/show do + username: @opts.user + .then (user) ~> + @user = user + @fetching = false + + document.title = user.name + 'のフォロー | Misskey' + # TODO: ユーザー名をエスケープ + @ui.trigger \title '<img src="' + user.avatar_url + '?thumbnail&size=64">' + user.name + 'のフォロー' + + @update! + + @refs.ui.refs.list.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/user.tag b/src/web/app/mobile/tags/page/user.tag new file mode 100644 index 0000000000..9667abfd14 --- /dev/null +++ b/src/web/app/mobile/tags/page/user.tag @@ -0,0 +1,20 @@ +mk-user-page + mk-ui@ui: mk-user@user(user={ parent.user }, page={ parent.opts.page }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + + @user = @opts.user + + @on \mount ~> + @Progress.start! + + @refs.ui.refs.user.on \loaded (user) ~> + @Progress.done! + document.title = user.name + ' | Misskey' + # TODO: ユーザー名をエスケープ + @ui.trigger \title '<i class="fa fa-user"></i>' + user.name diff --git a/src/web/app/mobile/tags/post-detail.tag b/src/web/app/mobile/tags/post-detail.tag new file mode 100644 index 0000000000..c7eb091ce6 --- /dev/null +++ b/src/web/app/mobile/tags/post-detail.tag @@ -0,0 +1,415 @@ +mk-post-detail + + div.fetching(if={ fetching }) + mk-ellipsis-icon + + div.main(if={ !fetching }) + + button.read-more(if={ p.reply_to && p.reply_to.reply_to_id && context == null }, onclick={ load-context }, disabled={ loading-context }) + i.fa.fa-ellipsis-v(if={ !loading-context }) + i.fa.fa-spinner.fa-pulse(if={ loading-context }) + + div.context + virtual(each={ post in context }) + mk-post-preview(post={ post }) + + div.reply-to(if={ p.reply_to }) + mk-post-preview(post={ p.reply_to }) + + div.repost(if={ is-repost }) + p + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=32' }, alt='avatar') + i.fa.fa-retweet + a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name } + | がRepost + + article + a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username }) + img.avatar(src={ p.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + header + a.name(href={ CONFIG.url + '/' + p.user.username }) + | { p.user.name } + span.username + | @{ p.user.username } + div.body + div.text@text + div.media(if={ p.media }) + virtual(each={ file in p.media }) + img(src={ file.url + '?thumbnail&size=512' }, alt={ file.name }, title={ file.name }) + a.time(href={ url }) + mk-time(time={ p.created_at }, mode='detail') + footer + button(onclick={ reply }, title='返信') + i.fa.fa-reply + p.count(if={ p.replies_count > 0 }) { p.replies_count } + button(onclick={ repost }, title='Repost') + i.fa.fa-retweet + p.count(if={ p.repost_count > 0 }) { p.repost_count } + button(class={ liked: p.is_liked }, onclick={ like }, title='善哉') + i.fa.fa-thumbs-o-up + p.count(if={ p.likes_count > 0 }) { p.likes_count } + button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h + div.reposts-and-likes + div.reposts(if={ reposts && reposts.length > 0 }) + header + a { p.repost_count } + p Repost + ol.users + li.user(each={ reposts }) + a.avatar-anchor(href={ CONFIG.url + '/' + user.username }, title={ user.name }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=32' }, alt='') + div.likes(if={ likes && likes.length > 0 }) + header + a { p.likes_count } + p いいね + ol.users + li.user(each={ likes }) + a.avatar-anchor(href={ CONFIG.url + '/' + username }, title={ name }) + img.avatar(src={ avatar_url + '?thumbnail&size=32' }, alt='') + + div.replies + virtual(each={ post in replies }) + mk-post-detail-sub(post={ post }) + +style. + display block + margin 0 + padding 0 + + > .fetching + padding 64px 0 + + > .main + + > .read-more + display block + margin 0 + padding 10px 0 + width 100% + font-size 1em + text-align center + color #999 + cursor pointer + background #fafafa + outline none + border none + border-bottom solid 1px #eef0f2 + border-radius 6px 6px 0 0 + box-shadow none + + &:hover + background #f6f6f6 + + &:active + background #f0f0f0 + + &:disabled + color #ccc + + > .context + > * + border-bottom 1px solid #eef0f2 + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + & + article + padding-top 8px + + > .reply-to + border-bottom 1px solid #eef0f2 + + > article + padding 14px 16px 9px 16px + + @media (min-width 500px) + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + + > .avatar + display block + width 54px + height 54px + margin 0 + border-radius 8px + vertical-align bottom + + @media (min-width 500px) + width 60px + height 60px + + > header + position absolute + top 18px + left 80px + width calc(100% - 80px) + + @media (min-width 500px) + top 28px + left 108px + width calc(100% - 108px) + + > .name + display inline-block + margin 0 + color #777 + font-size 16px + font-weight bold + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + display block + text-align left + margin 0 + color #ccc + + > .body + padding 8px 0 + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 16px + color #717171 + + @media (min-width 500px) + font-size 24px + + > mk-url-preview + margin-top 8px + + > .media + > img + display block + max-width 100% + + > .time + font-size 16px + color #c0c0c0 + + > footer + font-size 1.2em + + > button + margin 0 28px 0 0 + padding 8px + background transparent + border none + box-shadow none + font-size 1em + color #ddd + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.liked + color $theme-color + + > .reposts-and-likes + display flex + justify-content center + padding 0 + margin 16px 0 + + &:empty + display none + + > .reposts + > .likes + display flex + flex 1 1 + padding 0 + border-top solid 1px #F2EFEE + + > header + flex 1 1 80px + max-width 80px + padding 8px 5px 0px 10px + + > a + display block + font-size 1.5em + line-height 1.4em + + > p + display block + margin 0 + font-size 0.7em + line-height 1em + font-weight normal + color #a0a2a5 + + > .users + display block + flex 1 1 + margin 0 + padding 10px 10px 10px 5px + list-style none + + > .user + display block + float left + margin 4px + padding 0 + + > .avatar-anchor + display:block + + > .avatar + vertical-align bottom + width 24px + height 24px + border-radius 4px + + > .reposts + .likes + margin-left 16px + + > .replies + > * + border-top 1px solid #eef0f2 + +script. + @mixin \api + @mixin \text + @mixin \get-post-summary + @mixin \open-post-form + + @fetching = true + @loading-context = false + @content = null + @post = null + + @on \mount ~> + @api \posts/show do + post_id: @opts.post + .then (post) ~> + @post = post + @is-repost = @post.repost? + @p = if @is-repost then @post.repost else @post + @summary = @get-post-summary @p + @trigger \loaded + @fetching = false + @update! + + if @p.text? + tokens = @analyze @p.text + @refs.text.innerHTML = @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content + + # Get likes + @api \posts/likes do + post_id: @p.id + limit: 8 + .then (likes) ~> + @likes = likes + @update! + + # Get reposts + @api \posts/reposts do + post_id: @p.id + limit: 8 + .then (reposts) ~> + @reposts = reposts + @update! + + # Get replies + @api \posts/replies do + post_id: @p.id + limit: 8 + .then (replies) ~> + @replies = replies + @update! + + @reply = ~> + @open-post-form do + reply: @p + + @repost = ~> + text = window.prompt '「' + @summary + '」をRepost' + if text? + @api \posts/create do + repost_id: @p.id + text: if text == '' then undefined else text + + @like = ~> + if @p.is_liked + @api \posts/likes/delete do + post_id: @p.id + .then ~> + @p.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @p.id + .then ~> + @p.is_liked = true + @update! + + @load-context = ~> + @loading-context = true + + # Get context + @api \posts/context do + post_id: @p.reply_to_id + .then (context) ~> + @context = context.reverse! + @loading-context = false + @update! diff --git a/src/web/app/mobile/tags/post-form.tag b/src/web/app/mobile/tags/post-form.tag new file mode 100644 index 0000000000..759a0820b8 --- /dev/null +++ b/src/web/app/mobile/tags/post-form.tag @@ -0,0 +1,254 @@ +mk-post-form + header: div + button.cancel(onclick={ cancel }): i.fa.fa-times + div + span.text-count(class={ over: refs.text.value.length > 300 }) { 300 - refs.text.value.length } + button.submit(onclick={ post }) 投稿 + div.form + mk-post-preview(if={ opts.reply }, post={ opts.reply }) + textarea@text(disabled={ wait }, oninput={ update }, onkeypress={ onkeypress }, onpaste={ onpaste }, placeholder={ opts.reply ? 'この投稿への返信...' : 'いまどうしてる?' }) + div.attaches(if={ files.length != 0 }) + ul.files@attaches + li.file(each={ files }) + div.img(style='background-image: url({ url + "?thumbnail&size=64" })', title={ name }) + li.add(if={ files.length < 4 }, title='PCからファイルを添付', onclick={ select-file }): i.fa.fa-plus + mk-uploader@uploader + button@upload(onclick={ select-file }): i.fa.fa-upload + button@drive(onclick={ select-file-from-drive }): i.fa.fa-cloud + input@file(type='file', accept='image/*', multiple, onchange={ change-file }) + +style. + display block + padding-top 50px + + > header + position fixed + z-index 1000 + top 0 + left 0 + width 100% + height 50px + background #fff + + > div + max-width 500px + margin 0 auto + + > .cancel + width 50px + line-height 50px + font-size 24px + color #555 + + > div + position absolute + top 0 + right 0 + + > .text-count + line-height 50px + color #657786 + + > .submit + margin 8px + padding 0 16px + line-height 34px + color $theme-color-foreground + background $theme-color + border-radius 4px + + &:disabled + opacity 0.7 + + > .form + max-width 500px + margin 0 auto + + > mk-post-preview + padding 16px + + > .attaches + + > .files + display block + margin 0 + padding 4px + list-style none + + &:after + content "" + display block + clear both + + > .file + display block + float left + margin 4px + padding 0 + cursor move + + &:hover > .remove + display block + + > .img + width 64px + height 64px + background-size cover + background-position center center + + > .remove + display none + position absolute + top -6px + right -6px + width 16px + height 16px + cursor pointer + + > .add + display block + float left + margin 4px + padding 0 + border dashed 2px rgba($theme-color, 0.2) + cursor pointer + + &:hover + border-color rgba($theme-color, 0.3) + + > i + color rgba($theme-color, 0.4) + + > i + display block + width 60px + height 60px + line-height 60px + text-align center + font-size 1.2em + color rgba($theme-color, 0.2) + + > mk-uploader + margin 8px 0 0 0 + padding 8px + + > [ref='file'] + display none + + > [ref='text'] + display block + padding 12px + margin 0 + width 100% + max-width 100% + min-width 100% + min-height 80px + font-size 16px + color #333 + border none + border-bottom solid 1px #ddd + border-radius 0 + + &:disabled + opacity 0.5 + + > [ref='upload'] + > [ref='drive'] + display inline-block + padding 0 + margin 0 + width 48px + height 48px + font-size 20px + color #657786 + background transparent + outline none + border none + border-radius 0 + box-shadow none + +script. + @mixin \api + + @wait = false + @uploadings = [] + @files = [] + + @on \mount ~> + @refs.uploader.on \uploaded (file) ~> + @add-file file + + @refs.uploader.on \change-uploads (uploads) ~> + @trigger \change-uploading-files uploads + + @refs.text.focus! + + @onkeypress = (e) ~> + if (e.char-code == 10 || e.char-code == 13) && e.ctrl-key + @post! + else + return true + + @onpaste = (e) ~> + data = e.clipboard-data + items = data.items + for i from 0 to items.length - 1 + item = items[i] + switch (item.kind) + | \file => + @upload item.get-as-file! + return true + + @select-file = ~> + @refs.file.click! + + @select-file-from-drive = ~> + browser = document.body.append-child document.create-element \mk-drive-selector + browser = riot.mount browser, do + multiple: true + .0 + browser.on \selected (files) ~> + files.for-each @add-file + + @change-file = ~> + files = @refs.file.files + for i from 0 to files.length - 1 + file = files.item i + @upload file + + @upload = (file) ~> + @refs.uploader.upload file + + @add-file = (file) ~> + file._remove = ~> + @files = @files.filter (x) -> x.id != file.id + @trigger \change-files @files + @update! + + @files.push file + @trigger \change-files @files + @update! + + @post = ~> + @wait = true + + files = if @files? and @files.length > 0 + then @files.map (f) -> f.id + else undefined + + @api \posts/create do + text: @refs.text.value + media_ids: files + reply_to_id: if @opts.reply? then @opts.reply.id else undefined + .then (data) ~> + @trigger \post + @unmount! + .catch (err) ~> + console.error err + #@opts.ui.trigger \notification 'Error!' + @wait = false + @update! + + @cancel = ~> + @trigger \cancel + @unmount! diff --git a/src/web/app/mobile/tags/post-preview.tag b/src/web/app/mobile/tags/post-preview.tag new file mode 100644 index 0000000000..e15b2be244 --- /dev/null +++ b/src/web/app/mobile/tags/post-preview.tag @@ -0,0 +1,89 @@ +mk-post-preview + article + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.main + header + a.name(href={ CONFIG.url + '/' + post.user.username }) + | { post.user.name } + span.username + | @{ post.user.username } + a.time(href={ CONFIG.url + '/' + post.user.username + '/' + post.id }) + mk-time(time={ post.created_at }) + div.body + mk-sub-post-content.text(post={ post }) + +style. + display block + margin 0 + padding 0 + font-size 0.9em + background #fff + + > article + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 60px) + + > header + margin-bottom 4px + white-space nowrap + + > .name + display inline + margin 0 + padding 0 + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #d1d8da + + > .time + position absolute + top 0 + right 0 + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + +script. + @post = @opts.post diff --git a/src/web/app/mobile/tags/search-posts.tag b/src/web/app/mobile/tags/search-posts.tag new file mode 100644 index 0000000000..4b1b12af27 --- /dev/null +++ b/src/web/app/mobile/tags/search-posts.tag @@ -0,0 +1,29 @@ +mk-search-posts + mk-timeline(init={ init }, more={ more }, empty={ '「' + query + '」に関する投稿は見つかりませんでした。' }) + +style. + display block + background #fff + +script. + @mixin \api + + @max = 30 + @offset = 0 + + @query = @opts.query + @with-media = @opts.with-media + + @init = new Promise (res, rej) ~> + @api \posts/search do + query: @query + .then (posts) ~> + res posts + @trigger \loaded + + @more = ~> + @offset += @max + @api \posts/search do + query: @query + max: @max + offset: @offset diff --git a/src/web/app/mobile/tags/search.tag b/src/web/app/mobile/tags/search.tag new file mode 100644 index 0000000000..bf2299cc9b --- /dev/null +++ b/src/web/app/mobile/tags/search.tag @@ -0,0 +1,12 @@ +mk-search + mk-search-posts@posts(query={ query }) + +style. + display block + +script. + @query = @opts.query + + @on \mount ~> + @refs.posts.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/mobile/tags/stream-indicator.tag b/src/web/app/mobile/tags/stream-indicator.tag new file mode 100644 index 0000000000..2eb5889ca6 --- /dev/null +++ b/src/web/app/mobile/tags/stream-indicator.tag @@ -0,0 +1,59 @@ +mk-stream-indicator + p(if={ state == 'initializing' }) + i.fa.fa-spinner.fa-spin + span + | 接続中 + mk-ellipsis + p(if={ state == 'reconnecting' }) + i.fa.fa-spinner.fa-spin + span + | 切断されました 接続中 + mk-ellipsis + p(if={ state == 'connected' }) + i.fa.fa-check + span 接続完了 + +style. + 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) + + > p + display block + margin 0 + + > i + margin-right 0.25em + +script. + @mixin \stream + + @on \before-mount ~> + @state = @get-stream-state! + + if @state == \connected + @root.style.opacity = 0 + + @stream-state-ev.on \connected ~> + @state = @get-stream-state! + @update! + set-timeout ~> + Velocity @root, { + opacity: 0 + } 200ms \linear + , 1000ms + + @stream-state-ev.on \closed ~> + @state = @get-stream-state! + @update! + Velocity @root, { + opacity: 1 + } 0ms diff --git a/src/web/app/mobile/tags/sub-post-content.tag b/src/web/app/mobile/tags/sub-post-content.tag new file mode 100644 index 0000000000..595f63d794 --- /dev/null +++ b/src/web/app/mobile/tags/sub-post-content.tag @@ -0,0 +1,36 @@ +mk-sub-post-content + div.body + a.reply(if={ post.reply_to_id }): i.fa.fa-reply + span@text + a.quote(if={ post.repost_id }, href={ '/post:' + post.repost_id }) RP: ... + details(if={ post.media }) + summary ({ post.media.length }枚の画像) + mk-images-viewer(images={ post.media }) + +style. + display block + word-wrap break-word + + > .body + > .reply + margin-right 6px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + +script. + @mixin \text + + @post = @opts.post + + @on \mount ~> + if @post.text? + tokens = @analyze @post.text + @refs.text.innerHTML = @compile tokens, false + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e diff --git a/src/web/app/mobile/tags/timeline-post-sub.tag b/src/web/app/mobile/tags/timeline-post-sub.tag new file mode 100644 index 0000000000..920503ebcc --- /dev/null +++ b/src/web/app/mobile/tags/timeline-post-sub.tag @@ -0,0 +1,99 @@ +mk-timeline-post-sub + article + a.avatar-anchor(href={ '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=96' }, alt='avatar') + div.main + header + a.name(href={ '/' + post.user.username }) + | { post.user.name } + span.username + | @{ post.user.username } + a.created-at(href={ '/' + post.user.username + '/' + post.id }) + mk-time(time={ post.created_at }) + div.body + mk-sub-post-content.text(post={ post }) + +style. + display block + margin 0 + padding 0 + font-size 0.9em + + > article + padding 16px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 10px 0 0 + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 44px + height 44px + margin 0 + border-radius 8px + vertical-align bottom + + @media (min-width 500px) + width 52px + height 52px + + > .main + float left + width calc(100% - 54px) + + @media (min-width 500px) + width calc(100% - 68px) + + > header + margin-bottom 4px + white-space nowrap + + > .name + display inline + margin 0 + padding 0 + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #d1d8da + + > .created-at + position absolute + top 0 + right 0 + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + +script. + @post = @opts.post diff --git a/src/web/app/mobile/tags/timeline-post.tag b/src/web/app/mobile/tags/timeline-post.tag new file mode 100644 index 0000000000..a71fab26f0 --- /dev/null +++ b/src/web/app/mobile/tags/timeline-post.tag @@ -0,0 +1,296 @@ +mk-timeline-post(class={ repost: is-repost }) + + div.reply-to(if={ p.reply_to }) + mk-timeline-post-sub(post={ p.reply_to }) + + div.repost(if={ is-repost }) + p + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + i.fa.fa-retweet + a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name } + | がRepost + mk-time(time={ post.created_at }) + + article + a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username }) + img.avatar(src={ p.user.avatar_url + '?thumbnail&size=96' }, alt='avatar') + div.main + header + a.name(href={ CONFIG.url + '/' + p.user.username }) + | { p.user.name } + span.username + | @{ p.user.username } + a.created-at(href={ url }) + mk-time(time={ p.created_at }) + div.body + div.text + a.reply(if={ p.reply_to }): i.fa.fa-reply + soan@text + a.quote(if={ p.repost != null }) RP: + div.media(if={ p.media }) + mk-images-viewer(images={ p.media }) + div.repost(if={ p.repost }) + i.fa.fa-quote-right.fa-flip-horizontal + mk-post-preview.repost(post={ p.repost }) + footer + button(onclick={ reply }) + i.fa.fa-reply + p.count(if={ p.replies_count > 0 }) { p.replies_count } + button(onclick={ repost }, title='Repost') + i.fa.fa-retweet + p.count(if={ p.repost_count > 0 }) { p.repost_count } + button(class={ liked: p.is_liked }, onclick={ like }) + i.fa.fa-thumbs-o-up + p.count(if={ p.likes_count > 0 }) { p.likes_count } + +style. + display block + margin 0 + padding 0 + font-size 12px + + @media (min-width 350px) + font-size 14px + + @media (min-width 500px) + font-size 16px + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 8px 16px + line-height 28px + + @media (min-width 500px) + padding 16px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + > mk-time + position absolute + top 8px + right 16px + font-size 0.9em + line-height 28px + + @media (min-width 500px) + top 16px + + & + article + padding-top 8px + + > .reply-to + background rgba(0, 0, 0, 0.0125) + + > mk-post-preview + background transparent + + > article + padding 14px 16px 9px 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 10px 0 0 + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 6px + vertical-align bottom + + @media (min-width 500px) + width 58px + height 58px + border-radius 8px + + > .main + float left + width calc(100% - 58px) + + @media (min-width 500px) + width calc(100% - 74px) + + > header + white-space nowrap + + @media (min-width 500px) + margin-bottom 2px + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .created-at + position absolute + top 0 + right 0 + font-size 0.9em + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.1em + color #717171 + + mk-url-preview + margin-top 8px + + > .reply + margin-right 8px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + + > .media + > img + display block + max-width 100% + + > .repost + margin 8px 0 + + > i:first-child + position absolute + top -8px + left -8px + z-index 1 + color #c0dac6 + font-size 28px + background #fff + + > mk-post-preview + padding 16px + border dashed 1px #c0dac6 + border-radius 8px + + > footer + > button + margin 0 28px 0 0 + padding 8px + background transparent + border none + box-shadow none + font-size 1em + color #ddd + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.liked + color $theme-color + +script. + @mixin \api + @mixin \text + @mixin \get-post-summary + @mixin \open-post-form + + @post = @opts.post + @is-repost = @post.repost? and !@post.text? + @p = if @is-repost then @post.repost else @post + @summary = @get-post-summary @p + @url = CONFIG.url + '/' + @p.user.username + '/' + @p.id + + @on \mount ~> + if @p.text? + tokens = if @p._highlight? + then @analyze @p._highlight + else @analyze @p.text + + @refs.text.innerHTML = if @p._highlight? + then @compile tokens, true, false + else @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content + + @reply = ~> + @open-post-form do + reply: @p + + @repost = ~> + text = window.prompt '「' + @summary + '」をRepost' + if text? + @api \posts/create do + repost_id: @p.id + text: if text == '' then undefined else text + + @like = ~> + if @p.is_liked + @api \posts/likes/delete do + post_id: @p.id + .then ~> + @p.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @p.id + .then ~> + @p.is_liked = true + @update! diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag new file mode 100644 index 0000000000..7114824872 --- /dev/null +++ b/src/web/app/mobile/tags/timeline.tag @@ -0,0 +1,128 @@ +mk-timeline + div.init(if={ init }) + i.fa.fa-spinner.fa-pulse + | 読み込んでいます + div.empty(if={ !init && posts.length == 0 }) + i.fa.fa-comments-o + | { opts.empty || '表示するものがありません' } + virtual(each={ post, i in posts }) + mk-timeline-post(post={ post }) + p.date(if={ i != posts.length - 1 && post._date != posts[i + 1]._date }) + span + i.fa.fa-angle-up + | { post._datetext } + span + i.fa.fa-angle-down + | { posts[i + 1]._datetext } + footer(if={ !init }) + button(if={ can-fetch-more }, onclick={ more }, disabled={ fetching }) + span(if={ !fetching }) もっとみる + span(if={ fetching }) + | 読み込み中 + mk-ellipsis + +style. + display block + background #fff + background-clip content-box + overflow hidden + + > .init + padding 64px 0 + text-align center + color #999 + + > i + margin-right 4px + + > .empty + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + + > mk-timeline-post + border-bottom solid 1px #eaeaea + + &:last-of-type + border-bottom none + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.9em + color #aaa + background #fdfdfd + border-bottom solid 1px #eaeaea + + span + margin 0 16px + + i + margin-right 8px + + > footer + text-align center + border-top solid 1px #eaeaea + border-bottom-left-radius 4px + border-bottom-right-radius 4px + + > button + margin 0 + padding 16px + width 100% + color $theme-color + + &:disabled + opacity 0.7 + +script. + @posts = [] + @init = true + @fetching = false + @can-fetch-more = true + + @on \mount ~> + @opts.init.then (posts) ~> + @init = false + @set-posts posts + + @on \update ~> + @posts.for-each (post) ~> + date = (new Date post.created_at).get-date! + month = (new Date post.created_at).get-month! + 1 + post._date = date + post._datetext = month + '月 ' + date + '日' + + @more = ~> + if @init or @fetching or @posts.length == 0 then return + @fetching = true + @update! + @opts.more!.then (posts) ~> + @fetching = false + @prepend-posts posts + + @set-posts = (posts) ~> + @posts = posts + @update! + + @prepend-posts = (posts) ~> + posts.for-each (post) ~> + @posts.push post + @update! + + @add-post = (post) ~> + @posts.unshift post + @update! + + @tail = ~> + @posts[@posts.length - 1] diff --git a/src/web/app/mobile/tags/ui-header.tag b/src/web/app/mobile/tags/ui-header.tag new file mode 100644 index 0000000000..7105d065f8 --- /dev/null +++ b/src/web/app/mobile/tags/ui-header.tag @@ -0,0 +1,98 @@ +mk-ui-header + mk-special-message + div.main + div.backdrop + div.content + button.nav#hamburger: i.fa.fa-bars + h1@title Misskey + button.post(onclick={ post }): i.fa.fa-pencil + +style. + $height = 48px + + display block + position fixed + top 0 + z-index 1024 + width 100% + box-shadow 0 1px 0 rgba(#000, 0.075) + + > .main + color rgba(#000, 0.6) + + > .backdrop + position absolute + top 0 + z-index 1023 + width 100% + height $height + -webkit-backdrop-filter blur(12px) + backdrop-filter blur(12px) + background-color rgba(#fff, 0.75) + + > .content + z-index 1024 + + > h1 + display block + margin 0 auto + padding 0 + width 100% + max-width calc(100% - 112px) + text-align center + font-size 1.1em + font-weight normal + line-height $height + white-space nowrap + overflow hidden + text-overflow ellipsis + + > i + margin-right 8px + + > img + display inline-block + vertical-align bottom + width ($height - 16px) + height ($height - 16px) + margin 8px + border-radius 6px + + > .nav + display block + position absolute + top 0 + left 0 + width $height + font-size 1.4em + line-height $height + border-right solid 1px rgba(#000, 0.1) + + > i + transition all 0.2s ease + + > .post + display block + position absolute + top 0 + right 0 + width $height + text-align center + font-size 1.4em + color inherit + line-height $height + border-left solid 1px rgba(#000, 0.1) + +script. + @mixin \ui + @mixin \open-post-form + + @on \mount ~> + @opts.ready! + + @ui.one \title (title) ~> + if @refs.title? + @refs.title.innerHTML = title + + @post = ~> + @open-post-form! diff --git a/src/web/app/mobile/tags/ui-nav.tag b/src/web/app/mobile/tags/ui-nav.tag new file mode 100644 index 0000000000..2c551b30ad --- /dev/null +++ b/src/web/app/mobile/tags/ui-nav.tag @@ -0,0 +1,169 @@ +mk-ui-nav + div.body: div.content + a.me(if={ SIGNIN }, href={ CONFIG.url + '/' + I.username }) + img.avatar(src={ I.avatar_url + '?thumbnail&size=128' }, alt='avatar') + p.name { I.name } + div.links + ul + li.post: a(href='/i/post') + i.icon.fa.fa-pencil-square-o + | 新規投稿 + i.angle.fa.fa-angle-right + ul + li.home: a(href='/') + i.icon.fa.fa-home + | ホーム + i.angle.fa.fa-angle-right + li.mentions: a(href='/i/mentions') + i.icon.fa.fa-at + | あなた宛て + i.angle.fa.fa-angle-right + li.notifications: a(href='/i/notifications') + i.icon.fa.fa-bell-o + | 通知 + i.angle.fa.fa-angle-right + li.messaging: a + i.icon.fa.fa-comments-o + | メッセージ + i.angle.fa.fa-angle-right + ul + li.settings: a(onclick={ search }) + i.icon.fa.fa-search + | 検索 + i.angle.fa.fa-angle-right + ul + li.settings: a(href='/i/drive') + i.icon.fa.fa-cloud + | ドライブ + i.angle.fa.fa-angle-right + li.settings: a(href='/i/upload') + i.icon.fa.fa-upload + | アップロード + i.angle.fa.fa-angle-right + ul + li.settings: a(href='/i/settings') + i.icon.fa.fa-cog + | 設定 + i.angle.fa.fa-angle-right + p.about + a Misskeyについて + +style. + display block + position fixed + top 0 + left 0 + z-index -1 + width 240px + color #fff + background #313538 + visibility hidden + + .body + height 100% + overflow hidden + + .content + min-height 100% + + .me + display block + margin 0 + padding 16px + + .avatar + display inline + max-width 64px + border-radius 32px + vertical-align middle + + .name + display block + margin 0 16px + position absolute + top 0 + left 80px + padding 0 + width calc(100% - 112px) + color #fff + line-height 96px + overflow hidden + text-overflow ellipsis + white-space nowrap + + ul + display block + margin 16px 0 + padding 0 + list-style none + + &:first-child + margin-top 0 + + li + display block + font-size 1em + line-height 1em + border-top solid 1px rgba(0, 0, 0, 0.2) + background #353A3E + background-clip content-box + + &:last-child + border-bottom solid 1px rgba(0, 0, 0, 0.2) + + a + display block + padding 0 20px + line-height 3rem + line-height calc(1rem + 30px) + color #eee + text-decoration none + + > .icon + margin-right 0.5em + + > .angle + position absolute + top 0 + right 0 + padding 0 20px + font-size 1.2em + line-height calc(1rem + 30px) + color #ccc + + > .unread-count + position absolute + height calc(0.9em + 10px) + line-height calc(0.9em + 10px) + top 0 + bottom 0 + right 38px + margin auto 0 + padding 0px 8px + min-width 2em + font-size 0.9em + text-align center + color #fff + background rgba(255, 255, 255, 0.1) + border-radius 1em + + .about + margin 1em 1em 2em 1em + text-align center + font-size 0.6em + opacity 0.3 + + a + color #fff + +script. + @mixin \i + @mixin \page + + @on \mount ~> + @opts.ready! + + @search = ~> + query = window.prompt \検索 + if query? and query != '' + @page '/search:' + query diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag new file mode 100644 index 0000000000..81dfac80ca --- /dev/null +++ b/src/web/app/mobile/tags/ui.tag @@ -0,0 +1,50 @@ +mk-ui + div.global@global + mk-ui-header@header(ready={ ready }) + mk-ui-nav@nav(ready={ ready }) + + div.content@main + <yield /> + + mk-stream-indicator + +style. + display block + + > .global + > .content + background #fff + +script. + @mixin \stream + + @ready-count = 0 + + #@ui.on \notification (text) ~> + # alert text + + @on \mount ~> + @stream.on \notification @on-stream-notification + @ready! + + @on \unmount ~> + @stream.off \notification @on-stream-notification + @slide.slide-close! + + @ready = ~> + @ready-count++ + + if @ready-count == 2 + @slide = SpSlidemenu @refs.main, @refs.nav.root, \#hamburger {direction: \left} + @init-view-position! + + @init-view-position = ~> + top = @refs.header.root.offset-height + @refs.main.style.padding-top = top + \px + @refs.nav.root.style.margin-top = top + \px + @refs.nav.root.query-selector '.body > .content' .style.padding-bottom = top + \px + + @on-stream-notification = (notification) ~> + el = document.body.append-child document.create-element \mk-notify + riot.mount el, do + notification: notification diff --git a/src/web/app/mobile/tags/user-followers.tag b/src/web/app/mobile/tags/user-followers.tag new file mode 100644 index 0000000000..7004398268 --- /dev/null +++ b/src/web/app/mobile/tags/user-followers.tag @@ -0,0 +1,22 @@ +mk-user-followers + mk-users-list@list(fetch={ fetch }, count={ user.followers_count }, you-know-count={ user.followers_you_know_count }, no-users={ 'フォロワーはいないようです。' }) + +style. + display block + +script. + @mixin \api + + @user = @opts.user + + @fetch = (iknow, limit, cursor, cb) ~> + @api \users/followers do + user_id: @user.id + iknow: iknow + limit: limit + cursor: if cursor? then cursor else undefined + .then cb + + @on \mount ~> + @refs.list.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/mobile/tags/user-following.tag b/src/web/app/mobile/tags/user-following.tag new file mode 100644 index 0000000000..c122acd607 --- /dev/null +++ b/src/web/app/mobile/tags/user-following.tag @@ -0,0 +1,22 @@ +mk-user-following + mk-users-list@list(fetch={ fetch }, count={ user.following_count }, you-know-count={ user.following_you_know_count }, no-users={ 'フォロー中のユーザーはいないようです。' }) + +style. + display block + +script. + @mixin \api + + @user = @opts.user + + @fetch = (iknow, limit, cursor, cb) ~> + @api \users/following do + user_id: @user.id + iknow: iknow + limit: limit + cursor: if cursor? then cursor else undefined + .then cb + + @on \mount ~> + @refs.list.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/mobile/tags/user-preview.tag b/src/web/app/mobile/tags/user-preview.tag new file mode 100644 index 0000000000..4f5fbc1520 --- /dev/null +++ b/src/web/app/mobile/tags/user-preview.tag @@ -0,0 +1,103 @@ +mk-user-preview + a.avatar-anchor(href={ CONFIG.url + '/' + user.username }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.main + header + div.left + a.name(href={ CONFIG.url + '/' + user.username }) + | { user.name } + span.username + | @{ user.username } + div.body + div.bio { user.bio } + +style. + display block + margin 0 + padding 16px + font-size 12px + + @media (min-width 350px) + font-size 14px + + @media (min-width 500px) + font-size 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 10px 0 0 + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 6px + vertical-align bottom + + @media (min-width 500px) + width 58px + height 58px + border-radius 8px + + > .main + float left + width calc(100% - 58px) + + @media (min-width 500px) + width calc(100% - 74px) + + > header + white-space nowrap + + @media (min-width 500px) + margin-bottom 2px + + &:after + content "" + display block + clear both + + > .left + float left + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .body + + > .bio + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.1em + color #717171 + +script. + @user = @opts.user diff --git a/src/web/app/mobile/tags/user-timeline.tag b/src/web/app/mobile/tags/user-timeline.tag new file mode 100644 index 0000000000..7aa23d2150 --- /dev/null +++ b/src/web/app/mobile/tags/user-timeline.tag @@ -0,0 +1,28 @@ +mk-user-timeline + mk-timeline(init={ init }, more={ more }, empty={ with-media ? 'メディア付き投稿はありません。' : 'このユーザーはまだ投稿していないようです。' }) + +style. + display block + max-width 600px + margin 0 auto + background #fff + +script. + @mixin \api + + @user = @opts.user + @with-media = @opts.with-media + + @init = new Promise (res, rej) ~> + @api \users/posts do + user_id: @user.id + with_media: @with-media + .then (posts) ~> + res posts + @trigger \loaded + + @more = ~> + @api \users/posts do + user_id: @user.id + with_media: @with-media + max_id: @refs.timeline.tail!.id diff --git a/src/web/app/mobile/tags/user.tag b/src/web/app/mobile/tags/user.tag new file mode 100644 index 0000000000..8f4c04cf9c --- /dev/null +++ b/src/web/app/mobile/tags/user.tag @@ -0,0 +1,198 @@ +mk-user + div.user(if={ !fetching }) + header + div.banner(style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' }) + div.body + div.top + a.avatar: img(src={ user.avatar_url + '?thumbnail&size=160' }, alt='avatar') + mk-follow-button(if={ SIGNIN && I.id != user.id }, user={ user }) + + div.title + h1 { user.name } + span.username @{ user.username } + span.followed(if={ user.is_followed }) フォローされています + + div.bio { user.bio } + + div.info + p.location(if={ user.location }) + i.fa.fa-map-marker + | { user.location } + + div.friends + a(href='{ user.username }/following') + b { user.following_count } + i フォロー + a(href='{ user.username }/followers') + b { user.followers_count } + i フォロワー + nav + a(data-is-active={ page == 'posts' }, onclick={ go-posts }) 投稿 + a(data-is-active={ page == 'media' }, onclick={ go-media }) メディア + a(data-is-active={ page == 'graphs' }, onclick={ go-graphs }) グラフ + a(data-is-active={ page == 'likes' }, onclick={ go-likes }) いいね + + div.body + mk-user-timeline(if={ page == 'posts' }, user={ user }) + mk-user-timeline(if={ page == 'media' }, user={ user }, with-media={ true }) + mk-user-graphs(if={ page == 'graphs' }, user={ user }) + +style. + display block + + > .user + > header + > .banner + padding-bottom 33.3% + background-color #f5f5f5 + background-size cover + background-position center + + > .body + padding 8px + margin 0 auto + max-width 600px + + > .top + &:after + content '' + display block + clear both + + > .avatar + display block + float left + width 25% + height 40px + + > img + display block + position absolute + left -2px + bottom -2px + width 100% + border 2px solid #fff + border-radius 6px + + @media (min-width 500px) + left -4px + bottom -4px + border 4px solid #fff + border-radius 12px + + > mk-follow-button + float right + height 40px + + > .title + margin 8px 0 + + > h1 + margin 0 + line-height 22px + font-size 20px + color #222 + + > .username + display inline-block + line-height 20px + font-size 16px + font-weight bold + color #657786 + + > .followed + margin-left 8px + padding 2px 4px + font-size 12px + color #657786 + background #f8f8f8 + border-radius 4px + + > .bio + margin 8px 0 + color #333 + + > .info + margin 8px 0 + + > .location + display inline + margin 0 + color #555 + + > i + margin-right 4px + + > .friends + > a + color #657786 + + &:first-child + margin-right 16px + + > b + margin-right 4px + font-size 16px + color #14171a + + > i + font-size 14px + + > nav + display flex + justify-content center + margin 0 auto + max-width 600px + border-bottom solid 1px #ddd + + > a + display block + flex 1 1 + text-align center + line-height 52px + font-size 14px + text-decoration none + color #657786 + border-bottom solid 2px transparent + + &[data-is-active] + font-weight bold + color $theme-color + border-color $theme-color + + > .body + @media (min-width 500px) + padding 16px 0 0 0 + +script. + @mixin \i + @mixin \api + + @username = @opts.user + @page = if @opts.page? then @opts.page else \posts + @fetching = true + + @on \mount ~> + @api \users/show do + username: @username + .then (user) ~> + @fetching = false + @user = user + @trigger \loaded user + @update! + + @go-posts = ~> + @page = \posts + @update! + + @go-media = ~> + @page = \media + @update! + + @go-graphs = ~> + @page = \graphs + @update! + + @go-likes = ~> + @page = \likes + @update! diff --git a/src/web/app/mobile/tags/users-list.tag b/src/web/app/mobile/tags/users-list.tag new file mode 100644 index 0000000000..3e29a0a4cc --- /dev/null +++ b/src/web/app/mobile/tags/users-list.tag @@ -0,0 +1,125 @@ +mk-users-list + nav + span(data-is-active={ mode == 'all' }, onclick={ set-mode.bind(this, 'all') }) + | すべて + span { opts.count } + // ↓ https://github.com/riot/riot/issues/2080 + span(if={ SIGNIN && opts.you-know-count != '' }, data-is-active={ mode == 'iknow' }, onclick={ set-mode.bind(this, 'iknow') }) + | 知り合い + span { opts.you-know-count } + + div.users(if={ !fetching && users.length != 0 }) + mk-user-preview(each={ users }, user={ this }) + + button.more(if={ !fetching && next != null }, onclick={ more }, disabled={ more-fetching }) + span(if={ !more-fetching }) もっと + span(if={ more-fetching }) + | 読み込み中 + mk-ellipsis + + p.no(if={ !fetching && users.length == 0 }) + | { opts.no-users } + p.fetching(if={ fetching }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + background #fff + + > nav + display flex + justify-content center + margin 0 auto + max-width 600px + border-bottom solid 1px #ddd + + > span + display block + flex 1 1 + text-align center + line-height 52px + font-size 14px + color #657786 + border-bottom solid 2px transparent + + &[data-is-active] + font-weight bold + color $theme-color + border-color $theme-color + + > span + display inline-block + margin-left 4px + padding 2px 5px + font-size 12px + line-height 1 + color #888 + background #eee + border-radius 20px + + > .users + > * + max-width 600px + margin 0 auto + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + > .no + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \i + + @limit = 30users + @mode = \all + + @fetching = true + @more-fetching = false + + @on \mount ~> + @fetch ~> + @trigger \loaded + + @fetch = (cb) ~> + @fetching = true + @update! + obj <~ @opts.fetch do + @mode == \iknow + @limit + null + @users = obj.users + @next = obj.next + @fetching = false + @update! + if cb? then cb! + + @more = ~> + @more-fetching = true + @update! + obj <~ @opts.fetch do + @mode == \iknow + @limit + @cursor + @users = @users.concat obj.users + @next = obj.next + @more-fetching = false + @update! + + @set-mode = (mode) ~> + @update do + mode: mode + + @fetch! diff --git a/src/web/app/reset.styl b/src/web/app/reset.styl new file mode 100644 index 0000000000..cf872337cf --- /dev/null +++ b/src/web/app/reset.styl @@ -0,0 +1,27 @@ +* + position relative + box-sizing border-box + background-clip padding-box !important + +input:not([type]) +input[type='text'] +input[type='password'] +input[type='email'] +textarea +button +progress + -webkit-appearance none + -moz-appearance none + appearance none + box-shadow none + +button + margin 0 + padding 0 + background transparent + border none + cursor pointer + color inherit + + * + pointer-events none diff --git a/src/web/apple-touch-icon.ts b/src/web/apple-touch-icon.ts new file mode 100644 index 0000000000..32e1840405 --- /dev/null +++ b/src/web/apple-touch-icon.ts @@ -0,0 +1,8 @@ +import * as path from 'path'; +import * as express from 'express'; + +const app = express.Router(); +app.get('/apple-touch-icon.png', (req, res) => + res.sendFile(path.resolve(__dirname + '/../../resources/apple-touch-icon.png'))); + +module.exports = app; diff --git a/src/web/manifest.ts b/src/web/manifest.ts new file mode 100644 index 0000000000..e87b0ee328 --- /dev/null +++ b/src/web/manifest.ts @@ -0,0 +1,6 @@ +import * as express from 'express'; + +const app = express.Router(); +app.get('/manifest.json', (req, res) => res.sendFile(__dirname + '/../../resources/manifest.json')); + +module.exports = app; diff --git a/src/web/meta.ts b/src/web/meta.ts new file mode 100644 index 0000000000..9729faa9ea --- /dev/null +++ b/src/web/meta.ts @@ -0,0 +1,13 @@ +import * as express from 'express'; +const git = require('git-last-commit'); + +module.exports = async (req: express.Request, res: express.Response) => { + // Get commit info + git.getLastCommit((err, commit) => { + res.send({ + commit: commit + }); + }, { + dst: `${__dirname}/../../misskey` + }); +}; diff --git a/src/web/serve-app.ts b/src/web/serve-app.ts new file mode 100644 index 0000000000..3292cfde37 --- /dev/null +++ b/src/web/serve-app.ts @@ -0,0 +1,9 @@ +import * as path from 'path'; +import * as express from 'express'; +import * as ms from 'ms'; + +export default (name: string) => (req: express.Request, res: express.Response) => { + res.sendFile(path.resolve(`${__dirname}/app/${name}/view.html`), { + maxAge: ms('7 days') + }); +}; diff --git a/src/web/server.ts b/src/web/server.ts new file mode 100644 index 0000000000..d30680f68c --- /dev/null +++ b/src/web/server.ts @@ -0,0 +1,77 @@ +/** + * Web Server + */ + +import * as ms from 'ms'; + +// express modules +import * as express from 'express'; +import * as bodyParser from 'body-parser'; +import * as favicon from 'serve-favicon'; +import * as compression from 'compression'; +const subdomain = require('subdomain'); +import serveApp from './serve-app'; + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); +app.set('view engine', 'pug'); + +app.use(bodyParser.urlencoded({ extended: true })); +app.use(compression()); + +/** + * Initialize requests + */ +app.use((req, res, next) => { + res.header('X-Frame-Options', 'DENY'); + next(); +}); + +/** + * Static resources + */ +app.use(favicon(`${__dirname}/resources/favicon.ico`)); +app.use(require('./manifest')); +app.use(require('./apple-touch-icon')); +app.use('/_/resources', express.static(`${__dirname}/resources`, { + maxAge: ms('7 days') +})); + +/** + * Common API + */ +app.get(/\/api:meta/, require('./meta')); +app.get(/\/api:url/, require('./service/url-preview')); +app.post(/\/api:rss/, require('./service/rss-proxy')); + +/** + * Subdomain + */ +app.use(subdomain({ + base: config.host, + prefix: '@' +})); + +/** + * Routing + */ + +app.use('/@/about/resources', express.static(`${__dirname}/about/resources`, { + maxAge: ms('7 days') +})); + +app.get('/@/about/:page(*)', (req, res) => { + res.render(`${__dirname}/about/pages/${req.params.page}`, { + path: req.params.page, + config: config + }); +}); + +app.get('/@/auth/*', serveApp('auth')); // authorize form +app.get('/@/dev/*', serveApp('dev')); // developer center +app.get('*', serveApp('client')); // client + +module.exports = app; diff --git a/src/web/service/proxy/proxy.ts b/src/web/service/proxy/proxy.ts new file mode 100644 index 0000000000..34c1deafad --- /dev/null +++ b/src/web/service/proxy/proxy.ts @@ -0,0 +1,30 @@ +import * as url from 'url'; +import * as express from 'express'; +import * as request from 'request'; + +module.exports = (req: express.Request, res: express.Response) => { + const _url = req.params.url; + + if (!_url) { + return; + } + + request({ + url: _url + url.parse(req.url, true).search, + encoding: null + }, (err, response, content) => { + if (err) { + console.error(err); + return; + } + + const contentType = response.headers['content-type']; + + if (/^text\//.test(contentType) || contentType === 'application/javascript') { + content = content.toString().replace(/http:\/\//g, `${config.secondary_scheme}://proxy.${config.secondary_host}/http://`); + } + + res.header('Content-Type', contentType); + res.send(content); + }); +}; diff --git a/src/web/service/proxy/server.ts b/src/web/service/proxy/server.ts new file mode 100644 index 0000000000..5b1b8d106c --- /dev/null +++ b/src/web/service/proxy/server.ts @@ -0,0 +1,17 @@ +/** + * Forward Proxy Service + */ + +import * as express from 'express'; +import * as cors from 'cors'; + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); +app.use(cors()); + +app.get('/:url(*)', require('./proxy')); + +module.exports = app; diff --git a/src/web/service/rss-proxy.ts b/src/web/service/rss-proxy.ts new file mode 100644 index 0000000000..8cc3711e70 --- /dev/null +++ b/src/web/service/rss-proxy.ts @@ -0,0 +1,16 @@ +import * as express from 'express'; +import * as request from 'request'; +const xml2json = require('xml2json'); + +module.exports = (req: express.Request, res: express.Response) => { + const url: string = req.body.url; + + request(url, (err, response, xml) => { + if (err) { + console.error(err); + return; + } + + res.send(xml2json.toJson(xml)); + }); +}; diff --git a/src/web/service/url-preview.ts b/src/web/service/url-preview.ts new file mode 100644 index 0000000000..d1a345ef17 --- /dev/null +++ b/src/web/service/url-preview.ts @@ -0,0 +1,13 @@ +import * as express from 'express'; +import summaly from 'summaly'; + +module.exports = async (req: express.Request, res: express.Response) => { + const summary = await summaly(req.query.url); + summary.icon = wrap(summary.icon); + summary.thumbnail = wrap(summary.thumbnail); + res.send(summary); +}; + +function wrap(url: string): string { + return `${config.proxy_url}/${url}`; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..9fa05af4e7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "noEmitOnError": false, + "noImplicitAny": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "experimentalDecorators": true, + "declaration": false, + "sourceMap": false, + "target": "es6", + "module": "commonjs", + "removeComments": false, + "noLib": false, + "outDir": "built", + "rootDir": "src" + }, + "compileOnSave": false, + "include": [ + "./node_modules/typescript/lib/lib.es6.d.ts", + "./src/**/*.ts", + "!./src/web/**/*.ts" + ] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000000..c1574ec1ce --- /dev/null +++ b/tslint.json @@ -0,0 +1,116 @@ +{ + "rules": { + // TypeScript Specific + "member-access": false, + "member-ordering": [true, + "static-before-instance", + "variables-before-functions" + ], + "no-any": false, + "no-inferrable-types": false, + "no-internal-module": false, + "no-namespace": false, + "no-reference": true, + "no-var-requires": false, + "only-arrow-functions": false, + "typedef": [true, + "call-signature", + "property-declaration" + ], + "typedef-whitespace": [true, { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + }], + + // Functionality + "ban": false, + "curly": false, + "forin": true, + "label-position": true, + "no-arg": true, + "no-bitwise": true, + "no-conditional-assignment": true, + "no-console": [true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-key": true, + "no-duplicate-variable": true, + "no-empty": true, + "no-eval": false, + "no-for-in-array": false, + "no-invalid-this": [true, "check-function-in-method"], + "no-null-keyword": false, + "no-shadowed-variable": false, + "no-string-literal": false, + "no-switch-case-fall-through": true, + "no-unreachable": true, + "no-unsafe-finally": true, + "no-unused-expression": true, + "no-unused-new": true, + "no-unused-variable": true, + "no-use-before-declare": true, + "no-var-keyword": true, + "radix": true, + "restrict-plus-operands": false, + "switch-default": false, + "triple-equals": [false, "allow-null-check", "allow-undefined-check"], + "use-isnan": true, + + // Maintainability + "eofline": true, + "indent": [true, "tabs"], + "linebreak-style": [true, "CRLF"], + "max-file-line-count": false, + "max-line-length": [true, 140], + "no-default-export": false, + "no-mergeable-namespace": true, + "no-require-imports": false, + "no-trailing-whitespace": true, + "object-literal-sort-keys": false, + "trailing-comma": true, + + // Style + "align": [true, + "parameters", + "statements" + ], + "arrow-parens": false, + "class-name": true, + "comment-format": false, + "interface-name": false, + "jsdoc-format": true, + "new-parens": true, + "no-angle-bracket-type-assertion": true, + "no-consecutive-blank-lines": true, + "no-constructor-vars": true, + "object-literal-key-quotes": false, + "one-line": [true, + "check-catch", + "check-finally", + "check-else", + "check-open-brace", + "check-whitespace" + ], + "one-variable-per-declaration": true, + "ordered-imports": false, + "quotemark": [true, "single", "avoid-escape"], + "semicolon": true, + "variable-name": false, + "whitespace": [true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ] + } +} diff --git a/update.sh b/update.sh new file mode 100644 index 0000000000..c48d20de68 --- /dev/null +++ b/update.sh @@ -0,0 +1,4 @@ +#!/bin/sh +git pull +npm install +npm run build |