commit b3f42e62af698a67c2250533c437569559f1fdf9 Author: syuilo Date: Thu Dec 29 07:49:51 2016 +0900 Initial commit :four_leaf_clover: 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/g, ''); + })) + /* + // 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 ", + "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 new file mode 100644 index 0000000000..1b1b1b3ecf Binary files /dev/null and b/resources/apple-touch-icon.png differ diff --git a/resources/favicon.ico b/resources/favicon.ico new file mode 100644 index 0000000000..4fd944c3da Binary files /dev/null and b/resources/favicon.ico differ diff --git a/resources/favicon/128.png b/resources/favicon/128.png new file mode 100644 index 0000000000..75b93cb1e1 Binary files /dev/null and b/resources/favicon/128.png differ diff --git a/resources/favicon/16.png b/resources/favicon/16.png new file mode 100644 index 0000000000..527ea7f41e Binary files /dev/null and b/resources/favicon/16.png differ diff --git a/resources/favicon/256.png b/resources/favicon/256.png new file mode 100644 index 0000000000..8e0077b4bf Binary files /dev/null and b/resources/favicon/256.png differ diff --git a/resources/favicon/32.png b/resources/favicon/32.png new file mode 100644 index 0000000000..2a40365456 Binary files /dev/null and b/resources/favicon/32.png differ diff --git a/resources/favicon/64.png b/resources/favicon/64.png new file mode 100644 index 0000000000..2dd9d71a81 Binary files /dev/null and b/resources/favicon/64.png differ 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 <>/OCGs[5 0 R 26 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream + + + + + application/pdf + + + icon + + + + + 2016-07-11T02:43:45+09:00 + 2016-07-11T02:43:45+09:00 + 2016-07-11T02:43:25+09:00 + Adobe Illustrator CS6 (Windows) + + + + 216 + 256 + JPEG + /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 + + + + + + uuid:577a9a77-0f3a-449e-a3e6-c1dc0aec6600 + xmp.did:638502CEC546E61188ECE0D1B3990392 + uuid:5D20892493BFDB11914A8590D31508C8 + proof:pdf + + uuid:4e413f3b-d2a3-475f-a59f-2bd2d181921e + xmp.did:972B0CA11846E611AAF7DAEC96866847 + uuid:5D20892493BFDB11914A8590D31508C8 + proof:pdf + + + + + saved + xmp.iid:972B0CA11846E611AAF7DAEC96866847 + 2016-07-10T06:03:47+09:00 + Adobe Illustrator CS6 (Windows) + / + + + saved + xmp.iid:638502CEC546E61188ECE0D1B3990392 + 2016-07-11T02:43:26+09:00 + Adobe Illustrator CS6 (Windows) + / + + + + + + Document + Print + + + False + False + 1 + + 256.000000 + 256.000000 + Pixels + + + + Magenta + Yellow + + + + + + 初期設定のスウォッチグループ + 0 + + + + ホワイト + RGB + PROCESS + 255 + 255 + 255 + + + ブラック + RGB + PROCESS + 37 + 30 + 28 + + + CMYK レッド + RGB + PROCESS + 196 + 0 + 24 + + + CMYK イエロー + RGB + PROCESS + 255 + 239 + 0 + + + CMYK グリーン + RGB + PROCESS + 0 + 151 + 75 + + + CMYK シアン + RGB + PROCESS + 0 + 158 + 229 + + + CMYK ブルー + RGB + PROCESS + 35 + 37 + 131 + + + CMYK マゼンタ + RGB + PROCESS + 193 + 0 + 123 + + + C=15 M=100 Y=90 K=10 + RGB + PROCESS + 166 + 21 + 39 + + + C=0 M=90 Y=85 K=0 + RGB + PROCESS + 199 + 58 + 45 + + + C=0 M=80 Y=95 K=0 + RGB + PROCESS + 203 + 85 + 32 + + + C=0 M=50 Y=100 K=0 + RGB + PROCESS + 220 + 149 + 15 + + + C=0 M=35 Y=85 K=0 + RGB + PROCESS + 230 + 180 + 62 + + + C=5 M=0 Y=90 K=0 + RGB + PROCESS + 246 + 236 + 53 + + + C=20 M=0 Y=100 K=0 + RGB + PROCESS + 218 + 222 + 26 + + + C=50 M=0 Y=100 K=0 + RGB + PROCESS + 157 + 193 + 56 + + + C=75 M=0 Y=100 K=0 + RGB + PROCESS + 99 + 169 + 69 + + + C=85 M=10 Y=100 K=10 + RGB + PROCESS + 68 + 143 + 67 + + + C=90 M=30 Y=95 K=30 + RGB + PROCESS + 46 + 105 + 58 + + + C=75 M=0 Y=75 K=0 + RGB + PROCESS + 98 + 171 + 107 + + + C=80 M=10 Y=45 K=0 + RGB + PROCESS + 81 + 160 + 152 + + + C=70 M=15 Y=0 K=0 + RGB + PROCESS + 100 + 165 + 221 + + + C=85 M=50 Y=0 K=0 + RGB + PROCESS + 62 + 110 + 179 + + + C=100 M=95 Y=5 K=0 + RGB + PROCESS + 35 + 45 + 131 + + + C=100 M=100 Y=25 K=25 + RGB + PROCESS + 30 + 33 + 96 + + + C=75 M=100 Y=0 K=0 + RGB + PROCESS + 83 + 31 + 129 + + + C=50 M=100 Y=0 K=0 + RGB + PROCESS + 124 + 15 + 128 + + + C=35 M=100 Y=35 K=10 + RGB + PROCESS + 139 + 19 + 92 + + + C=10 M=100 Y=50 K=0 + RGB + PROCESS + 183 + 0 + 80 + + + C=0 M=95 Y=20 K=0 + RGB + PROCESS + 196 + 26 + 112 + + + C=25 M=25 Y=40 K=0 + RGB + PROCESS + 195 + 186 + 155 + + + C=40 M=45 Y=50 K=5 + RGB + PROCESS + 155 + 137 + 120 + + + C=50 M=50 Y=60 K=25 + RGB + PROCESS + 116 + 105 + 87 + + + C=55 M=60 Y=65 K=40 + RGB + PROCESS + 92 + 78 + 66 + + + C=25 M=40 Y=65 K=0 + RGB + PROCESS + 188 + 157 + 102 + + + C=30 M=50 Y=75 K=10 + RGB + PROCESS + 164 + 128 + 75 + + + C=35 M=60 Y=80 K=25 + RGB + PROCESS + 134 + 97 + 58 + + + C=40 M=65 Y=90 K=35 + RGB + PROCESS + 116 + 80 + 40 + + + C=40 M=70 Y=100 K=50 + RGB + PROCESS + 95 + 59 + 19 + + + C=50 M=70 Y=80 K=70 + RGB + PROCESS + 60 + 38 + 23 + + + + + + グレー + 1 + + + + C=0 M=0 Y=0 K=100 + RGB + PROCESS + 37 + 30 + 28 + + + C=0 M=0 Y=0 K=90 + RGB + PROCESS + 63 + 60 + 60 + + + C=0 M=0 Y=0 K=80 + RGB + PROCESS + 89 + 88 + 88 + + + C=0 M=0 Y=0 K=70 + RGB + PROCESS + 112 + 112 + 112 + + + C=0 M=0 Y=0 K=60 + RGB + PROCESS + 134 + 134 + 135 + + + C=0 M=0 Y=0 K=50 + RGB + PROCESS + 157 + 157 + 157 + + + C=0 M=0 Y=0 K=40 + RGB + PROCESS + 179 + 179 + 179 + + + C=0 M=0 Y=0 K=30 + RGB + PROCESS + 199 + 200 + 200 + + + C=0 M=0 Y=0 K=20 + RGB + PROCESS + 219 + 219 + 219 + + + C=0 M=0 Y=0 K=10 + RGB + PROCESS + 238 + 238 + 238 + + + C=0 M=0 Y=0 K=5 + RGB + PROCESS + 247 + 247 + 247 + + + + + + 輝き + 1 + + + + C=0 M=100 Y=100 K=0 + RGB + PROCESS + 196 + 0 + 24 + + + C=0 M=75 Y=100 K=0 + RGB + PROCESS + 206 + 96 + 22 + + + C=0 M=10 Y=95 K=0 + RGB + PROCESS + 247 + 224 + 25 + + + C=85 M=10 Y=100 K=0 + RGB + PROCESS + 73 + 152 + 71 + + + C=100 M=90 Y=0 K=0 + RGB + PROCESS + 33 + 52 + 139 + + + C=60 M=90 Y=0 K=0 + RGB + PROCESS + 110 + 52 + 137 + + + + + + + + + Adobe PDF library 10.01 + + + + + + + + + + + + + + + + + + + + + + + + + +endstream endobj 3 0 obj <> endobj 7 0 obj <>/Resources<>/ExtGState<>/Properties<>>>/Thumb 32 0 R/TrimBox[0.0 0.0 256.0 256.0]/Type/Page>> endobj 28 0 obj <>stream +HLQN0 +I ɕ8vW8 $-kD=vSp>F8@׿::[|^Oq= c~߿qEȟ\"$%t:Nwmܐ|X6FmuU$T-*T8 PEM D[JFrʢd\$A$.EKX"m2{ Z]yW3<p`iU +endstream endobj 32 0 obj <>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 <>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#soRZ7Dl%MLY\.?d>Mn +6%Q2oYfNRF$$+ON<+]RUJmC0InDZ4OTs0S!saG>GGKUlQ*Q?45:CI&4J'_2j$XKrcYp0n+Xl_nU*O( +l[$6Nn+Z_Nq0]s7hs]`XX1nZ8&94a\~> +endstream endobj 26 0 obj <> endobj 35 0 obj [/View/Design] endobj 36 0 obj <>>> endobj 31 0 obj <> endobj 30 0 obj [/ICCBased 37 0 R] endobj 37 0 obj <>stream +Hb``2ptqre``+) +rwRR`?> v^~^*vD_)p%?@lZhdg"I`($>dCW@$ ]>faˀ% { *23J ---SR+KRs< +KRSj!ABPi5Zho@p2A!@riQdL0cR?1^: Sbj  O +endstream endobj 29 0 obj <> endobj 38 0 obj <> endobj 39 0 obj <>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 <>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 <>stream +%AI12_CompressedDataxKeYv6/Cx@[p~6 W[&$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{1_yP7kOs_Ϋʳ+,x[y_Ís.=BOc3~/^DK>7>mt)/e)/L޼y=O`}\Q~i7/QIDg_D[}G_}_ɫ?{|.G~ ķ?.OQwC#';.%oˏO1,сRqk?қ?yw ?,W?g/?G^^>{}?l]Wbs\䙌F2<ǜLzԥӛ_~湿&M?}j/?~\v=䣽yog|Wz;^}ԗ_.[}cnjn_/ŧ?~v7.7yu>4~.\!rO-[7^ȶw}_7O^g_<D/?/<K"?dy79M0 ?_n/C~g/D w_| +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 ,x 2262i_27ܬL=VG9J;I+w" oK]c.ߝYeK}xwuJ{^{~efu2iV.VEْI-jLU&9A E޸ee.* n/( 崻dYI^Oe\eUeqeɲwߗV\K{cNXQ^YB!\{2_^WtYP!CdO"p&CY?|Anr^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(^Н4i g $'^e"/^(\\Fȍ7# Sc 읲2^:9e˩c΀s%cOG:33"3^q$еUteNýI_*i;Yv4nstּК?Zkjmgmo_R&vmo-XRoٚiV?;mw5axkZފZ&=]_mIBw6UXLwEwXd:j+Yn!~*Ӊ +jɞVW0mB01Į1lu5@g86zޭl~9,5͵S_aD7:_,ѹfD}63_HSƑM_%&! +ȲE2l4B0/KV OԽ FWhCrݣ\{xsDfF2ͲVy4ҙ6`pPMskgGcm16>2>Gύ ,ldq;M+"t,Ի|[l,VatưE*8ַr:ur|viJStӖ2-ұRD (}9sAw1J׻u.SsSԨyu?IrQtp#I.9_./ds\tsIiޞ&e4mJTME?Fc]RD: uC;RQ (yM &[/@[k]wt 4їS\ _w]:_SXz!:TQ{[-΄]v.չzuU 彻zo$%^GR3"¿y ]?py h8E!v'rG{@0QxVXh Pbb A%%+Œ&@cj(L8x"t]Ϡ?egeڪ;C? \:o&a<L][޶Z qԫ=H,QO+n1aSTGmq,J0&M ؃%a\G'LXة aU+{^v~v4!d5S=o P2iUP֢!)f8GhV،N6dLMz\>f㧢q:~{tԡ Ȟ4鱍#sC#5Jm,=l?zΙ8a)4Z8tNmLeM^fcm8vښnՐqRʸQ֑n-6P8Л _:NgL)mp;W 8G1bvf7~A ![bB/ z0vW`\/FuA|Iy“()Dd E)ɒ%mJTڗImW}4U!% +d*QiWbV4&)Q2 e08]M <ɜi՜cCMm?r4#D_3% ӷ;]zz<^Oflmܧ +se㏮ô5 JD˗Z8cGɾ8BjVSqTZmJ?=A֟71֭@3%ۡYom~on~{hhgHa~+qntW& pB[@'}|~|0s/F9g0xؘCY!WP&3?SXIjXT%jƌgvV PEp5 + ĸ@Q)׌k2i8F3 u'"qx lL21h Y:8" ʩ):xc(p@NcqgCZ*L}/wUjUGՋ;Q傩6<i%},W(ۙ-ވ}d&xPՊ|0ԟA= 5wk; mJΰۯfOƭ V{ܽ;" VA{Q +52] $kpeinj+B0>(iC*CXwB-ůe.ч-ŷm&[3H>qS3$QxyXW75G{ Go=BIyYal +캞";Z,b~EΤuUJWv#K?eRU6W3UbU|O +Wۼ'{'3^8yxQ)7@&%KKz[41ݟp Z0\kMgef:)@SB.aUɠ{5 `Byxɦ lw\ 1*4w+lC? D͝!L lSԀЄU^v ,Sk^uA -weAz=mp|uEj3&\pyw QR]5JtnsӢĹػŔ)ӻq!52&pl(\Vh['#d[0RV:Ä9#kk{jw;]}*k{2kAs P6t22` n5@mk׸5ɶ7Xڌ>t2h~wǭq.}UD$Ba(;X4Dd'E&]DDQ=&-`qep}w7|?yam\5O+CG`;&2𐁆l8 4 [$d`!Li")jQvNRek[ =M[v1ӕvܶ?~#(g#s%Z^fy(Q<"w`ZO@,+'N\! \hI3x.qC?m-[-hB8dmbhꌭ6d9x{;jl-F g(k^i'weLBzisG]C̈́ÜhGCk=~ۨ>ig8_]^5ǫs׫cW^CzUs8_5Smb/r@f{s\0Â9lÊ)\㏦$͚l'ImQvݷ&OSU;W{ϐsN]kp^g-]x1&]k{5F:۾ ({zXk6%d":% {[ͼcY $)jM1O՟ MLgnK9w?qgnY?7o|?7of&NjyU֍HEdIkuxzGK`t one?w檣8s0 忳dOlج qu!\]7<ғ' `MMrwS?IG6СoBátYR- 8 + 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|pZ 0ݦ\߯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"QGX$SvVJU`U~a]XһRX+sOC͝P=&iaBsLD9I TvjH@h?Z^1*#9MKvi04FIKy\~wi[ `ODWfX%9{lK1kqy`-. TћH}ZRY-KQM@'ӻ0rh߾j-~0e9v&(sgJdł58۹3=Y1t58ۮ}1Ry8#|O=ߓϷm*mɧ UVZ8,;+6Jo])G(̤̕%&3*>Ll2o$(~6D?+~lM#TLL.Qpox2xh|##|Rdvb<5 p7kבּ+E3E3[bpF=|S-H}؛v4N7妢FYҚ646f.mæ]zf\j=D/5ٖ>~.\WK!8T|}mWai-op^E.rlF~F4.I3yW3 yE#ڼh֭g<Җ-k\Z;EE%vm2p溩ZGXsmʫ\>zټre?%A.5Qwx2օkg4,kNgϋ69T5FUFƨi}:ҽݟAAW+ʠ )!:Zde-R["b o$K] Ms}{udXnU6-&p*4U7|֓tzxn<IԽM8K&ЀEQ7ʍ(zА>"5G=Ų鴪s1:t'WN{HVbrcj<̈́nՊ|gwFx ZL?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+ϹϮ˖{_-DA$5ڴ\~Suش{yзc @&yEO^(( 6w$QŴSI1җ>hV;}d,ȁձku2J#1A@!ʇ\*r|ȥ!ʇ\*r|ȥ!ʇ\*r|ȥ!ʇ\*rRlb3 }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-QP k/xG5gsC8A\@ߏ&Ʉz̈ sj>Bv4}.*X(&WscG|m lƘAlrW~Q?xm2fQ敕}ui337ǭLVq2 E>R }po8Cf^͉m5pLYp lV f;;oiV;]5OK<: % e(Ӝ(HUџ'Pӑ(>{_5zJz:o[zpCuwǡt0(@O& +4dQ[:=;%c$jyBE?ɒ6K~v;_[d4RFF9/䎿#;lL=ZV_))Zǧ0mY*WIzil_]:۶z.i zǧ,we4C&Jf&"}3, +eEǮ n֮PƬ\>9}S5}Sɪ=хTG_#Yӽ)k*<)G'kd<޻qZGYHHLd=#Ґ!YdbD0\`ya&="V=:Q)H[U.BkpYX f^홬:mvmnU;ZNg>-2 ZX`#}2\{XV1L , +,6Ȑ3iQ]c +׫\aǵZܞMqcX{?j"ʺZ0re\j5RĮ@-;E7bUN@%9Oۜ/ 6g^?䬇"'or|-z8?dPy +s{VuJ8O8#ٓŞmPQ 6宼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ۮ҂aZp0 fLAgT>lrlOl~5fL|?Zri5@堀 Yh-%fSMWd@I;gnd91|vJ߾N>uS?Z-PŇoo]/|0T1/< k2B_1Z`#F}Ǎb߹ɵb&H~u4sof +}ɄCņA)20+30="KԷ\L.#FU&#sS]05w^|ɺ^J__˷?yNUG<_3y rW~a#BoXՏPiNnZ7E+.Uߩ+Eg߷w P>W/7/^?w֗[пٛ7_$?} + ۿ +pAnuUƭ1Td P֛RtJ9~~M ?k泏yq@V /.x O6Cv~sŧo^z_P>s7^'^yOO~?O|? +~[=RV|)5yũ&E\-ԴoCqN_ AsQEA1JBr 6|Vo苷|]nOUxM .A^cоेxDaRbUN)B"EnN/ Q" +D ReIPdV/WrN,r2nc2' U#ʜ**"kG=3rjlX|Ѻf88F[L+o 2te%{yDš@E֓RDg&2Q )Iv6+znA Qք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;.*11uiVUm Vrj@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~i"SNm[qê%"U,^Pl'(m\gE].q B]$#Da^z*|0N'uMP + r`9;n7[R(⃥IX8niV$vbJDO"d(&eڈ+. ly%I zUG\޴(Ǿ>L^&5&r9q=\ʼ=F(+ YO<fe` w+ڐ@UZ~dBG/,{7h0˕!}wʧu Ӂ28ʣ"71G+ A-`-ZI +䲬z܊u%h OAzҧȋQA0tW-n48/ϔWTUFON&7H*aD$`^BLc':cNa Maϔ̟"7{ +5m"oH[A8B= P"N[ΓL*Rb.(~*>n &JlW%R?+M +Q$.msk3k$;6[Xm,8ΖUE# +LqŲ"eE@'[3uև1,U$|!X^wXr~.r\S3*uht=]<jqd=s b2aE'I55ޮi.9XS5?`~Z:I!YZ2Y:K<5wވE/޼~7w~?>4GQs,D_4.WeZh6tI$wSw3!IPYʔWLJl ῖaӋ>w*Y̰'`D\ZUIw*`LfU7* }XEI&*hsiE v!M2.CQ\BgCwxIWr0E/YWjƠ'LTk\`'V vWEB2$JIנ cB)wW 0h;MΔ丐QdR<]y*@Dx]v + +uqVJƢ6 y )L1 T>pp{P(tڋ'jYA +QCщ𝤹ͣOOFVi;+} *pu=d1@B62Xrl")18͕OtwFAL*]msvU=$/(FXu:s)&d/UTkBGeONe-;Qc5thQo95L sHQ19'k:&c} v9.DZM.aƞ KC=m7 k̐ #,wX] !?TD~QR5 Z2OAPע.0<1uOof#ܙA{e8o>0몧De͘bn7/*l9i;\mv=&m@Pͨ9 ІZA]=7tDb5ߏcfu1wJ^+,|~A"z[A`ugEGxP1*tJ-< Wf{% WNqWe ^~Hzo0`Up Q`u=A*=j_#b; oLfRrN%pCMPDl:DȀ^)ɹlNeRp*:2g*'K?ߪ|'@X A( U7p ++&̡i=GAA`#4*P! %a "<٨ +^M )7T,lVi#%tJs7nnU4ʣ3Dx+ )` +,yABsLZ1 ѡ'y̓Zu2F(=-&§P(zȕ൴TE!a"4*I_'{ chJ/a2EWڝ s ک;DQtWt0eƎ֩C&[鳪L5 8c2o20{,VGvP;GA8ޫ.Wys7YQW;SD@C- NT؍Ss;/j4-1q8xl [+7]sW.>1c %d*$R!|ToVU%xnyP8-ˠ, S%EK>BF-T1Y 37,&yh+@ʰ56)D(`qr/#.4.,DتY _z`>( >KȣA]0 ]kV~H}*ɜmQ PusomZ0+i t̮kU1f,0{4Ȍ$%q\EP138me+XiG%"]:LWi + z4a'_ PiEJj dX& 3EچvwRa(b$}` 1pTJ=T̫7|>zo(IЮ + & I]. 9*>t{]*H\KL1(lbQb_5d5KT ++E1Svͬ$lTՈ`A2 : )Sl0P1%ƩLoPz"G_諄5Yb^rզ@ +X9)U%D6]EB/dpp,riP㈽>È6R +22:0upOcn+^#R*z jAs*0(*06cP?R/kbmXDTӀŤJ}3| Bڪ4,*B9dd ǰ䄮cw%L̯N wM% zGO2fO)~NWȘLIVyETY)ʿq&YlӡŎb--\ c, s"D04l7`"AD;4dӥ|K3c3sgNtB6yjXAHbZ`XpKCfٺ +(Š.?hv^_'E./(ݘ!0ւW4 BVsVm CʭCp-FMn'A%RP:P:5=Uo GG1$ӔE${e_4.FV\j޷uHBD!L f+(4*#1d.G Ad@4l d- Ԧs!2`.d 'x א)u'op_# VgUJ&1~gq D/s$?bj>3XK#}(j PoaBDW\TB"<,]J=19tkQX3 QI;Q.PT|2B,YςbN;qeLHƾm֊=:rA2d@-BZav6+goT|/Wq8Zy}pȎ2]5*ˆU0뇁eUHnL PA6Щ0;egL&Bb؈bCK窂:=UcKJx,M.3[s*ein+z8>ĦRT%[/9bFg#,i RUJbz60.>cGN 뒥k됝Fݜ%͓BW^a )z31fz7W:n`"E6FMӵ9KSjʠ"$jYe% %3P~2nTgY +^Sd@@<3|6 ҁh>+Yө0f(+%>POp-E(bk1p"l;1x#9gp , +f5qЍp"ue !,Zahl\Ш̜JJ'@2 p:= ow\qF i„dy|߭HX4h9Za^[>[J `ZJ05QHcH?%Nz<Y5tJS0Zeno02ښqHI<ض 74'g+Ş4j>0A Tq%NdC[w1IdӉ9 /b@RBoFlY`7~lQ`q r(\%k`@F~b {og ;aǁKKA۲nMav?̢7f`Mphd0tl9/<DׄM7'y"wjӇ)BuAX#E4t2Da;DOIt^LHydP>?|\4@zs`L'&w̢l9SVE?m; dI([h24/l#@R- 2&9(-&s! f}bht=^5c%8yqXˤP򈦱l-od6b+& US6 +˚+5E3i9_`12^^j B +O_` :=eRy(9;07JiQcggQ7:!Ep#qzqh4xKMRτ.Ga/wi:UP?BFS/z B+Դ)YfS]Z 4 >,]7 XxT Pt(n&O5c H:r]41+.@lWaX'—2)85=CuB&D3ojp}C@b!y2@NҲvLI/03OU/55 s%P5UF{k00ƹגcӔA +t1>ì8oѰb;}T$b6EZ@ļeD^d34c@;lMп+sX!= ]²sY`f>Fȑq7! Diahkҭc)s'RSryJOU;BFk~krY 4nѧҖAиhFd82SMfU 5ϪNT7j8~t&XWwFXb5j:0el`6r: -3< t>hhLkR.T#htvҰfle6(ut"\V.)<I)%!$5¤SҴГՂcbNF (vVB "C/c wg:>:T ~엚/7Eᾍ>v4bt! Dp[V'Գlt7ZȡWm}3kT0;^L!ީgx +"ǒfU]bJP٘аf+d Cj\K8!ouzzw +~鬬~7/z܏^˿՟܏^KS~<]q#\&ݹh(5@57 -Fg%ljyLբՎG:ůgE<]v"uF:=Tz:PP[|TE6pV'$ȣJ+ըЅyVNKy׈%Ris 4i:0ȥ3VJR6߫]0ǚWWPdU OXuJ]QJ]`R7UHՍ*u1*u~ԡU Yr]L(L] 2uM2u"!$) %Q.0.Q.Bm:Rs]G:R"xVtVP< +Ձ"7BuXYȎd Aiz TF!=Qp.m ՁQaV_}VUvT"[]T'R~V(G:ޖg.E1*y#C+y~ÌT*Ցhe:j> !mKՁ*"@JՑ0~s5 Tm<9^hETGHaJyfk0 bu^Zu.-,']J/V-tkUeݐPߋu\[:k\j$tmq>02UM?n[uJV sko%XZu,b!wju¦V]ZuLVbEsm(8c/U Rujں^um@GexdzvfK֪#51評':CZlkՁZu ȟCUVn+{:V^cUtr亭U']C/V7sA5>0B4^7c^02V8 AXq)VשX]`,V0aעMNm n+CZm,!NTWFeSY6isXU!EbuqwZXlJ`3/k/VB]XM:10=fEjhUQjVR`-`QבlaU7V.ЎS/V!s[7ҊՁ Z:=^RCxZ! ;3׫>Y:!3aWש^]J35(ru'2n:ԁꘫjmX:6 @Z,=aHՄ͢UBoV.G^.jua)ju(- 9VySN$5dGeru,cМ[\~\MruJa[ y/WGV\t{:򪑽0A lޔ y$Y\t +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^Ⱥ<CH ps:mFIGU0{;iruޔ\IOpI ygN2סO:If:}Z")DOa2YMSϒia9up~fd7.on:ugɬr0b,PCͬs|:B4'7wHj\̺\̺-N#yh1se޻uY-uH d"![>uFj MP}#D܊"Y#vVPR!G-B-Ցu4GiqdPЮ8gޑX#f +o:T{!U~uE <'iYNW\MC&U:)G:)y8n#븦s΍Rg YG$)ݔN&X\:ES}# +5V:|A)\x( Y̅SӍ<ف9^h. .d)YG@M k%&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 :cBIB! d +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)!h jRYBEPp$LVP""RMek +Nڍ>uR>C2c.hI<*S:Ն^:ňvw7uʽüu)j*b+umiڅSʅ4u +S*n n]fW?:BO`ygk/m\0q딹!:R}UZم#b;e6YRu x]W)"Zu4SPӺ`2oxNc8 xQ[ [uR%@CէڥS#huz^gͶvz>o]6z]Lz. c՞,vn{J `.VRWD;sՋ.b,~>%uNK)̝: `G <+pN3N}.g/ Z6'N;T2 +c^ ;Tar`e +[vRa9 O[{% toŰcUʋ;.Q[b)CbP %v`9N񀧀:v~o{BŰbe߅R3!;R*vZ`GIS\{]"q;z~.]xt#6|쬁n,~~[feֿKp눚4.ίTkii_GeT ` ni`[;[@kavX;--~t`z` #vLsL_ס | +I\酯K-_IN3lMuT%L|Yso>:dX_'uq~߅7Nj Mb$k: N|mPX+ZZZ 4딁b|Jхg:/ `g26Eqc6NΝ6E`G `W@SZs ׉}*+)5>Bn]a=7ؑfvX{7N=+=:`Gm|&s&ة.E+r޹ v\t=gj^;`sؚ!vR2ehNoK`GKS`[t"ȧ޶vo}!,;=V;%hB;vc;&;NQؙ> ;%3fA}bG7)mm@$Nyߪurjg.W+܉](; .*5*Nu$e_7ES-Ї +]at&iI($;e.Xt7E" e*49N '.DbY> +M*;AvJ\  +9ߏ}oQPO9v3pӇLs^?iWmtrK:NI-A:j8vNBOJE3j;d/f +P46G׶(@vZ3tN0OE.FEdNإZUkڻ'Vx!6N,DT'?Gʥdinuۊ1n 'HmN:H dE3UOoO" '!4[-+EK쪼]Mu:NL/d>IvK P\ ;,կ  jZPJtH$; p).xǎsf99v)/ c'QKű}CCptH:.NT,$Ow}o.2%AbAsP&6Ɏ(lc.]&u_")FG/HvLԙs0)wW;Ɏm$;4TiݯׯdGQ1ASEzX^\$;d dGzFdGj .j`nf;9JAvJ(; X;85c*Փcr9Un91v꥝V;2NJb3va쐩` +ScY:hsɕ诃/{d~Hqe.'Nq*@vXnAvX}Ȯ`vIa xqg ;vɫ9>;T>7NѸ8v/66NUb-+ +E 0`:$BZ"f;/Dg~4ZůCVסP/:)/~:V(m0z5`Wx`}>q;cӛzr{g.nցr쨞m]*NZIZqhG)by v + +5d ea~# +S;6tT&17űCNm N5] ;"D"de옧Pv +ZWJexee'r(b٩0TG儩лQvgçFIV@Iv2Yw|Pv1tio9ލ1XvV%be ]ŲFb٩5o]Iɲ؆,~P#H XAN* +JeǛRzTʔC+\0;G0l:[t(DR>S񤕈^4;\&1nfHp^0e# <fgJYvK 4deb%+(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 5z f,/؆vo!5 VaS4;3&hvf1hv29Yv?MŲ3(7rJV8l1@G;N}Saٙ\Ct~KaN m +>mPNّ/avFu촰[{Ttc^+a6XݪndNJ1`vl-pv;8Kc3[t.# Nm@ fi#e/`vRls0\0;\7R2þfGEReWudU rj, 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ڢ +y rPN9ɳ.Qr|,A<^Q`Otd:aֵ<;N{?:xvquXj@@Sy!xOn7]c.Rq<:O@{M{gWhla+i!aMS3Ю*RR$PuY")J|ӊ~ pD;*yTgD;ohG}`n@s CoD;^HcOV ESĹ#v>)D-D:Ѯz{B)} 9b +T@y#P_pNs{ X+Zhb +*t7ϞpUiS UO0wkFm`,E֠qvyxnTMDhhS9܅Sa6ΎF4;u{T +kWNSTd):J/ffObVS)gG'UvӶ[3"ٍbuN%YVCT4)Ԅe7e^Z w7NDo]4x 2VV{7nz"رn`C.~,2U^Hl~] o~]V7|ˆA}Ic봬u +uN}{pT]vë +t[8tK <]nسsnv̒{6=lE7u| tFЩ~i/.Fe/]qş+FbM+M+Z@ʕl]7z4PeF]9u,PNXsi`p"cQ +z:Dž܂Α.%+:g_y7tR?9=tΞnIPtn8.*m/`'[7t;t;(sJayAP,:RfI9ɿUq9)sTU'sN}*s.aۆyIOs>aE 9LiŜKĹM@%*#BizRPJӸ9:Qx/9r؜{p9M:hENo9e +\{Q09҈9Tr˖"-jl T8=-.XBs.>r9ZN_`S|+ËձriIT{mҺ[SN4 .\oQN[T Ʌ89mC +L܈_ɩOM!^^@I.ɩ +lҢ"G}{DNAb>.)i1zZ άK/INJ U9MCƂ?rR'AK8K7AN"MaAC9MB.j(}!4vf ٞTyOp'9 e]8ZMP IO !G'B/giI7MҊ*NL Z/\sGT|7@M gl8A!=&ȡ>iԼr ۜ~ Z [bl^\8倇. SһzTS꾲6/ +(A1uV-.|q-8A[E[j䰊)]ldFŎuRjBn Bnc.2\bX gɬ0fW:&,I7@U_̑J|C.:aӛ߱r5d89zrV: +0+Ux2pzZ{qul>ǁ`#KAm8]tV0]Mǭ.~RW 01Bz V?49 ~N~R#x$vq KjPkJGK rxE NӀR$p’AݒW04jG= Q }>&QeI(`(y;p8^+OIA 8dDf Cc[+o~s.zRJ*ypY/ypR<8!g(|$ VȊ-QFul8n 7n7M|Z`@דǁ +4wYQ@6zQ /eqnܖg!HX\ƃÒMG,.xpX8KKWk6^8\߹ypi볗̓Լyp[xp[RƼ!Ī!%d4f&2M #T: ' NҜ G S3>$8{{$8)D gESc4WV$m9Hpr.o[K ޟ{.TՒr IJ/ +I~nD%piBHinx“0>o8G0Y-췥Mi-KM{٭hu(!̐74koMnT~"yƒw7 o |R[8o[4ޛ7n aFz +zbO{2 F%cO#Imd-5PoGW{0 @Z\-MO0+HoŘ.iBdAzS΅z0YRS'I VR&Fg0?buɅ\:!zÕY3B5M" Pkpc8PazàV])7鍻qа7 < IzCyphlЛ7+:gݠ7)dFzӽUF b '1=oQf2a2) @oa!@ok#AoAo:5cS'Ϳ@o3e@tJ&Py#ېܜ7+}q޲6@ϒS5@Rb@ot1gY %swp2)>?7uI[RpVj~\7ذunЛd3&4a2a]7 zC!$[7ڵYhMH/ЛBX >&)_&~ހXėb7ލzST,U[f t gt4֞熹7MMi3y?7lBhqeLJFvp1 [IzSTe"Qҋ-0iPo6փ([Sެ?=e֛tt{a7Amāz@($Po4; +* "zɅzc՝7]"Azr[mԛz@1U44Po {\oԛ0(w䨷D?o"Q鍿7Nn"iaZYK~(:ˇ&YFQa$(o +2\%Ӌ&o/!iM64oI?9M=M٫1Ǧ}:vv֍vC:;%uX`7n[,[ nر!6}nr꟬&<)Ɨn ["]@7 H@7 UnAЍ* F{@P-y FLcI\PF;๡^k ~Gc87JT877΍p#sf3zܰHg( + +[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_6Q1 FuDRO:mံeR6w >:k}Y-9 +ols: F3/m ƇͦԛMÓl9̆GR5mgnPٴT傲DVgi''5l()~@o V86%AQ#^_BcLuʦ.'bދ:3+0l;`9M܄fMaOR@?}1 6?MŶɅ .0Z_{զ]5mk?k$žqytU$8ztԯkfe(];>k5*e`5pqUDv)rͪb'nM!.%S*#톭UjT԰d^pڊ&05}K"L-?H[052,NRmE,7o0(XjTS,5} KM)2K-3Vus[h^瓥f\;Aں۝ +B%7;tNʐ`fHYaej3C;^H\Sy 5?Ix\5CGWGwHM؁Wlt7{'ѕ78IjJ + bRnqqԔ%{`ԸFDڋ.0jJ F+U:MQ٠a+0j)+8jڛ X5i-9j)56o[DgR'|0$5Nڻ +-h1/&q<TG}C?F@v0&Q%25AP#Ƹ j6AP@>jސ-B ~->&K}C UYQCFU̡6D QUAQuP$<59DM׍kѠBV?qMp;FC ; +pڟ6}j 5v/ .a`1Z9c7TBI;BX54STݯh(1fTBMT'j +5m)2 ].F# B5ҚO^ 5;D#FB:em7g5][;9Ek(i!'EMe*(Ɓ\|=uS$0M};>!D QӖ]‚i-]qae!j󖓶Fk"^o)H"5d&݂IrShBg5#E-vPc|5J"H-:.ZeY5U͂vȞ#l lC|ԴḆfBԶ45mAS8mxԐyBQzԤ7hj ,J05kyg#,-Phjr`l9hjzS@ _<K b`0EXH;Km KmR>qܢ4-=nиPjVS-Y~yf^nbpJ-?]NRK)iԈ S;$5tӛkNIM ]&Z.1׎nAQ#MQ,Ua{( j&Qwy1jhHRhBEݩnZsPc?SOÓltZ/OknxH'G^98 ..MJOf`ԳkoP`@svBd Q4 Ih/gnb7v{~^gPi"-ISn\+sCnH5J m%J!/@gyF"gr RT£AhNt G[bTB/hgh4*kd4"vNFo4X:Asz|*Yh5J{p 4mu+n ߒ:qQЖpQЖ4:_1N 4R}v]m+dÔ΋frذ@>. B2<h2mϰy`c?}R6-g:ŒtO%_,.VOR[Ǝ<‰<*k lx&s`Z.ޙ>ôJs*W{ +5U߬3 asF_.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`\`3w5 6ÇwlF}f %i\d3\f\d3lѳylӠqm&UE~A6Ӧ$f"ѢQǿcʹɾfR N + .*38OoXiiR0 flsh/ X",12[?"iVHdF\fK D]YB7  x(|'lN5Sj4KN8filgXW9fa!8fz6LͳD3Kqc2-؀_@=$JY͖r͖D3{@35/݆# Ֆ́f +Rۦ1#g@3c hfj) 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#9Q gp5: BYX…3̨۪Qkxi|ŌZzg&toV_P옳sdu~Xߝ,ZC w@}FKK*s~Wƣ|]qOBoXv:ps`UF|U2Pvm4nY)l&Z} +]*z(%9g.$o{anܕ*c灡v+:)3:DkiULwZBB xI5JEDʵĘB(crܙ{jN 10yAyq0ADjm9"R} ǙM$ T<K2+BPlY +@%"Jլ6'VS8H>pپçIZq0%BJcB(F{l)NJ7c0B7UGEŬԬbEP?E}mYQ* s47W489PAN۲V&U1/ժ4-ߵq`'F/ƈZ-ukiט>lͅI?2YUTf-U^fwc,dSm*f**SMd)v+;[%Yh:e辕B +,U9Hwf5c1 ?-(!ltwoW7vJfAǽti6 n,{nrz85pl yyZ5V/@V=f +}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 7Ӯĺ7U '{۰b)ְ;;:Y +Ƌ-'#}ogU8V?rT{2N:|,Hen>K8nBkZՐG:1P~ +R{)ZIHe%$۰9E9))PP"IUxZLJVrfy1ekҭq+J+d)zjq'b/NMgtS!Sۓc9(jVKf,TұܖRwS+ RMI6<b%#'ہpI5S|a1d#p;SքeGOMpR +糕['6z쑨Zi<_ʿc~m RJb̊2*=g3(,TNvrpz M HƆ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 Ī,5RbLxJ,>zQ4z't/У@= 6#>=%Y]#0X т)(O˧_<lEA_ަuMfT .w՘)v2Eɂ{lnFf-B[W8 Zbkhl5SܢX?>QQ/Q؍*S5V T|=79Paw)HHe,T ү,C7 fx+ARO?!k1kķܸ _XI( Dٱlm]ȝHc6H^L&мME|Q%4 6ey5Q~Ix5)h[r~Q皪.93,vfu&VQ+ߎM֒ouS59B6t.ĺ %?h͠sD?jrm u 'Ŋv|otk꒕F +-]+Z(\|ScjbйּANF=bU@)*/d.ynJig*(F=؉1*5_D; +eN%!eC$)ckSUZL"V+k4>VLj kԩ٠|@!ZS"(2X-ϫ w +*)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=|tk ZG@ SɈ\cݢ)E' vA4tOK{3TDz'b%Hj){ UA"F 7̮۲:u:k5d-P?۪G!1_OP]\xtB.R,i\U)@k_Z8 ,ZcM۾T +Ti?B{n;odXD; +J&HK +Oރ}Ģ,HŖܠdSp9;k d[iՀQ&BຬXxGC5s(7seu­M->9j RiPcC"h/i?irKz#i&ɴ^xܭȫe?/H7Cr-+kVU݋Gstt*h-ڇF#"M jF[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/F JcܞNbQUn*V͆/oz@ +ޤIXriHKq񬶼fNzb߂pCIHDy2z' Z-+B7 +c(l|2MkdS%۴aPU'J_j8Ƨ2hfj9u:˿vXQrz;~st>IkjO=Tw(2xFڒ3uYc)=!D1)۪5dx|F|RJ- i'SEf_V x|ե/1Y%õ^qVIM!RX> ^(nʣ$ yw'rR,dYYLJ,'5VP_,R"\Sp71i־ešbeeTOS)QeȥLLˌT魒!RE[<%?DxXCLrbkK]W,aȧ #IEMc]Myת#d.&0 + Je1|I:h>3"_֎6 d"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 _@OڡngqwZ#.-Г+d`( ?¢hu©t5H}ri 6DoF cՎGӈȲ׏␋׶cʻ][$>}PE9M}ƛVkL8vL8}JL kTW5pyBE WQ>DPb]r#?SXbɬR/P e0HUz+g"d; ޡ$k/||}$qט|ke>Ǒ ~P-VrE#(*ЉJK/} %_US"Eɻ,uȸ e#LNvFA Q!_(xIQ)T<6i6Vt 3SF]tap#PƵ{W,-&v1kp2(CXcBR ԧ*-s]Õӕէu +^]U+t> wWqӂxZSh#rAMT +hQ)hV_T% +UT$~WuċWC= zgX厒H~໴Y> +.,, +.8'E ğ )J7/=ym(ÅXx<""A2C>nHNЊ]nfLvjg#eZuϖ,7M.`P0ZG+Z{r`EN 'Wg|F' ӌm5-ƁC0= a{2Psw#A ,N}@$}sJ:r[~~ kD1a*~p9Z[+*z؈NRXcHٹ$yZ_%U>ݴ)-rzT.ݎKC- -׼as +\We˹tS+IWR\JFbɣ1}Ѫ]ʆ!6/^ŠPkj=Zdl66PzX ; dluzbMrrTeQ|``0MB@MVyc\~o#k r9G0jsTf6P^s"TKE2[ؿ1W`I]AtLsm¯!{61YL6% +̨aME$i~?ɢG&ITjÓZ fD|p'-BE}(dU@" IfzYRB]M |E20$.&J-mm*J)b:}_`@uOV;sEUs t( B-bÄ!4pnV"UeK E̾ͯ 'ӿg +cP 91%6)p$(x +T^IZ<Ł 1~LdxxԨSBZтvc[˶<+4@ 3./ G#Rile5L҉Fg$[`@"vlecEd +HvX! vBv$XwSQ)fhV^x ,z)|CV} l=4ʋ;&BJᯨXqK}ȫW5 0^7&{P @MZi(2. Kl^55Ň88f~EՋ[:u|eiy?R{yfs}}2ݢHZŨ6gqMu=ʻJP0ԡ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ڙ0Mk2^el,KZ96>"SJmh@@zD6hBVC])XΐSpC]SsVZ Z!v0q$'a?Rփ(?ZL kǯi ,WXsP"]Zi|Vߏt u J!2~ڪLOG(Hs"x֙!2T)U ޷*cgZ `ErVVVq[ڥBw&5245O!/!B8!*7kHޞ?΢6ZeT1zz]hjh6AG@m[[%RR?XXI?nûN.wH2b8EKw݀@- {+'# o~{@ІGMEЋe*!{EE#Η,FcfFFI=QSXJ&zA3qykeJ}b3mz!:!ѶFE).3Ƞ5fR96e#BN[:eic𪴱!d͔B5e kjKfUCSvYbK:2\7yIIETYzSTw+LVSAޥ@BVG$I UTc=7.Fzvqk|?Yd*~SW )<{!SG9Ef1|Kdc|AQ0Q,h(v0y=}Mnh/o4ufZad"6>X;1QoI> x71`B9ob"721Qo}71E2^ǻ(td"/VsxM*lzۃiB&&vOYPF&nB%6a+t2Y~a`LԦGPS~75R~9}I4Qo?74Qo?DfaȊ[8K boPw11Z&ex(ׅYA)"gsʺ@j< ?DD|kQnj"p""d)i}S-3}*JTj(*n8>H> .^ &➰G !464|޼{2bh"W h"%3M64 MD3mhݭlh= e~zh &˦&U(&z7黩*]=[DOD/'5wSQҊM6M\77QoHIUMT)CjJ.`566KuI>DIss9es&' +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" +CFQ0N$~0D(NDH)Љȏ3|F-DIf'km{aNNjx +5ى*6h*GDHt^0ЉvXDDhj/t"{N~@'nB'n9Љ Z6`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('(:wf xȎN,sDk?D>˓f'Ji,q՜_|el|"g!택DM\3[gs|V.|!;>]g&p|/z"ץ⩦Yr O}9 '|N1oz"qvEļ)UyLE͙?qnz"jP#X=єJ@x#bn"R5'nz".fZez#y$g?=eEz1 = '=SS [O3,RM^-D@O41Ÿy?ύOaj=O4S:F-Qy3/6AO<'fܹ ;=^?D6SkO¨M*(߿)v"٥ZLz2rqE=!E#i(ڔ=82@(n(Δm[#ʎB(r*MT 6Fǵ(J}1(f~SS }n߈6FŅQԧSj'Fq+FqˁQA#.w@w.& (Jc5ǓZEEDI̒e7;^NoZ@Be_ !Ư +@H?ysXxa^'F:"SrGFNb~M MA)HQGrG|Hھ79^H"JstQ4AQ&L" g(R6J+8qSub%pDtY*& =TBq+ՠEU@(nʎPL9t\Y6xn9>7٥й ʁPL9zT ǎ@(R)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܌ޫ(kXmc=D!(wv}HӞ$(26~nD^Fsx?QO PD:mxR(6v">C:Ѝ]I"K>Ƀ(m$?QE"[u~Eq(2ow&@VhG`-RKN"j@ +yitV{cr孯-NA诛!";dFEPd M $wEdzA (a$I"m3Ps(?E-ڈ0/6TWx," ugAPvi:H{E|X²ΝE)CPToIP}"^NP!iPTx ]s?+@(VJ q&Pn7Db'B]BhV}7EF:Cɜ-R." +ED 0jZ@zE(겑v ")O-H`o(T)EݖPYEbb$΄(p%tR*;Dz% +3Yar@ U QʇAH'mE(fDQ_RlE[ T5> Ed C3VLHk(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|@$[ Er T"ᲷމRT-Kќ5,ECOO[]RgX,EZLqu >R']GWR$ u3@EjHz:PQ=_o{ STᅳ!uaN|NX,=phOѺ-e (Z_,M\/NQo])V̅}GȄ5MNj֝zB)"zH;x!hKv;OuSfVȪJN)"S9NQ\0V{(—MQ+hR +I|@)6X7WJ "ANh\o@ *PQɀO8_@/~?@Emasvk`޿>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|ZIxxJzOQIOk k ѽ) iS$#xR>\E%X."Ia>4_HFsZbb)øM+*"~ߞ6*ѵԱ=eI*bx1:j6$)Ȟ(~f}JUlx4r:UQ#89+UhdxQxrx +e}UT%݂*jBHpVnq7/v"2a+@"tųZQe/VVɝ VܔrH.b"܎.'XQ#gY0:Ǔ`E, `ET6VDGK'XNbWa++ +Jy+v4WLX?+p79I*A*PCw+rޕ%XѨ͒i*:ex5ΰXUDQ$б|*۰R8ـyf~Q\|XE=Vt,&K=ZP!d(XշXf+J`ſ+Փ Vdς9K&ĸժ=+(- VL4DpUPc_;Xe$XQBW+} +.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Ғ.8*)8E~UQ{"rq(Z4-Ng z"7;?)k;O&?gTmT( e-)OQ;&Q׻Zb)bwQ:SPO1ɎttS-:QNj./X`E]O;XqSvx&;Xc{YʼH@u^*PGD=U$sBS:P~Tq)'Tdh;-*q sTQ~ 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&Z힨V4?.h$+cѼ4wShE{}Na Jަ:pQ5XV$iv%qt~hE^!B`V9b}e [MIgD)3i睮&AW<< 8B-4U;]QeV`xCMiϡJ=؊Rc.lE7Tų%VǃHT3B[{hqdDωV͎VĈ?câiS, +kh֙NVAV +6X.A$+J6w +` dEL8dEYSU+\Wa~Y4݂)Y1 bc +,3/ +j7Z?Hf;dTbAVԲ"&bQA+b•\bgB7e+tEWl+;QxW~YrvTWDj?`/v-J`4w׃cxMlR9)^WdߊWvU07![px΅WTWH9XxEQ J[vW$FW_k+6,@ONnd(xEN%񊺧fGxE]L{ʎW\+z&5,OM׻m`)'YW\&SL)BLr+8\O!W4("ZG+rF\:_GwT"P>4K8& *@ڴnm;Nu,t^QMVP!|ť^Q4V*˞XF ⻚(QEW4enm>etE,4r mIWg^5[mY#tE-|Ƹg醦t+2 b|6:2QWSaޓHunq6_fYlTbAWr}t"=V"g:Btſ#|Ѽ`&PewCO; \t~U=u:+qFJgv j}o'Qe i~38$w'%IJ.I\V^#(۟HEHfMGѓk(lD wFdX +yH<_ + XTY1ԅDeLС2Cԗe;҂! +XisւZ&ɂF1p='J:= =%;|f jn,ALa .5 f6w"^>>+ޭsD>VK@z 'j,*Xލs؜{PI%8ln"ՙ-!a7&un;$>xѼ&7T_eE6k̉ J(=цEڰh>k_hÂa@BMaڰ(c Tlm(X>І aQ2h\$&ڰ(a=6,Z@1$ڰ<ۘhCgz9Bj0k#.ăl( .PJ,ȆR~Ƣ?+7Zd +endstream endobj 42 0 obj <>stream +\hÿo/@)m(_'ڐ&ڰTPuٰ|ebذPWXTzp U-t>ݞj+bb(@pBP1Z %(ou !дX =N2UTvRc8 5`@ ?Tod>K )Pez} +T5o ZZJQD +8t`X z?APuv(%TZ*;@Y( 6EjɆ@ p +Su\/6V(^>;*]ss  Bˣ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*0V VUnQx>ŝ@nG>7"hr!ɴ0&Pr*0Pn$(wgNqv =4Osb +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` +.$^bYY<(88J!n_k/d J^0RN)lE~RBˣUry%Pkzq'SBTó})dWGja{r +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 h*h~_E#lJ+ P2]A#4BThڑfqڗ _2Ei(Qa,B 7ڵԩ!KwaaGvCΙ9?9ZA7my(ӃS 'u0:QޅQ +1/:(5'q 4zNd=:TBS_:v`XˠKmQS9)uPoHax!I XE#8$G`ʚj(Vzy򀆭U(x;| J6 N $>ĩ !|^JoPޠ i rS`U8guGADU1[ʞ]v;kPMu" jחd[  m)lX%:UP#e |Ge.KUgx߱#HU;2kH֗VyHvR(9bRl:фMR'=6j'L9x9OP![3jyJ['OZ`о8A, N$'HtIk'H-!йps;p r4APt|1 } +8A&Mj3,[MX%AWtqtRyΉ4 бp/:p N]-q ,q_ 'Hĥ;y~'E3xI>xO轗(HV[$́@Rw +*X;&P (( +شT:8) wX@Ag|xP`PP_ d1pn*\kEHGRO"ZP@g:P,T5hD߀r~<\_(z-ajN#,@; +PQ +V8br0VRjcc0ŖgaUgۗ@_9P19[j??'Pq{- Ry~ V$cw+ wX *is=N}W2u,P0l:'7?cp r K3ܚ%Փ`CI7 +Ֆn DIJMyLNY yZVm1\Z?-0)sb+diح3wŢg w=8*FA"d9.#-m2V/;jD?%ğ˰coMNBpżv^ ~t W nvIx0Ju)'+~b] ߭Y5`t,_ Ox#>i~ +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}R0 f(XZ>Qky2df1,W/}*6J+DFHfCx8bT,v'a(g>8 Rj>W(=}͠'<^5CXE5p>eVFhkXf ([ by8ľ{jA?̑5S.IWי>^ľBry?ľ߇YfȊ|Ԯhn<-}:ӏE*b0k Aӏ'} *"4+ϔv'Cfq>)"'Tӵkh~c]с{>LKK'+{0hΙ} Ӱ`}WHUSTMDb掤')?}zq_A GG~C~6j1oXّ}̻$߳$s٧69Iq b@=}["hz9Ց}۽ +ɞ>lg;@Mz죗Eѫg{&(9Md_v=dV^i^>-vd%ZMdq+ʛ>N~L"!N>hP d2Mc4U)V +s؜(T\*&WHf"],Ⱦ#ѭASEEbČN]G |k53};pObP тi:Z I7AXC0TU*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ԓM2J6Q3v4Ag9A}D#m0;:3Q8ōͼvN2EzCi^]H /و;K}ԇ:Mɰ!k`OeG¥>de?P`Oak)ԇ8QԇJ2%@}(x-@[, +K!Iy%Y'YLkY!Mvk[Y]O  z> 3Ev~/,9@}FYh氜cZ&H}p>d +Շ8 x*ow[>T%sVԐq2?vn`Q;P}oKs8OhzR]ԟ>)|5$=2f4X'@wjcW,|Ac -A}E,PgjQy$1 Paq `#if7+ǑO!$^OPJ(]>WXH>dV`CNiԧGC"`BA[܂))tclGyCfV_!r +x\>"Kr'է0[+3Fɉcv޽ن|Z=P}8O +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)t5:XX>S<:^<NHf#yP|oLS:y9_Mog\T|§-A· B}.<qBVo^N&OK-+^*/7ZPXlRYGި8{$ޖ= 8zݰfS BZu@z FKx,A z 9)@TzVtAƞn%UIl k/{s!?GƁm7mcrL.=Q{ ýzt wqÈٓ+pUOHZ_zĞ{iL +ž{9`OG׃JJ n%Ku߅5DO|->@ط]%;ny(=yJHvxs::' ZOx8%+8 k24q${op!4@O2Շϓ`2.Ԇ}߳$(?O?OJ'<)g8ypǩ mJ +E I~ +ރ?0Õh<,lx;A*S5QP~^֎aQr+FaRyK vbYc&9tNC-+σ C6nTCs;6hzpPjn+x<yyHRtG SgǃN[Qp^05tOjnƁ*}ܕtujޒWǕbNW C^\ߩy<ց_ ֮xڇ'u<; +8:;5jc؋'IӗSEͣ^ɠ-w^Na?Z1㦭['&qӄwxi,Po)rGX(߳m=azUzi=eM\uQ ;0=}c\FY6ǒ^LT +W6.),ޞ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{އ]Ť.1xҠ4DKIfAR +ePsRceé1&ԍwGSc}nbnk˦V+9fJhЙx֭2# +?$jQT{@e19r طMRO*(;cvB?jUG`q.ZfY3%O2kٚ{ m_o?MZ]!phqoi]QǢ7{~J]3y]wlÁ?)$SrgX8і:+..몤R͏-,: UTb}A+?*1qlPFËCn?_}RUݝJO/UB$KP̊/U TY̖ۜ밇b=/(hػ-M#5%B+~;hc*eV#-@v_-evյvtrv*U(+-AJ&߰LHHaI ZU1)K{†{)4LNX,v)e)< +*Մ/"l],*(˦0'U 1Q[%e5EnKRV!fa_X0!ɦG.acAH +T +Y3onՇz_K*R[9fi$V:B{(yonKF{! +WB uS-\y 6v,Tr[dV:= eTnhwO%@r@) _(a]њ6.Rd)QcS5=II柍ZA_ l˩gKSu5(b)P:WBVh>j&z)~;TYjx?&q[n(z -2L%Y @ifEF(jCkF.X.Ehcoa?,m#G& =#T:fY=)evk̕gч1nvТԠ, PuH~R Tl䯴]?sW_xHp^%6G*HFp+ڙ'.eA0ZV+íB]7hTȑؠ>ܵ'Uwj9W)9p<\xqy^#0X4Z\@FGTKGdq!j̪dv\&EK +՝2&pJa`02)p|6B8:V4}++Mu8yFCd@vtBMJ`+t+RT] +2=Qrg' |0#M؃NL2 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-?1Ezlk[j[ $5>-R*zŗ䫧L eY̫X V(ݕkˡPj$B@XWbY K6)HM7m5jcj'Ըڗi7ݑJ+fU29{UW~=K} !rp!gBisxӗkmš,~,+ǟSl4Ī~b\2BHCB  Zt@d4"s{n%Š{p2T +Z+닳(n}P<=v{ծ̧hI-1R2^q) +D=déNa`[Vwf߻FPxÓ@}q,``3!N:dQ$AJdx-Ree%)+n塛-mM{cUL@ak4%YfOm>co +xDVqr$;EZ5|32} 3ﰀj/?ᶕ.F8l7` e mOӾ ԉ|3WBB7vVy#wl\*4b"Gf% /%4!b2D~}'k$7PϚէ*aC/Rd;ֆ +)(^Ё2L8Edߡ7DhѣTvZG^кgZ4B +:z2]-t{cbil!8]c_/9N ]{UMߠ1)9C7ՈM`ZUj5J(7lӇYɬ&/RvtQDihܼ?E@2n5|lԽC8t7WQ bbV$^#cTK6Oa7i{VgUbbOjԽ HUTBmKIȒ +Ư/7ܰ 7N!yoVmbW}ԕi3e!4OiYaZӝuA:ASSBSy{ +mrJ(Hlњ,I3oZjd0;m0-EJB,g] G:eâmhU!KfxZM-3;}tuU+|W #4) RZ/Ta'geSD>iVǓŽ ux8~-K +&2L>cmWAGzF(kP3C?޲|S6ljQ^}KnU/pmg&eKtG޾қNk0 TGYk8|q3jYx/iYڱ#/< mL + SwDUew UqaԖtu2R,8}6` + яZZ8XB39MfͶ'F#-j=B҃[G 4HNd݄,`ɠ/wB!+ύ] +-k51*ܔ굑 +-GQQؔsjfT%qӤ`p +n%)7{wǡR)@t٢bT+ ^D^EJ('Nwv*%wR,hat.0,)@dhc:Tv,Pyon3Z.cR=/pPr+wl5J͌jxPWQl!%sIGrUJzVgA/U?}f<vr+~)lɏ7JN. TxOQz"!5m~WR +oձFE*M^tmz9Z,A. +zme=I$U3/.>sHzVڭ+:PW![rFUז1hQN]tXO5 nXh?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 fJT@B>a=7(zYvDaH@ 8uM-{"L?q[ yZk{Rp2)KmζU}Q2L]ZJTis*kyc@ƌ䊮@rsR$h2 r␜!^XDSv< /:Ak +Eԝ&^V+(m@d~ġ[ݼ dJX$tl6 X[3AmK[hx*~ǀ3~ҽqn'#ҿ#UӅEDClf rzwJ[UagH0ʾCgS[k]mՆ>B*_7*u{;AbB M`Tq2Keu^@-S[{Vj 5E«;uE}ۨ7O=-QSVQMPT&/w{"Rp@Јr%pX{݀ j4RdA%M[B) P{T.enTĔuyi&V,FcVٷ^6p.kL(d 4)lV5~cs/,biڝH a\ӭn;"f8o$S +zk*Li ,"M2)Xjiq O`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|mlzF f&L%i+᷍8ؼJh&ۍW&Fb%T_$ rD& +U^,74{LL1K6jb͠&\uXvjF)ZNh""Ǡ&nƤ ,.'+&6y8mzݮ(D](o淞օ"^ UnSUHjb%R7sQ5B^9Bj D) Ws5ؗe͓*-R=5;AMD$5- +6?Ħ &Jʼn.Y(lx̿Mlbp&Gղrbb"c p iMl&>ӫ +Q ՌŇߢ[i/pتQUKpet Nljsĕ\`-˜X{}KzS[B+(_e&8؈CN\;(u]x"/ XXt"{o٭ixQ!ȉ[bOINl7ȉ(W5TNU DɿRMtNDiu~ЉU &vNlXeXDXye5ND v"eya؉N(Q=.IOzLu(:z"/yT΁!EOldKIzb[ꇞٛHzbX۱4>R1~[LKPjTk'US#j5ke PmQb&(zfJ% C#bnr_fMUJ֜*NYGz#2l~w9&WX:f wNm'<̢'lci=l:66;?Q=(r~)כDN%t3O~FBO~DU'V_M|ĔX_#uL=>sz;X?DɵD)W$=ҏu7|ߣ,zܠ'rRSX)R:WqCue(s'V6D)l>>8q9uԺ|:!X.պPL9OoFu?U=!kܪR98)I,GZa)~@@.[);H1)V +KY E~R)֒ E)&HQJ :}] Ez`:%&_ E}_"TX)E1y +&HnH1ro |y o.o52U#9==-\jp .S9@)HQFA)J~R{d{]HQ*RDygO" ŪTB0@ b*Hq 7iY:hmʞvYURLe).qΔm[j5RFo)]-QTwˇ(JrQn'+b~Gyfr+%R$GQoŪeA9K99)b9b{& T;GQ߳ll5nTlUGQ'X{$XNq I)>pHFNbây`U +1')r7I>X)gDq]#A("r@&FI"v2ah RT()nRT)V2ZQA~ڨPTQ=TCQ2AꓡM5,S$Cq)'C1`(Vim ƧFzy̺tdΪb(.d( E6H0?og(*ha`(*3A(*g$BQgλ?)I6w2<`HC.!cW-PL@(E+T!Z}H;C1D46wBH}Pȭs&BQJYE~WXNa bZ"ZD(INa*HjA(Ri,E."(~aqc[ʼnPDVg1%aM'Dփ hō,Yg@ R6틡HC.ba HyBQl$G@%lAUm&CoY&D=C{PrcO"(rޚ3+*AĹ :G0EZzd#Kax +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; "#bGV"~H(cu42 +ES%_DQ1E9ObG("dAQd"i$EoI!(v")ŎF'E'Փ6/ž":Ez ",sQL;nKEgNiMk S`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!`-L LMK}6#a"1L#"Ʒczۺ"n18uƾia5C)4`\ur{ܿ>U"$tETN`2Xyx譈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 @EkWQX/Tq#u T4fO)v8EZ9Nr\uvn,Wa^cWae)vJGa5?Ubtq]y8E6l)Dp'q6\|ö!8 :=:惨8jbf!*BATd + ~I"*|3D,/aR +PVzSd0'xly#qfISES/~4EX&2$8L %MqlDj]IS'k;+4űy[hЊp͇ ".\]xahE0@hEx*Y[Ƕ[9Ɋ('Yq}OMgn?kة?Ȋ(XQɊ(/""F "Y+"F_U`E>0NWc!p[Wu/չ~c拫8p+RQ1UD+=8,ʐˢ[e‹m㔳G:KY6$!D̜DpaYǰF*f57JDRUne^8X~+byew=%ae&ȊyI6s6*Y;Mr.",">ė|d4Yr&60BG+0s9~ +>,dEb KZtǩXIVζt+ҡR4gric\E +\'"'V.Hj|W"^+tĶ&S RdQdE}Ȋ4SdE_7dEF(TȊiI +2("jj"SsIVe;GSD*YfN.*EVd'p'ع(8$+ѯ)cyb;b "7ģVb/*2*BUD |!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*fgvX04lv!*⧬5Qz6DV,DAvB7=|!6[AE*26}3e=kFW VŢVTVd +@+6z; "&`lŏ?#ox?ъxp&Z~\hE 5Њ gV9n8s(}h% [%],g[YV*VDvIlN@,¥"8[ÑNXQ3G2}"[q(xЫY+ي(c7 Wē |̄+iAZh>b7B0<ከ5BWJpX8pEXSC^hUي2Z@BZX,hE+EGᓙDRЊi"԰։#`(u>7DhVd%_dEZYddEZZ:^dEة14FaRȊQɊ]dJ*8NjJ80"ǃ9ԟ`EZVf>+BLJ+vV cIS}Z~vMjP")N…u+RT}xLa/pE.G7چx8\1-ux9qh D>&f +B\V sd+ {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,[EZ 8^|E>+r^qn" +wEڮ^|ŴbhاX+*pl_Qv["BƢ "zKW48J[v_q:|E]=6 xx:a!0|Et+R0WL˓+]MY\6QocC.ׁe9 0G8u±M v1zEai0mb>9C"WsUuo"iOdH)EX,U8;)#`>i(E3;$q< tvD|EvQm)L,A,2&L"6,2J)'x'ND8!..WҶ< i"˔^ᨗ{ f+V= j`|i "w6tE +a}'Va^g6MXIXLsuZ\,~» ]H<Xu"ȾBbr=,C,|ET1S>1zf!g$w3`(fm>e9\DL8 +++ëWD+hWd"k ^د+z/" tEl&tf"*. +\X_YpŴb)} "Z 4xEu؛<4=#WtE|e=ՙ!"7>E$Yh+W8T H=|*rq"T~8DE8_@0(C%q AAl=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 hHØ%ᆵm֘!!^欅 ؚ{t4Lk `8x†%h6熔Ûach '"MO @* ѐPN͸%ni)DCE4g +i&Ұ"sCk战aiذuX5A.]%lC7]Zj5$Аe>es!nCprOeQsA Ύr?0&pzAzs4KN4k~@v0؎f(.'a`=k[d#{"]]x9m"̹ +i[!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/!wjD7gzraT0V;Bֹ;/kZ)`Hٶ4<i4 FvI`fr|>GW6~r c;>eaAY=\` a=WNͤNnS,DAӔќ )28+u=у]GeA"Nxh-PgJ,MBBa& -=H:^AP0~ +=@ ȃթ`( +El-Ƀ*/AA=Qvt3{[{1QB'؃r'zFӡ , =HˌP<7z=7SfA$42\% =$ '.ʠzS*z3ĹNcS9oG9ľyJ I BC%=q/#{{Pzw"]Upm:*&O tVa+,:8r) .*A tɰ.lޯk>żf'EH"W E 4ogbPۮ R bE|0OʘH"1# 0xvQ0>]sq LW ݬq`1Sd +DZ^0H`#ݻkBtN 2qgaaOAZ/آ}V|AZ~_8 N[ quxA)xA)& (`CSVxAÒx*\/z ^:}&]/ƤԄClA֐0тXdAv,yP[̹ 30XAD6*?>τ +^*~8_L+̇Q, D9 =ӟ@dR ^ &!}10~eaE DNƚȔΊBx)۲|2-d+'c > J Q@4 t@"1xz-%h"VۦpB"{y5ܝΨĈ[_-MHCeՅ x6[ + f H 2 +xf(C7cg|![''\XrǶW ?_ +Ȣ9_4@pU0@dM["`H0cB,@V#QUJ ++ p?D jf/eĂ +T,+p^!@o4=kdf9(t?9ݏ<9`h8X}2 +3,e-EcVak_/\K``]Fp?&'ݯE`Ξt?XG4WgTJ3\bܯ7`TKU܏I *p\p?Zlrz6 ܏] +v$܏d +ܯQ1ŎUb \4εEG Z,>>~8&9t+~tk+l?Y!XA=XV~gI{p} ۯ0~Vhl?61U~=ot +l?Xvl?6oYL~Ǔh?ԩ}h?KIc&_uXeeޥpuκaO,;`Cy_d?Y+ݎd?62쾐_`? +E֋z(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~V V|>~\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~4S,~0(_)%O٭T?I&_T?(xTd"rAÒj`ٓרKDpR.~˓`Q!@qвU퇵\Yi3|fC2~vhf9~R?~LY+ Qh4ܞtozͤ!`וd?/ocG!3\?,\?X\?ϞX?mQsP9f gԜߜLo(Bc臬 6Gol󃿆Y`1^~/R0NJceB"1G9<6ŏLRE vdp8)l׋;~ep}Ht߾YX> +#}hg(̀|GD^о+܆u1_7/$xď8H>?->AumROXCӊàB|Ys$2 Tq] _NdX?҇r,JVJ H WKDABUAC (7]/>6Zc + $uzޒ5|ϛੲ`H-6M\YT>Yʇ堘;q|xЗ5//-B^P>{0-:gL>f>#D,(sȷ,8> zOދ +㺞]2)N;[1_$>Xٞ*owY > +/$a4z |IJpWK~Fk``_WazD@/xH޴zQܓщ{ 1;1:Uzh^&4Uۑ=ײ,yhL~ g DdF [KK>gB=Slb c ik= +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?N Ciqk3 zpAk70DKK!9H{X1,##Z.w;ٓ5({eW2Z+ +c֖6={X@e`=M-}ao:{8QD˱[ۭ䂰BKs{Xis=UYle)b!l#)-gfx +c+f0Ykp8`ņ|6O68_vB_ܼi$zx %q5z`S`g3UBcC$B˒{ +_a$0L7C'_/ sR'7kGd׃09e܃j 8=l0 +,}*|=|=639|4Sъc$nt.5-{jף/ǃ)@&C! @z x^۽|-8]3Y~韍/O_/KjցPGv{_#VOn~0ÏӇڌ sߟ}|Zw[叾_|(90 1h~ ^C&С|OVvIA-\1&7Lf@ i6?g0Š4(i+TtRclp"g\_نC AamD#/=t[+2;wBQJ Eh;c CwWHO*~a|쌓, ӂءi /ذY;}gwdu{\y/[ A=G\!??˽u525]j?xEQ!{<k Y +^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 +'5O 7;{S8 @eۧ/OO__~$ʛM_v'l鏾?OQ/-{~]O/=|}#n5 7[R Gnù+Ȑ#6 3-_uLUPd}͚R%q~F6uVfAMc+Ek9bBa^F+Q3bmZ6i)JPPEajFp{AC[7PBk`WGh=]ݨrqexQ߈U\͊s5gC4vZlOG.Frr!Ȏ[*%^d9n&ӻy}𾰠߹4}1'wE,N~L M,6 fjs +9-`nfCnv.W>X}mp{zAe'qgu!ˀxVdE_k TN\LQly.: 8Vp1CE4G?Ng܄(/&-16qEjP. y+r&q T;6aA^GdPd|gGa@},Dui,,0ȴXI}x+N1G$`L0A*cC'dhyoE_Jg7] |:?&A[8"*e.= +uU>6n&T͖A;\+0q_ +6ezQ^a LX|Rk0~9IDW4"fw1΄9$+PLp +XXu! =Y!ğWfشnE*24Yv"-MK-mpķ ,G5Nfw&g;t%5_5#ukHA޿q|Wva{RFeTwy㍝t}-CyihHphۍA#Dl؇> f܋9)_O78>DO{h@Y51\O + +!>2ar/F>Pv#A5 aGgCc"}K RVcnAG(߹Fo tp?bD`0>t&6@!`{VS}dd?V<)2W>m&\L;*lytCyuie(1oca@\Z躕g{ )3>=0?7ZY^ uDQQ '6UOj?1|RZ߆ ag2a88+'w~,9;~)$"+6cmx,]Ӕ$醨.ߜ̡}lo}:HldRSʵFe9늂}8_wBsg  {gApϦN'ƄzŎKƲ +׽^G)3m8v! =\^RփҲHrgX(X92<ӤY7- >^fE^2՜^%7ֱV6{j(+6ix=;~ǣI|N䙴FM3ڿ"uS]xn7~]O&*lNkEP̪@v(ۆP;W&#hLvMI1)P, U~P|?7RUzQ$jbYެwz; c>P !ug;StK~,pEݧaDt*;uT΅FF# [ަXIE"hb5c,C5)4<ax5zdqQ(ܦ h='vYB>">S ¦>wkha9#ayL\|˘ۨ{Fmq\} {tJ:ܚvupM57aA3,_K 7tV}z9?IK1.jt4ౄK2jRe`syBF^t#7ly +vћJm}3a#Q? ?RpvHpFK up4i?9τ0K )Cx,In·L ҟi\4j`o+ e$seA8/Q8Cm/2˻sWik"JaɅ/Z]U 7-b"*^ZСX1A8N9{<4H>XAM+MKEZ"H=`YQ-H= zFF5##VTEFXt؎Wr|,DoU>:Y@.j/FXk\)^E(t. +u@9j %4цk5:%D} +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.E2ZgDGg%2Z(a[@ZI_-!~GLf-+=$Tv;VJenPӵTU޽i7s@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^jFFr2'9dn,fؑ,~į\Zb,͝Ծ.00N?wί?܊; y =n*eRyBo~/i`K9x-Sӆ:x?\gމx9WX:w2 y7"7G;g8_T cju}&6ю^ח|L2O@~љQmz|C3+iwԥ%?Wrm G>!Hu|Qm2Xwɠ٢06Gm!uƪ-ZjXDcƓ0ZO*ו0,r_^:|}JN.:eJaޭ<ү%U!Ӆκu?>~/Ofvm:\ү׌wa-y +оݿ E/3.BWSȯ8]!{W㐳yp@@i/Xy/ y/'bFr|: +Ȉ\bjur1 1e 9-ڊ$ਙ35x)orlq'Aպ $̐ǻbyxfYd~X?mqW3}j?7-JgՓgJ7v7NQz[,M lG{J/r+VrO9y)Mvnd7jG^gjbuC:P Jky4-2KC$ )(8}/z :$-lT9Y}ݕEkѥ'fKFO,PzbڰR#}i̩';zb;`ѦO=b 8FKl Y;bq*1#漣؎靯 ( ECl)l=~ԆN;^!?W׾G"4nTC,~o(#閆X U! i"|GÆe~!N;I'" H:ZC,Ҫ} +ݳ`7}/уO\6S5o9;bjm}~R +6h=,P`гCς +`rTEYPBQ,P +D\ѳ`Dҳ ZmEϢSԳp )h؀OE+=bh(%h4Eo1Eg|(Z SJ,!p0*PxHOE >džI. Ibe h!E#,ZGg\IKlp9UnE#*kԳ-gS@f[rDH'확ѬTaVW=frFys'#,t\<@及'mPѬn5nآUw1oi4 A3q̖2:΂Ʃ*McWaag4aE2-=jF+Tf7oRKO1{p[m: +RtqVb-mⓏN>JcG Q?ASj/Hmt{`F3E=b]/Jע.-8fbb׬-8/{r'<. VsPB{9͍U݊41cO 88g v\{,ât4BYQ>7ۃ T{kO% +7#x2OHڌ=<:Q$C_"1P +psiCvwe#g&#ڶA1S1P271#3]p>Ksp \et(zW׍JEFf3᣶i&]ה|P> I|?Dۊ +'dVD>c"L>?5ƺ( (fZNa8;͓7%􏭾_nIO.! 6dOhhYnѐ vVv + X틍40&"muxmg=JXu2s7|A-%kOw8Ӹ938XAی9 +ק/YC?_:9Bh$;V<=ū}`*XfYsog6èe"5 '["U[$p(sШAzՑC; )+Fc˱["Qݪ$AT4ϽGp&ĠJ_%t-#?>>':2r@/Qbge2{6/),uzZ:KM-$UPްjDiNSՙU)2ũ|3xx'j.|ub6C(4/U4i9WXK)vKkN3=$8Tv}g )" =  Ȓh̘Bw;|z\GyJnbeJmqHőҚ8tzUÚV. +wWu=bcۛyWB`Bw [Q}{K6)x:Wx|i 's;#ZHX*bذ0jfGtQ~͘P$ݎSV95Ó5/EaWsT޽:9"Qj%If~ea @{HH ):Y+ݠ1#I8F;'=`G_Z]%ߡ7]pUյG[yO|Pm L}F?گd_sv5׮'^>p*FT%}[1EgWu,Sd9~՚ób Mn1ҫNsrx6inQi|/51A͟2NDMŵeQScC<%f /apgvg)Quz9zGSR{ڇ*K r\} /O=VbSm=(|huVmnpSlr6CjNv;{%wBJ jюuFtqk5Rfp-j7Om0"I豌Rz,jS+65kaWS}!_ڸM5kA*-\Zַ%ph\ !uyD +^ u4ǹ(Hyܡ?(>*R*N/B&y Zy_kS= M޶B>zʇS S>C+׫q-rʗ2d7izkӗ\`Ӈ𞾽D3XF N`,Ð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؃ˆpB50@2 \Kbst:՗!8~mQD\t"MЅZ0(DĊ򖕘Rۈ=GpJϩ<#Е<"a0x?.^#E7ˣ +PK=Fs\*;G>_O*sQWA@6Z+}]qVmHٔV >n梏bHsh6`B!w^ 0W_ogz]ۀ4';E  ê C4 jv؏TEp/?E~_gނOYC!} C}fvs?< 2PڬSfq-ßd|ćtkcѲQ5: Ϗ#׫NZӋ5|)乄t~jRLn` |"=h6aIR* 0['48kcC>W&fQrH?غץ9=A# +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_zX P *YRA:}%swk D.νX1 ä٠1C>JS HSN1hMޒcv4c0R8A1 9hLN QǠTAA1HP-P1pp2vd~&j><կrN-dDzDZ|ʁ@Rz o<Ǽc71;o(ɸ={u?ɧ??6;YlG;3A%nTb pP֗іl~7]^B;4,<@n<`vSw8H 9!Otn$ l_6LxeCkMR$LRaGꔱ3t };wz EQvo+>싒X0D@ŀT eӨvȕ.~1C1r\B% +betjGt4p}@q(`=nfQ9)r7sAɃ-;q틊Ȏb>d +DM +@A +wy?ќ%upg{QjIRUq; I{i%V:n%bW!9>'iSU[Ks|7Uz(@A;SX}sI0z~%|{D'{H@}ЏP<]g2Pq!Y?C2qUt9R& e4ugCO Kuz s8h +F0J8N{ vK !t4C+(e`͉ =qކ +{mP &cC6 de9J#sIRX>`etŽʥq3YQ'".Ak" X5MXNE<ӻ!4% +6a [}un;.A"X>'\BPrBsBbbIt^ 2+QbJ^_ m1P@ x,2 +::E/0DB!dfVT$&&@D)DfTa$57Yg)cR3}ZKD̔!fC# *?Jp4TޛTrJrAO*@kgErWƂVZX9"!z _L[ArH7ȼZWkA~˒H,`- JXX 61}I'q]y+DQ>6sC[Ĭeu}+?k,PV~L'J#dp:=vD<5^ m1ְvdㄺhKLlTA%6%7>BpqsAAb4qh8hU i@C;́t u=ksgB '>Xu5%3[c~{ ÄR"x^G| `'<ۈfÿ1c-azR Wn"_nNԙYx"7k&(a7/ӚjH:Qa47ⳟ8.0ƇZ7Ɛߋ5#H4kkbP={6Xzu(E('v>Fy1 *86@ C6F{"?fc>|LS>:l?HXYhnHrVx>]ż?$'&IC Ntt$bjP c7ɪH@cjs[b4, dwnvx3i gRZgGxg j<:r&%:%ս䵻+8 /=EXv¯t@X> rf'2MVxmz=T_J p-hl;}d 樵QBK_r)Zz¤(OwHq~[W9+`>yyZ= MN.G9MDCF暞W/*>XNU=U"nySICΊ:]?nʔA6ֺnOD>oQ,9'`qWXU%}\{7My\OI?(A Iy< &L+0z+011&?c~㘫YJZf9 љ(2#  GX6TMEP +ʪ\:(nC] . +QHb W(kHS蹎}Qk0Q9{LzM +kV5(Qy"5E!<&Ԩ$P%:4*#R'deN4s+=- Hs ґKO=#,7M\,\)w%k4p>^! 'Ԑdžq~^ss +{/w}HBp=`ޞܑ^y['"?N}Эdqd90l{X.nR_v ׮?iB˶\ rHz'⹡.J;{]fB޾F@,|8)>r%g}ņTv}M7A٪1 idR޺q.H8Ӈ >Kc¼D)x0=UdZ1.2<;b0>7rRTv)HСϹ/ 8L^պ YDcv>O WCgQ^!!QtqYoUzxuݥq*˥Aс5"D 0ῌG10>f9xPRܞLVzYmWE@ߏ x,>zP}S~nY/3?=1j(^'ksg]k:foyrN[Ton>ݶb7?xև4cFLo;AUL# +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=ƤcPo J ` JJ1(Y@Tz_O2bP:= Bv2(aíǠY, JrPs#| J 6) 1&1H2&Mr6&p1&}|p!{\Ϻv=.xPyu x\z2.FǷ~cX} juƏ/!H+(!7ê82(نBede: +DSjn,Xg, +JHAlmc^Pmio(Pk=NRc.`fn!F{s;y(^  4<ތ>vBU6?q DGB QNC*4JeXN1ɒHK/"B!Pmڼpz~gYc,ʅa#Y uPl@;Y=_.]ܫXkMtDs * /lzhv>ܗox +~SW?{VRChFްxBB +!Xl#X:pȍ; +P4>dO 9X4=q+Py*8 |FmgĚXA>MnW(اבzCW')%&~^ݱ,YψNfkc6j?;H]*n59hXSyw7k3n[ӧ쯶A>?u+ˡK""A-6tq +x7c`٪e8H +w?𥀪F\j=IKC +*[aPؼ*TP>ґ|#Y:Wp9qp}{A2]_&]\ma>g6(Pnm%*C/@iX>AXvi3\)i.͍D Jɱ;jō|'F7c):F^@MmZ<ӎۘhQF/6Cpf\4ɽM4Avo'E轍fl~oxDhdzo#jؽ ˜ Mm;of$.I[ Ժ!su25F&BUQH2dP*ˮ6זQތFBYFI^ϕe8G(ԔevI_+xX)<Ӗ\[T[jPzMN߷ Q!#9a!(' +Ub&4# #z9Uvjj+()?idѡL:OG|g @H,V87Z $&5u"aiA)sU;vQnm7QtG"e;)[U*r ry!xY0)=W0P<=1(k~H+4z 1G١5 ; .l9s~)-θ|176phPνKȡd,_^NPg$ڽ0(dwmEKF&8Poev!wH"4p'pIbЦަHႺzR %VLofȴopdJ;a& YXtVy!S. AKw&=*+(S&!Sh(" uA֢ gH5ݣbɧ4B.OuYqxPG +k))r2d)']ŷhJT*嚜MQب#e86 as}TqȤ{T4(di +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=aJutWKe WfiNSٗTr tCcond)x_R ضkWY)|[72%:9~ $?jd=AQErjvdq,ٙ&x"v3^Lؙ Ȭ`-8zk6k"p|ajer`EJ{+bޯ΁s cR3l9}cl!\lWbVow!CWɜs#,Y*"]ܒ@lI_F^Bx`sFn/ Dоix9T.zǒj"„U@:u +\k.!`5;A)*@L(&&!PƷJR$%4 g<&T&!TB'XqATfTh&~Mɩ8t1<;>MP}W'2^G#}em|K Pil;S"7Gdv4PH'L Bi $3M#|͌`j2دZQ"Tw*3%-yEeE)â3PōLLã!USUr h8&J%yGg~}b]RHP AR0!we'5Uiyb3,*6;I:S8Q1#wj+BBD6U#وqjHꀍi diq2bs; VPYN ^V,@L0#SK=%ގSKmGjdZ2dK [ j%뫤洅T[_w0| ϶m;pkh۬v\9?)feO;sf#N3)6t59psט\ȬƮ̲l,L3gjcf66jv9Ue36SɛM^/3Bvv*^`9(ףy25oGk+2}byXル(&3[ +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-C" 31``d03.0Ĩ +cv S%/P>5e'8~|fv}76U5̻V!Z؟kU3S5ELV}ն՗A_}fjQ닯V +^$`ɻГNWG7Og'H\A7J4pi9tW&3 0W?XY$鍵sa~_ULΞl0[tN>e:qn(b<2TDž§ O>,k!NZ +LYlckmcB4A@94Γ!]}.mZ=CHpd-'(&"7Xetζc=؇ [{y;CI/d.spc=Aj1c$#M<9{~[uMYpӾu,. +;iоJHu 'E!_-*1Zd9|E$\WVJ"׈F>b;:aÆlSܣ^ u%S' |͜#{5eu>:eIȩf%/D.XV4 +6N_z!Bwucھ+L(~}K}T)f2\x R\ Պ8ށWFKH~2dOA.x:{g,6&^&*j^n'kaLirm%}G=oK0(V 9,rxsv-;2Ѳ)ԎMy5c 5b`|Ɩ|Mss bݘ$cY2Q{/ G=;ލP{i<]%-]%-]'J5,Q5,Q5,Qraat ˓tQHkXkXkX bDN9)nہ4d"ȫ +MsGN&7A76*6ZDN-oΆ: /g",@mԮs Ha 0~jpg+>ϸDB c ߟ,oI~8ߠqVcQXa˜a1,c1~%x,snex,ƭ EUcq+cQnex,ƭ eέ UGdvYf(,ʮ: ˌ]u@eWESGdɢC(,3v1Y]uTeW˛Mq4DYDdƭYd#p(y\jAYtoLq cnV~]NX^ubNy՘wh*)' O(2vPd Eq"g2CP\>}Bq'O(.vGP\ }Bq'O(..As 6 6 ŞO.O0OsMF +`;'W6(`Sb)—I+hjѱo zb.P.6~#*Elf^Gq`vxr5 F<^ 9TAOӈ ʣ¤aRw$0 F440IW*! ,F(~)0 +:h]ljSfr9sIM!݌R$~:Ged/ )ֲ8NHJA& +? M[@] %|$./,E1u'MmKolͿi:g{`w>R6%vlN,[~tNiߐw$2X*3kh%)Rj"W!t,8^ȰE]oI CKR&b4^G93p{lM8S ,Aī%'j٭ 3&^LIrلhOgaRejB,QTERg +;=ks$Ѥi5O$JCYv< 5m?!c1D(=pyU 0B"f 09c?¨ǦExQ$+!o|xƟ}|(Hɞ*1ǡ~tTgTu5AÃ/[ 9Ȇx=2@)R38;&YJ0`tH*QHKGszu5ڛw\ G⌤!4;MGٵ +f"Ar b &4E2k:Q}[D),D0\(8'S* +ioVwXޜhNalIA Pk$pd.`N-];A +܏PFi:$~uV@sr0>|2RV12 Av++MЖf;L36KXh#Ћt9]v(j"8nB-ut CbNhh,0ǿ+eЕ_$m;'IA$"!'AWb44QptOglNXJuHb!?"')c'~#f' *ɧ +P4I6e̚C\hU2Vl܃TP"$P^T2,ss%ԙ00;&0G|@0NHc(5L:p^!+81- +P8M^n>U 0%Ƀ b1CDlCjVQe$Z)7030T"'71wFR3 v:}LaѨZ34zjQ[ێ@ 4}U%`Zll@Z*%I4f/~h+^L:qAP Br(#1q<rBIT^+O/~p31kB]&?" +hM p>P6X6}4&6 ُ;aYsR%LR*ՠ>;'kcF=p]ʌ!pnƪ&I ͭ p#z]ݲu|lglam4VXJȘn9$f@u[ӏFN}7,qS$N"TCb +|) W#y/`@sla0EFDsy 9J\`|P(_$H/B4I'gS pP{Y +?pY FDA U;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*!e3UV5,g(/= x5ՎR?D +1Ƽfeh]"N,ie`VDJ=Ū&89TE TV T[ױk`6\Hd^r^vy\9VQ}#=!S N/8VsG41wQpf[ }s\ˑ'$57"RO`S)u^(bMv*MӶ8]"*4ZDJ)M&8Dp.5aރP D ̑F ϗ-l 57@[m=JHvCjwADfjmo2H%5T`Ƈ2,QVyCkm 9|6e#_O?B^KeaxLyی AR<*)Ѓ0| .Lelͥr\($F)Q5oۖ|Iw:6^5_oZ^pa(UNxFA;_ ^RcM]FtyK11e/gufdiXN"Tw6ʉ^tOSBI +&jB v8t*qBJ](i3*\!MÆ^ph|c{&сw( xH1M8WMDVfr& @n%0ɕRƶq L0HHFKSyT:*qS5N)ܷ h'zK{;nBh;pEUЦHu DRX\" ҄IR%#ce{ R D%[0U aRGH56yFu3l)z(zxʗ6m\1RCd+%kK37 ٪t5f)!"oc5cJ@͉ +B#&l;™>(^%4pUǰ"M'`Ӫ@M.t+")Sbunw#ĬQf7fWlSMZv#Pٜ-ianA S}ԂjK氾=nTkUmr3901юYrv:x7휛t`j|ÌZ:@j5Fe\ef<3 g41O3OYxٶ<݌PnY9u6z>W210 ̓`yWFȕ0jR>)C3]Q +\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/ÿ́-F FAX1uP9X5s.t8sjVMžYԎF@]φZ,L}~ek`*[I,_}6jB]Ń=N6uvr+\A 9 {Swcsy u5EP8?\D)T,vݯ<`H2j tH?Vvr#p5K2)ji TxRVFYh89FJ t4U( 9H7 +d188;Hndhdods`&"aUJs%t^m=7hᛲݬo#u-nb&ڱeGOjvV佫Ʋ~㛛'|7NoN}-I*qI?{zvsuM:ëg_>9:y*C3ɯOO+u]u]~~p'O +0n{gmx;{st͓'?M꯯O??;ox~g˘6g'3y㿽zI *??=~xu]u`Sޜa=Noݗ?k.J D o\Zj..wDW[ZmKݯ?; +endstream endobj 43 0 obj <>stream +j砡&Z3Bh Ƥq)a~XylJ=f:|!:hz| +O$KggeJ_,ay4r{;FQ?Ocǰ[vڧ_􂟄ɠ>"-$muoxYZ9u>ṯEDEJ|Q{&Ԏ{2%+ .n3jWKu-jF*$p(z_C("d =} }줱4`E)v"6Ft?Rڈȓ5ӓS[W9IiM5~QkP\*d/&э!s kWX y2B'ωH¤`=!"n (SE \X/&bTD? 4v }L&@i" D<ҮwK&NY'\>p3qp|bOB֖Eh5'd9li t!UtJM^gGiȲnZKG9YG p.vzHFfvƃ3t9cU񷂫Uv>-yHQ$4>TpbW{,.N55++:k>ߘC { xmޡJF &t~9aTJ9)˕R1S`#fPP]9K3oDob\_K0r{٦y49zTHHjRww r#{y/tqڻ$}VM2 +aAF>o=& Ӱ֟矠dqӝ>r]$/,(/~>aL6O;>&`&m/|z}4*%23 Qfŧ;}UD>'J/s"5E.Dg~CF(l`O7F*hJ# %d1p'qD*u$@d\nph¹Pf)aP8%Z}A0NoTVH|Pv#DOCO kl?#=,-H{yxڂy"eM{XaIćA*((8=,eֈlvB}8&W5Qj\@u"6C!Ȟjs<7 Ej都/erP5S85 +⁄/1F/虯~xIKI7| *2N-+6GAZ\AhY =y+a6t=pF tTt0-EEzCH8` +=(ĒgTDUġ;ӈR& މ"jY\Qc~ |*rKG7~EH@HҨEYiC8ѹɟh](} ʒcU:BnNUóǜu$ d.NVWse汯.%I"@/RCmŜ\TV_U~bVmFQ.L6mBLRc;٪PO[C^zxT® +\2Of"m\>N}*# +L*_1ȄP6V):!0SsK_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ڒ&n6 QҪ>-GȎnԪz6*e829;hIjƏzv}U&d;YVvz"4YgFWa LUecIY,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}at2IjJI#[LV5qUO,.|i %1d_=qBWM B<FkA \ DzpNWg\-pA\Z"<\Yg^otӾQ\nzP_" +$Dv b}] SaԇiJϺ:G1k31;*86O7F]d}ʂt*h q#M:P(q F`c*ۀAUbթKYnR(;uU=wCT:9iLB6w]GIdh^1)bdG0rdЃ[ݯ&9L[A + 0f3Y˙F[JH"*Λj4̲.f51,~4 Gf>GJҮϼsB;5"P01I{aP)zVE+(/ RL0ֆ]"cO>rշ.[xU% $nPDQ,\ڽMRyT +O^k!AH2d#1 N(Ac.GJd89ws:lslˢZGv|c-xwC60 D"iե,4\؆ x(d?D10s c^diʬ3'>˜*ݍL!b+tuW\dyxU+&Z 5)%3NA8dB䙠@i=5ʕ%FwAWba ;!ʻ"r59$ @2g~%MZpjAw3yVX6 +k}0^400xfmaS̊1ZQrKs >Bxb"!eپ鞯FI7.g1k8žbkm,WOB8t5| U JT 1;bU+ J|`2kSZP- 2(^4A`YO+(LEQL~ 6eՂ ^0 j8GJb2Ai#QrhWD5G%A$Lh p"19yV$iD )@0 "L +fM& c$]Z EU` ڂWDe(sk!IPt75YT8n̹Lze0B.eb:*Q"yljMLB[/~JDEҧtF`%5-F7}(|$<@*;;k;:M$#01 ВMe0G2 M¦x&sCq8 J=2 * ,vd":, AeMCQoDb¢oA=LUr,}~YE BEWg+%Lu/t TgVSH;kaDq`|n6 + ac-%ӓyQAIQ`aDʄwxdW*A4]+I 9M GJW=΅~B#[Ys$ ",cMv@#|9BUM8MwJ#&,Z5ȵKxXƲ](|{yJ_]GEgངM%@ii r6  GHQj ?x8,Ih5S,V^|j"r`#A' +IPOȒ抪N좓SLco6+Yd.H4P4ї@Qqh;K.!Aȗ ҷ3U-s)hxw !066R'b!dg*CgJ{m3>KPkL\ǩHMC$h'1QiIbFj溽3PeVUF4St^D5hYHh|WK9\.3(3 1}Q1 ^ts18K} pV*um sŃ0$uŸĩ$m;n*(1ro-Э*AmME6 c('u%*Y.J8s-N"38brj HS*RYβFd:oUGX:])%wğH,|ێBMȖk&$Wr*i)KR6"n֬gTш̂4̤^/ݍMueg!B&Zc4-ڄvc5Ò^/1.#cCr4~8< W -y,KBatwj@aSUm*+f-b7l[K(W&84/JLKf'wpum~ݐe@Wѐ-A@]X5F$dV%8[~@f N 0nAO(x':OPքu ~E"u9Q@Ԟ%Kavd}Dh24T$'ِɒ23ѷdlIQT?cNv&kP +g8ͨ"vS^.7~Ҕ'NS[XQJj JH`"!jJ,2d!eM*N[д5 h茗"l)Rٮ4P5%Bl92zЮ·y9P&)=CxQ$bPcɺ#'01h1j'8tahȡh0qG7s&0׎lK`Ov2J_^tn#=Mó֠Me IoX?[(p ~X],6h"H(xnQR"=[YL}B7UY6D%FU48ӏڎ:hl+hX%R6Zsݘqkd㨢(jr`,%q&~VْJ5K?*jq0Bv㎱ywB* +_%ij48 +h0Z-1nWO\P[,0u٥Vă{]`ׯǞ7RY8J+}~&B,L XDt9CaU\}liꛗN9BLt¾!, &]#i0J"`4Q* +l 6gsl;Z^YgJ rIVN wKXKSE:W3Pqu/nbA,' EDJ ;6׆]- "Sa넝:Pu ]4ʧ&F*g}8=VB jfiV4J"8S(p2 r$O?l,'c[\A(h7@B04b&o-6,3_՗1v\=LpQ{U c nB$5%*hQ Tc#z6{gfulG)-7tŮG;7~%NItzs_SFVcYj7<*M4@8Rz7 +YH`;yZ=#E`07Rĵ,kXx +|-0pàqH537́s!WDLKCURD @Ж)09B,㠼C s9*Q{ k* ^vC d\n ~5kL[pB%6)8IDTZ*Ǟ',Hz ds(ԁ#s)xRP0JA`MGhY e"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"fR1{MagY%tRrk!%2M(}QO;_P7QTDa4Fc!b4Foݔ خT ٩+'ؗ-&zWݕ_E XO,7W'3y_bH`H*RZx~"MS/]1,.]꺾3T,ux U|Bj&i]$"N ,ɉ|Êr +ʵ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% +Q UL]. 6X,Z1jz3z=|v $Ax1XKoXr!)gTf{KeJ `ch P`"Q1!_3RKU O,8 , 7B@R|Ih,4/J{ +%kMxX,Wv A.?.1aMS:upS26[ r.!] ++LT#2YbR_Œ3a1Ol͗ iR/OP <)RDM!HNd0m{W5[T\H?$)+8F,__aѪqQdH>oko2=&yEdeަuȳ+ޝdgM rH(3\q+SBM4 hp0*U5N /bVq3w<a ̻B=3a=$s;.$BF[3Jj|[Kr^n Kyp]tF5Y WP0Ub zp8E-/>.Zz!^,OXcUÞ"!ksApś`Az +4[~/`{SD+[ҋj?3@T5vGu/5{hEujYZfj i +ZpF@폂f/BH3ƐoEsuQ 5{Nu_蹪ct5b.LBHOe~UvɥR  rQ! v8IKEAA ,>$}E~a8ܵkoV*S/n >$8ZLʂ!JIreOYye8I'K\tdw"?kU4 (I}ch-0hݓ}ң@h&#ɨgE--X~dWaR#-H,:>v&,o +竧Vbf8a\/Xp "p0UQs)edPPtŸT؁P<ŶE4q%ÄfSj&3R_RBt +%sH5wpY5 [ѼkS\ykbL Ul v(Iu#Y6Fs[Gy%%7E}E9Hv P + \(]QYS[Ś-"enktY5$uP^f+ԙKDE<x?7ޅdf j+L6YO5TVŐ\qf"tgf\D&'8-a?+V5pѫlұfmgn %ӘJA,Hp\$x b)E>q8B6>]5ɄLmL̏&X :8AɮJJPzuDݑ˳Li˭ցU`Fd^Oh{aIG+s6T@52*AHZWuSY-B酕?H`:L?_pE)jN]/ DJYVBh*Ҍ}!zb hIW-˨Ym-hr,uעpu?~,O~v?P;rN.//Ny܏ޅo?їOh_a?ai{ߴUEht;fu8 ӏ~DaY+6.ѣeu^%KQb Eu4Pr; =pb/>6&&r@V;J[7>dAm8Ug\Tj +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?l߻t/U@&GZЏCgӟ~~vnZsn{PV/dgǗ^{?Cf~䧧콳.o݁{?a[ӳO?Y3?ކ}q&Ю>ӓ^\>m|'oG;{Ƽb~о܊ݱ WWe|7/?~q~zyr뮺}~vO~}/^.v_CkůNn??݀N߹^^}xsvsre85o7*ü>}|Gg)k JdWNo'olF< .M<|_/}vv6޽ëgWn6:_;utԝQYG9stf>>nWgAb7NI);%uԝtvJNٹK///_~owώKr/choGyޝ78桝(ޣbm>Gt#-O\>݌o+,fKeןb~jx#tZ3#Qtc']|lЍEЉ g}wg^Qp΄}rzyt& F3L8;6,`xh˵3L8DŽ8lf+;sH߼{M{տE]^\}~_6;>9yqxX;5g$0?}zvsv׊Fio?;?`)OO֞q_C\N.vګ9{v;Cgݳ㛟n`Rr_uKt=QqkJfZ9p_`>z(=\li? A}^˳Mv[!f\_9ut5LnK^ԣhԯ^ӷZ9_o,;+gM_Dwg#r(? ߟ#`D#Q>fj{/MՖm_ƫy󷻛6|yywGP=Xg>gQ޻g~Y;γ +γ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=ܓ}(ӱw7 D,Q;KD,Q;Kjigz(;%JRdYMY-KSk locu΢:d˖lp% 4іl0C7ܚK{+qOͯrqݝ|#ڒ;y ȝz\v%61bށ}<$͗%-ߌܶGQ`*}Թ^oVmopWӖ֞XMnAm]#n2 )ll]D;97KvփRvQG.hu:zWֶ\ƻHݝz+Ю0 aGmǭ[]lƆ 96lmWl؛XE=X??_>C{n^\r7~y%O6>w|קE Nk5Բ8/_\M />99&Eߞo2K5TǮSvMխls-ӓ_dSOwN=ݩ;>UǮ~r}ܴ6 .uON?;tӝ~O_[Y?dp;i ikt?nΤܹ-[yKeAv(Z"`ӧրD^N/_]=ϏO=&]=;>96ofE:m6-;w?cȍ'l'<4W_>fF( Sz_K/X7ܤt=eLڶ]n.vߤG_{\Jjw(+=r?e 9͒\=W~Byn=N@~m'78kC_N [!< +;$y5 FI~Hv򭹔w$yw' + + +<;m,J^{Eڍ7ڻulnyagߎ[C?{eC@?[lǁvqܚ[Gg7WdN?np-:r;g2gas,;$}ǂs׊?~u/<pp~-=ީm(p`~?Zm܍'n6H=KON~v`L@mo?`ɰw,Ǩ~7̅><{ޛۉr'7%BH?Co/Ax:uhZ"6Iߙ}^d>CY6?w߰3# +`/ܑs&^gً q};*.nw~էJqŴ2gbw\qWt1_ޤ+F0/oWr\DS}W lebӳ ѷWz!7If.דw=9|Q5;TOO޿ u} qoڇh,k/{c8wpӐ P_?&_o& ϯ?O?DUI:< +wՑi_vK^=yK m9ȥ5~ߛl-aî݊b|]`h[v=إE]dmM#OoC.bvKإ׏]ro\&u[.жLx$K,ж]v.m[46bm3\*h˳InyNAlnu<M{!`E'TP1=#@8y}-Ew׌OC}0R +?ǘtSxNv pa*Y^[ncܞ ]eșX8>yՏ.w2}ׅ|^]`xPbR9h?ǻA#r8Hx'{ケvGk 'WƻvpP]y]Xx߹ uP6f;q_oZ֫ݺ?Xo ߪ >A[;AEanv|͚?:^p0A`{ǶaF:MOl+m$D<ݸ0S{xR_;M{?1 ~Җ9M; G܎a?BY|ut'C9 6?o4[吝O{?GA&NGAC,؂4Z>zwq;y{ؙsg^ s5?ƔkCCՃKUB7]"vchM ^ %bc4Ld΃0ֱDXlmgkjl"vx8c :P2.볙v.LY|h׺<6僌&6niM,Av2.ǂ{%T:4 +NBkZ.J]jm1MKowa۰ |˥ƽ!ڟu W9cc{)`m@kD(+0+CB+wKc v`CrG6RHG O{PJ6g8iDn%M !ՓbB30f<۵+0#? ,6_JۭgO<mݶjE840`Biڳ}yyёh"I|"}ӆBq&}fA=41vA!W6sDxЎbB0kYޤ +Hyv;*]Cft(!-i>PD򎘔BrىBq"Ɋ,'T?fLyV`  ?s{wZy +H,H*APYJt}XZ)GrҜw=0<$&Boqac̫T(!V aZciM$Y׸8(\m Zp2y + REhL8/*="Yy9<X +<*3'vNcj0 X`NJdS@VʚHFs [9y:o>)sSv+57O4r*$U` sYm!L +dxN>R HTS ,[B +4NMPRp%UfmvKSpe2O;eȨB8gHļfj P~ -\@xC9~(@4Xv/ز#6mͼeFqbɃJD rLE\tEr5uxNzT) +0{ gM+y3GinUV>-icmM"dNSlJP^nWtQQZMy%{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 RZ;"!Bp!3ޛȂ(2ܟљ|G-^b6s`D@4:91I<3IDȵhlh~>'yV@3W?E KrWH`1 4ČBwmz9;m #@ +l)g޹ gbb 0UcC*Ȑ ?g < +I R ¥7(>KQh`&ex|+ 4Ԧl*/C.C!h;eb `jrV?̅EV:)~=FJtw}_4f.x;< k= }Ny !<9n.x(T+ VV9_nDm̲@ vI 8>mK*D"Z4Hf^Gp.Bb)M2F40}2/IvS>&0k %xl1)FCд% +T $ [1` 3f@ YDUt>w1 !_`7*Lh8أ'`m#[1'8wϸQ{ӌy)Ѓ4WRf@n,gYVac,Qah\4)}/5MIѽ!20ϟL Y 3-M +3󬅻C>kA99%9+W\}#VNA %PD ᚻ,{ k:Y2QkwaNlkKp UpT%awV&_oBKvO7[J)(JR"/s*_L0DJL ۠CF+KHG)y#{bυ X8Ra20%$YL`"|~MTR-cKA S}C2K&j +frE8:(B"amB>;PHQ=+s 6Ȝt$ q3 9dK3LRpZL2s z8u6idɇŞviD}ƕcȹd^>zj8R[W:A39,T3b+xR8d2 9c ⡣dp5T eR;eoyg:'P/)pĹza]4EIeO(;@)V+2zO#`!P"\61 U瑵Aw' ML7[Ш^r-qkpe"~O:%Tv,]YCG]Ef2q?wcL"=C =4jyjr:ŁX ]ZRB׸MeM"_Yz"+|pewAM}Ғɧ1K5R LP7B%=73A+"46$tl_$ 8=(l$hu6"xUUrӭ Y|]&q]!itdoKbY/n=eKJbuIgNi3 }dY#gSx˴Ae-uQ ā&DEF$e(2f2T ,9XĤ]FR*CR>Fc&Jl>c62[$B -]^`iMdh [jMS9;̒S,; ^$~waΕ^W'bwv9nLK;H!L6YFl(8!6uDt1cOHY^bfTC.̶D}eL{X +ݓ6uWf/E E2Bq9fm2yf IrDI0b^͕|{44{QrUi_9東@"3K3b]9vm<l.pS挿TZ5{71/RM`G<P~w7jI/Y կz C!OowAZyVw•Xd`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 Ɍvim|vȎ]1{iLT;'/t0 +\)>d1ou6\1lsEE}t'uya!`h1DQ+ ET4L^Mgd" nKWBBzm?FwWbcDIClxjbU)0Q8G6T. 1Nt% 9Sno^CN |{g߇P^*c!Z^r;o;[bZLO|zg{ w~+ηu5atVznݣ!M::;w_<[/7 ım b9@fvѲ;Z{|gnnٍVh ŽiCuh~vN2N54ZHm(_){ԥbJIQj5BXB>S#L ++UZ(q;(JK U/3#ܶpT.8 ð24u5YFW/U4[&&Snʙ LXh?-_K(OL3`$XSfD\˦y A/tP@O)R? R 49C.)B?Rd䦦㴛lV9TBaM hAߴmih[K0% ɉK,kfo)^]Q&D+` ,g +KY7#Y1K'Ge]Q)sg@0a !F贳߆ +:i +]$S +,e_'E}ۿ + Дι~hj FWBBfK74B! +N + PVi6%D[_ +vhgCRbUZknvX Vͩۗv24?M) EJ?y.)AD(Q^UMmϵA.ύ聅ϩ+ +TU2Z`%݋~ُC-U2wk{IO51@4kbsX*U hXYx47׫U u7=نX<7ep,H*&̝r?-,6{Xм'm3c)^$MI:7Iwiϡ^-" жMGERHyKa;j9r,?gƵ %n)]ajjZe\tga"*c>V<3ƱP2ʡ&\u]vU]%,\!V_^U;ՇpJ>임Yc-Wi6/<,.cQܞJL@w/֧#yxU8ܻv 8oW;X\۞k\)~c+QWӣ?C[ˤ(~l!5MM~iz{nrW_8%=-ɭ8.OB,}7냿z~X[|-1#xF1TŸğuYO7]sEESaEٺWڳ\:#ݩЌhYnF1MhR靚aZN)zemJ4Ls?Bib6\4:G}thkS +uJq)3ݔ]r,NH.lAǘRS +nRn?v?wSJERr٦nc8MO?v)FIQ|)&빤م3lh#m4r1`(Htcmhvn(D~SxI6 0эmfŎ@zpi>1K\U]sm3[yН~O3wk"g]I[K (/gڸH lk@7 + TL flx<ګT8aoꩴ.{((&{#.B] +*@kKh6A@n^yG +߿ @]kDYiuC,/mI~ CP$Lkɱ0wVEbx{)W"dvPxmXw%$<@+9В=\e~\#HE"^Me=3lc,cܮP5MlΫ$KC]j3z,y%R;mºAlbHף 6t H%V9e jڕ1k֍5bXExHXUveY9W}"d3 +m SϨ>Mb =gY+S +X +M]JԭS]1"@Um~VRCi51z^#4,n}L6Pu5eFcaa}N-S,ueATÓz=ڬX֗ 71S=UрQv6 D:#zGn,@zDO4&< X{(ڢ ^wy-r6wN}иd.Ӏ]6quP5'۾Tn;oSWFq;6|$WhB|nZŰi"Gkڟ/(%;u icbx#*jcTȀ +홎Y!5*La8_UѤpnW +w<S>BUc~U?{'F"\⦡~I=h]=df« +B VݫӋ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]EbA +cR)w,AW1V( T% L{fq-Y:W\QA=}6o v4.Shq^ɭnRc{0~+9"i] P^iƤVz#ѢzAqETJqž)?KAk/Yߥ0*d$ ,K u|.ZmZsa kAsnH{K-W0cTf@i P48Z/Z zi"QA,pb聾`.Ga%d u8ȚxjNU{S޸_4- Щi9O-f9o5C_ENz~?gdqI]nPu.נk%t8Z/ǣnSW dO,!K݃j]Փ)GD?ag(υjw AKFCHJI3-$\}U'Y^E9 +A +9ϫHzJmyzUiiۍ WX:p}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 ZclaDH5ZCy 6.t [dgR@}@ٴynBV{aT)ML,DiwɒgڴdE5u5pfo|X@]\ɣqC缠[v[\lːۦyq.acPňTm׶I֬[;rζםE=Bgڎc?{=Zi])eOژ ͌+2\6;~ ׷Yb? p5dm額tɲt6nI0Okwթp403'uȲXs49VL/F2̑&E pl +d U:K?m)X=y)1RW &7h2RE78icyKQ̧z;Afzhckݨ~tӫU(^_: B8 {?Z*lۈ]o;S ^ds{y;>鐷!oǧsy;>v|:t.og|pv|:!oǧCގOv|p۝!oǧsy;>v|x5>t鐷ӹPaHt鐷ӹy;>v|:!oǧCގOvq:t.oǧCގOq㹼y;>v*h鞫y;>vpiv|v*hTNnLxq㹼y;>vv|<@-_3⹼y;>v|v|:!oǧCގOv|:t\ގOy;pit@^یo90rj΃vmrtZa99;stT90.0Ȝ]mnqs`Vs`D}ut{TN9PAso9PYsU>[6:|:8 vj|sGmtT5gT:88etpţs`DM9:LW%9]ut;\;F0X9T׼ݴtpChj69@dnf6bؓ#%Uxͦ*L΁&6ttt(ܾwMoos`OrT=:*w wceRQԳ{QVuw3ZM0l \WLud1u¼df/(.P*UМYXԸ +W)ك. +ګT4Mp^4P]B$So +:hLUK3^}TkP UNv n5_a ]UDžWP].4_: +Xй +/4_Ah;_z +O'4WAbM[6WAn 4QӅ*(  ea@1Z[^=szWAh %!1S0W=}s < {OA]¬Y1=evNklQ-14fmBRPxy;<x5WAZ~I͓ػ +?XTh\n +)E -5sQtem@)1݃U5_A%$p\J +WWA\ +1ñy +0lU )zml1Sn0cD57Ab?;) ӽD%HVƹfϵ|s=1"%)6ufKXt,y 4T/lNEU' W/\w%4cE cCNX7B$E5mG@n5 Yf K/Al Ke0fXN{ +%Z\; n"jİDhY0V]sHդT +٭\I ԧoVsnN:jb97/&ڼ 蜲+!q9>: =Y{</X6ܼ +;LK3;7EMW|dh+t+:g9EfW_mwLG ϘdNnW בahjܮFn฾W I&Y2tKTcxK6Lt~FR#v<KgssUtjC{Saځ6{0ۆnSMU=הm\M-UOp21n_`,31=gdW|\U7NFPO` Qb;zba$9;s#vNY 0.ٞni4Lvg>5*b2,hȰܠU'(2,IQ-ZS(fOܝ2$F.r\:mRpf[\$ERH)*[?t9hH/lЖc4$p'@1gbXN2y7yPPS+@Lo2 ^%#!oEh[Wr(~@⍂kmQݢ(k[ -z+;ny Y.VPQapTTѢ( 4hjkzԫ1.V;>(ESuY%q:i(NKg^~ ǜԉ9 K4זj2*C[܆va +E` aAQ`J (C#z'*gN5w +N-Y{<s3dW*Kу`@z Uh;\IǻMd(7˔Eתh^ 8h-F4DR kȋOѲ-C}4@Y@-jRd wZ[0k^ej^v z.H4`AF濶nG~(6RXS + 5r+625<;Tz!kުHogt؀e!OjߓW+oP2uۨdB#/Fen``oNtsQM:!_GR䁘 CĢk݁֝qΠ=Cڜ`.Ռ8읆vêߍrAj6X/]6pC$&ٹkMւL va'kAtP0͑ňbddɬ)n22ikk!=6ͦǯ.-O;33rmHds;W ER0Ԡג=şdmT]ƞׯv4Ҟ~ +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@}*Y9 iP(Vj +~h3$6`i2 +ӁkaЏ==>YgΙ-a~4M7#)x2 FG d&{ qQ<Αw$Ё2܈HT{;@_t^_,Q39Xϰsշ__uֽ7,e`7)%T鵯ݼq+7ܿ<{z _vܼw}o^ݸ}[ozx.^_[>c߹iO!^w6MooܼݛWܻ7soㇿw}ul;[o|%[OzW~)Oݷ5ۯy߭W=Gg7?=O:}-?VɝyߜNoWQZ9ly^` WVA7mVb;wa3ꗯԆ!<@wcj/C# ^ .~[~;3╯߽{ͷO߻[7 iWu1NS+_.&q(ZWC1iI2%[<}.Z"NnaU.ղO8wKZ_=.%f{} /'UXfdY}ҵ5KVrAgou:!nѮ_+y@nǻNKM['.|F՜q&i.M7Yכ vaY1|\ԣ#k`Ћ`wy<[ 9b[x]=GʼnW%+OeSYT]YMVK ~ L:.fdQr9S*GtA*Tzn+,%7{\p_Y61S0N)8Zuc"-`pa<үqڏ:L\ $*nqmt#]قvEDy]Tr*Pq?J9_ąjMsqZ'`\'<е>9RsqW{L#'"C'iJL^|ka7,޲&zM[4g-\3:@f~f&J 82h`1S6qbooPXV.5T7׆QHɺx29e]Xh6>˦%+͝ +F=SXn╬> 駩|5_aW-Vt7A%{1?*`(%$G@rd ej49Z7u1` +i 7s-<z.QxnͬO+uۙQaHU\Ux +2`&e6PnPd-+T 1Xs543 +~D;ej8݅UJ-pAJ]HQ[z,Ky0"Q!<{JKڀ 8 +WO{K,L,ƪ + ylv}; +U; Oe CEB_ӵ{{ƠcWr~cmuAEO 0>w"n U"?a$ K#M`ʤkGdtkF/A{{o-Չu4rY= +b4+WxiU7Ֆ[`bE/)JTPOj/ VMU(0ɝ{\ +,Z|TYfP@;Ag%5IBeab| Уx@}`+rY){^ 3(8et ꩛2C0!$—P<`و 76Ԥ*yȻo O* +yNK?Vʠw׉3W t ]8:=Q%11n`j,xNytW-'Cyz-V͡w#^OuKEb95)rƌȋw* +n@k&`Ϊ>[a:e}-򃴞X|1$Hx\㉜K9[h4LӞ7BPe_cNB~yL, +*ZR Uh@Sa1ݷ5<ԪǬ%Xv,{deAln+0Ҳ4`3+-e7 ',2 +rPg +:(+e.nɆSmT>;)P me $K[*hZXjo\lO*U7~t-C|u7O4qJ*#@N1NΩ[lu&o A[ ++Ei1med`L󍶢HyM+^y2=D&0TNud9]QP!5" T;xMfbDaE-T9Uφ7X:w 0MB97>ԪŸ,&&7LtE+;[@pF5u9O_bjkLU5΋ %Ɣ`05iSA+RnT!`^ؼ]- ࠘U|)x) V9(%QjdxrivJB}j[vEx͙c5K@oju +IlC:3fa_ybd/zMsp 7:~*nT U QZ@GpP*w)^.+ymj jI3U%q9eǒ(,^(j 5 i`TYEBIfilq`aMnHf:[B(VP2{UzP):|2P(FV~ʂ:^afL=4Zuo0s +]tN;*rpwy*[U =pLan`@|KOb2?4_g/e"^ +fJ37uB 2h +쬻0$e"pPDLh>]Y;\I0&u|a"? :Gü]8+^çgDSSV92aV kNW&ߟ̈M +IB@X1C,zA*\+ W_~/6gb\]I8!'KBy(7 ޲2qX(EϺg]B +9J x^*-N-HZږK Y^UΠbW>lA re1i 9˾cQD a`O&ы +3QA+?I.-ռRO&U AZ_T;wUmXƑchs,p:w0 h4 u8^K 5 +魷$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"y b4U1b6`or.LMrm5ݔ7}!\&o]T/B [5~BYbs3+eUq F`'ذQOhU@\/b/p +FdY x%b:~ .v}EY:8~Keơ$ds3kJڣEysc]P_٭c%ݔf8 +9X-=>-j~-/| +O{w޹ݷO~;7Ow}sLg ѧޙh.MCFoy=wJ_>U/<.^ٻ{l(Cl}ErqbpFÊPε]>[2[K@/ֹ)۞/C?JzL.CPUa(g0Y_,bT i;g1) +xeȳՌ}VkC Ōq{ MHiaX,< zf3vkos{W^ WWڽvV_y{Mo߼v93_+lL"&)+pm NmsRZXC<\(aу6Qß=N׌`  5`<`lPD֎Z-@B&Y3zcح5ںb:k6+J~lQ"uU] vOhf~ +8upWB4wLʁSL  xI jՅ]@ KXH'^Wj]i1PY5"P\:wW4 na*i5ʭD =h9xAP7K4q2Cpc +%lckb(hcWU xmw9,p]GE &g8iq"Leqg h[ۋSX3i_I ^cv%CH6.'.ZCj1@ +=z uL.H"5A2NeAk.3!9IN`%oK$Fg5GQ2IZ@XI`X+3{d80M\3I dHѬe4b <8God2kg%93H8(=oߵVvYA+}\W5,R2Z"whFI +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[@\nrVzZ D~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ٟ9dٳ (.YiRީ/#xU +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ʔ߄oL"[\2}<r٘#,̈́ˬj-sָ\ɡ&!ޚt_0#.K4nN 3t w'W82T)SpW]Ի1KոގdCIVzZ2>TUNl+ϲ5_R'DκX<9V*\BX\ !KE7Ҡ&XNqVwxZf $/$5,l"!A ǫTW&&y.J5! q.X`eM 0s`)2XgJm`օ#JCiD ,䅑*e$֫ʿ-4z^]xАz]s5+n^(`g^OHYTz0W3+D2 B Au ^ܘS3wVY;3u5W6TEY>ȢފtI<%*hpw 0XJ Լ͵ui^U^o7,Ei=Վ͋xTlۖm;xy`0͌ .<+ ^TEY ,v., F#}DCp e(RxAzU7*NBj+Yrx/RQ _ Dm(ce9N2!`RN8)bX| PU*JqЬTrRgV +vb'F LMHG*Lb.!D>N'sCsEίI" }LAյkg?ko?ߺy{ o{ܸ{{7z킝I~[߻ߺG޽ۯo-'X*[~_;ꩬG֏Sx'x]?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)lt׾Ptov2~*r7n=Uz5{@|[m}G|p?G^~ޭOveOq* +߾qf_o⫽\0zn^+J4Ğ,o)syŶV]b&g1Y'ye9_~TJ7 tO#1gHwr)+_}=fh޻qeƯ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~W߼uۛzo9xnކp6{|oWS!B޿yAuջ'{+:>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 `R(>[)3B/%|\jEG^-_/(~Q^E +cD 9lk/맃,?8KA,A8If/x]sKCh2I-f,:%7^^tp8F ?n]aɿtge 8я2ct zͧ0_e^p/$g[?ӟ;DC/20:W"9sg^r1˟Gяɟyܙy[k~7!ߑWsF&x7kUdMz?/zKaR|qtG~bkvd+ڳɱcj^٨LkCM胿y'߲ƟŹ/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+_ +-k#bAגtnsQ $t\ '5oNP7OmOX>eu ]sZf|ʴ;M>'~xjd5gՉ>l.OTjk6)/u^WjNja:a:Bh7x%PT$D3Ed߾בr =dZm4B%dOFݕ[h4tOeqI&lz#ιTpVGZ +*'Np^yRG4|ULqjvka\[torߺb\ɞN_i"1889؎O>M 1?JLl\>"- Y,iǐ_}9>CȚrN9PK9"fNdI|m}ow. +9ϯw,aAؗBa%bXI$b#b%}C_kbBf^Yٳ4ᙉ凹ejҞkߎ!fOg;ޖ /G#{<Ƞ0tHz!9|8'23-)kpd -"0CzIF`A!I|$r!lfiᗰ-Xh>ׇdq^XR^vz$&):{âEF(ࠗ ]DCg  C"Q4!L%"~AzJ ~ix(PҌV$jL +9Sf~ \1շh~7N4*o?~ZNĿBG \>~ۿ^gLc5g?8EmA&"H*@a +h*i1/Ǔnx_K'csc8ED(*H`n1 gRE/ !G"h`>F 6;kTƪcdB(wI`aޜ|rؙ&gGeY<ƣ!xg"I0[!p~?_T@ٯ=GdC 4n0oZȂ@ſ2Ag¤( +hCu>n*f':og&t̵ߢCX_ +-?M码٬ƽC׊Y ֮1VCFwA1^wo]wk0ύmf-_& LuG1P)xDGq7ZzfwF_(/`qb#Q & C:ϩ9M]Ky q1N\6 ǣ]ˏoݤOx"-#sR9u ɐ~OH8b}'Vv_H2VU9? (E?fMwLt&xtg6>qwp*:CSzHuce=Ws:X]2Zrfq^7WVHdvs>Z-Kb {0W:x.xM~MMo] A\)]v<~^ +*A;@ﶺ"ֹ+>z=^$Ͷx4 } }owuU )j׆?ۜM77 Sm|:T}Ė ǃb[ L)*]|/r;𽻝A@hZ*p%\W؋mX`-ӿBQG!]`%s"b>XzRNk + Wz GS|/;"&7?_WZcASGx+]b}"96`J(؆LV?xqye[f dKϞ+tmw4V7]~ JXU 6TebҊGh*XßnnӝQ_fQPDč;/wZ8:U`$R+A71wIqCeyF8*p{4!p WJm 0(E5\%ft05LbRw>j>KfD45 rMY-%Ӱ,'!h- +y!0t<Q?.Z̳xB.E`=Mz;rN/uLq ҟY&?+K'Y|7\-a:OdfSQe]ȓuYHpKrga1콊x̒EmzZox+I%zk:R$ɔS+0 y,ZV@ ZB1&rMEkPVWEPjVd62P}q?2K".^R܏pyT݃r:i4&F:BҏģFs$A,k}p@ku.z%1WהvlDݝN5'e t"Yv+OCŕFb:ķe׽ ^n8 MmW +>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ꍡk|8b +;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;;[sSvbJ{#c^Ӂmw|EKbD.&S`z7W.C3$u{WߋB8V5ž˟GCэw?<_,? ַ`3_  \7أrkFS\30j x/+`d +ï8[5Zyg+zo+@=C[K:޺ٷHjE: +m=' #{Vv6a̾&kTk95] >vטF} MKM_D\H;K>][G2?b!$?@=}܏9~|X?D"ꤰELj$J~Td+$p 7Q\)ZH/zKt}Jjg;ÿ?_ީ"wdm@IR8ZW\4mOLPHܞ75]S< +nw݀+bFrm5;5k[6-Sw#s#.x +ta[NQr κ(oت_s f9*~ *o%S G/>_0-ov.'[r$ +с÷?pɧoNDGſ%?|~ _q#3Oa>3_gf ÷÷ R}s /5\ um].U/<Ӱ&F] +)*wS@EcspDuvW5a V?@1YZL|4M6/y)N!j"=4W^wS~?W~p ߛPuYt7wi s ނ2/#j3fn\)pFp}wLt  1(EnY_A)cU>QY';oq%)>-KCd&!r䑫/+1]2ɫI:WPI@Ot%֨B@-FFsF2Sw3}lgyw<2"5ִD/b4Si[Bj4> uZlL + P.R`ﰜZ52Kd F65"T5|S0C\c- G&=0;kkEk ^!X |@kue?kDE4b &xVSIZwuEӋ=tc6y2hnȧ 91֗z63LH߆ +bt)"iHgr .m-v,冱aŵol jb|'].hWaU$?[qu,W|#>6/S@|'=Yh%\XvV4 <~2~'1.dxcq:4c,\E)zU܋'m{qz_!E|6#ߓ<'VeA [b  +m5hEF_t~J1jR9 Tvtz _>h6xq/.. +l5o^CZӞ|w}c׫d'}7eQ1i/ S-O%yEڞS*MZs|njYT?F>]>ttf)|`m{|lK):NzTBP͵fkI'd{CO3""z]\jvH~ceՠLrcRRm+abZZsGmj k' Exr2ND<vᒽs%s.+Y TfwvNyYIRyħ'oSygkI'ЏC23\06=]|rl2ˋ^0h%4έzCR\V0d5]B&+oy4xzUe6,V}'Knbb}Thv"Sdr,jSɗÀʴˈiӋd9V.mxr7 DW>l] +^15Dk~CU#E*fJmJ ,-oOc`"<6V3ֿ,?%޸Gn|4VuD<3&]֙Vfd3kU|'\p^:ultŲ'v}Uʔ 3BqaI^~7ກ?KSex +drT +uioF7Oè |z e-WĞnkh ^&3vc +Oz383Tr0߼ڝ&ےe7M9O+ H7h4$iCܟ@n "BٔZ7>PZ[KW~0? bjf fEE놠0 7-^" &Żߐ|;~ԗ1tعJƖ1務=zX\lO`]ɣ4N'ϔ=lvApe gਚ&]3:0 `Ak1NQc~$]$oXqtsⳇWdNmwL wi +DCO_lo 5W(.GaGӗE1}Έza~Ц:9\>3h#JՊ~Py+^KyT4J(6 4Z! +c)>S]9X34n?pO7n70[^[|L ѯPxk2\d!`/gڲZ3+(oaxL3yEB2H (/l$ngg>KƧc_cnSXaK(,|oœ,*..Uwkd[d64gd_)'j3m(o kzDϘ]]ޓ#c06Q>\@{ [/:éy)6˙jjc4U福=s٤zq%T{^b bFX(0ƒ%Yh@0F:y $(Hg`qB ]KYsG7n<u;#˧tNd` &+m5VqANr=I-.~X#p0ܧ&v$֛Ar0 :8HƌH^}x%M`~W*M(Fh'%tp&nƀUg:E[ҡX傛' P5&}WKo2ם0-5-YuZfٕ]$xZ R3%60^`sϭWA + X f2os+л:mb{c:Y*VWKș(?¢d 3IJ_5&bhʣL{R]ehK{-(SH)MXΦ2j!NYk0Hs~V +Ks +u->Bpdy\eZ4-xSH D/Y&EIGcވϱH۬+iDhz? |+׎ ޿RU{r 3Xjjo8b]>T4do'O.?bmU0U27@[*(g@E P( +F9 @pzS]JurDBѩl7t T 4ajȬ *p*wϕ6m8g8뎚, N]iMM2oFT Ǧ2RS3][3nY we@sʶЙ vC^2a^{W0[}M430. % OT/D-{@9sA#mk(/G&KU Q߶\iՉL`n&ukJ)..)L KXć!U1|2`5Eld?ѝ$mcw>/xiOnǶ쯽W0*b^7'>uJdT{J +Q3EM̭3jvh97C6I㓤3=ˀ\0{*antf^w5 &3ydEHk|ưM$ulw|L!mB)g^OvsVU72Vr:/ 3,[6@wahW/ ܈oġp ioU`y\L>]qIC3s2}9K@=%V,lg]|B61\7^4҉zTvѿmۏW;TLmO<[5[HxV V fc,B+Ex^)yz_`HdRI޲;˕3>?͎wޥ͘!llK+re %w<3>Xw=< J@OGo~܄^tlた(:\yezpmMLM,f +za¤uh O6m p1c mjpI(RZ@w3POL-X%3 ju`џ) 7jE%og"ˀpG3$`OjgIGSlOq5zDO ؼo"絡eNRER^2ԭ1_3Sb`j&9]W3VZ ޼jxeFlVuy +=15. +XE:W^:U9p4͛˶$b)ֶ﹤5kXÆYBX!I[1GBVu+`~.oVf "M؊DH|^+=%Wj,BNns]k5z,_?>kl4hj=כ!fW>UC]r7ń`;V$DVbj`hu.b(z\c _Xfi%u}#ۗ37;õ+i>kJ7_]$ +Ѱ 5 ^ɲV"Ì!)L#q5q?VVRumt8 +7 VפZa^'i褟#D!@6NtJfCEbc &Uyw;kZ-z q!C +X_hZn7B/@OFqC kxl#X Ap+`hR\gM[}n=~6p["Ihj+`}%-`ey H{YoEEmsc  HKؾx"/\ܒG$h`g+K5l֒=ؤ V +]Kǚ_,ͭ]"kN\vz &ˊ2JkW.^C Rxv>1Xo;'E/j#+8558gP촷-IGt?/xTH@DCtI !K4 i͡B…EXG%eY4S9~24b<Ɂ,b_:];+>p(l%B׈M,RR)ď_&Х=#MJDCb)4,L&1@X8dx{ѹL)o@fU, i$$J 7u1y +}+Rt,7֙hᜈ] )uO +1jm!mϴR&w8rȩ)$dv/JڙTjˆb> /TgI{RO 2r.q<|Wsqfna4@s8Lf1 8ȓ:)Bi.:(`ub3.Ylծ{pEG *X,/B3-1A"Fڼ,{Kb;@r9($!6ݻfNlfc$mnܟ|(JtLJ!_lBb@B'X7;VQ&^H7I-@sr>Z>4||L khNRgWc||gt|XZ>Y|-or[Χ^ܞO}2sQӔvf9$Zf)ĵ3Ό>AQ$RO۝O{R @ UTI)Ie+.UN +4,/ +ƭ">#֍nk&}:S/tI,v4{;J|PL +(պ47480`zI:Vݡ8C4 Cu/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?*qT,Ll۔S=ɎйbA_Jd@)) ŲTa6W:J똅IheR RHqͧ Ur#|O|_n4*+z'vUVk*9r[{EoD}9esћi72tqlcW0Fgyg:0z#GN诇ӕvqTX S${X͙JE>WmgɰҷFbHj&fCxFQ驐;:7 -hfYr&T6J*cÌ?_]dXX⧙a[i0kFN!Zg:e׮mTuh>θHz:2֤y=IeJ)+OU'ygE/Eʼ3CNOZ'j +R=ueSR'VªטDܨ7mQ:{w[]=zJz^wdt)wKl Sq(rqOs[F'Ew^]R&F )Φ݅;f=*:O4BtW^k䰲]v~Z +ZI=LU!y¾b6 I}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%U﨧*WJi*.@ٔM*Nj@0ժ4ӛj^(G+ĴJO{Pl,;c$g%۫{V#hzΔ/TZwSSo{3D^)peH́rT,iӢZ>Yۏ vja w *<]DO z"9Bm E5}t@8:o +#Pܚ+-&hC62(0Lý'?\wzWy8ͽglрKt5٫ν;1)w^{|;,8H, ?.j'u }ڿWվSRz/^/(Nա[)¥ ZO-EP_FDjw=ߎݽ(#2 tR,ZSLݟqGi9^Yo( +Y|'=l>u=XCU[tYTt +#Fq[(!cϺۦ0EgޫUM&=;>ѾtT 3Hwklz/ĶWU.<2LtI*W8Q-Xg;تu5sy kMK kU 4ʰ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ϻK6݋ABzйưo7+ۊ@4}7#a<7RS.>MAi7)qvH^W>d`mP^Ii>IsK*R65Q*#kq}s4vJ~ՠ5E4j=:֑^|4+#PQZT1F1.Ik61Z'G$GKePQ oIzD<}@IT)#** bD;$QJR% r&[饻$Q=n^qדf{;c.+2Jbe^Vf ˏ/+eoBaC7zYRė(³}6:y>]w䅇);C/>";mν +\}.#gk/>ӰiX[p7a\ g@F%4hOLx&mmagH^h`&h) WO׉=qQZOTʗ7n'󂩐L>hV5(1",dbl 6J[{:h .YO>??"lvkb &@- +S۞t\mӖl.kc}%#&xot2~S_ݬf' SY<\OAYg`&\.ſ涧أW2w$Жt*NY:n`XvA8m=`"l2qH6H2̍M%kq&t/7܃ٿ_^Vt>stream +GZ27`h$/5J}:SW4ҐӸwMރRH럃u7A +9+B2kS`zO@6 Ndu 7>a3xm,Ϸ;Y6x_!> S!  Y~ٴ&3dtIӀQ#|;n6k(ߊWA=O3v/O ˆ bP%dH]B&E-QϏD4+i ܲa#37M |`Q,hAqbdt`rg8CD zIHġ|`4HtSI狝p{ofȨ%\i7sge=io-EJX."(ZD`iAD8CW̯Utȶ\u๭mSaҁڒ8E%f`fzN )@gbߓ#TW" #4su@le~:F,cmf_!r96i'btbaA,8d3B%ifީo$$;p|0 NpNq8W3ʯ}Knqڱ3bUC4]0Jc9Z*}3bfDNpF} {Ic9ATQ9ѨCI>%m0'_, j-9bͦwJ!灝xCay@iSgOmd 6*$s&z.C;&|>QP6=g>lp{ߕ58߀1NMeg|o,Ɩt]i% \L&-!~#kep<"kg,NMRYІ ?zl/`gbƇmx1q8}V'3C)`h&+gv!&ڡ0,-Do[ He6S(J ;:c)2/3 b1*X$3_ \ ?|QKf 8 4\b\!լcfg=H_&Mo2V͟(fFs_>ɽ V2^)SI+{.ի6SٓeTkΡOF^&0'u@$&|̉'] +IƟ|yn`>%-<ƙr↽<1Ry; fAgOI{eu-m|2myLʄ%Ȇ= 3vAifY"f5" h-8v焂ۃ8 xM/XQk~ gXh +x$ \\i,6u&΂8&NZ" DH|38zDSO$CL#Ch_w1]^F|xA !`{:${ +Iywe;Vx~4OܱIEz5ֆ?$l8ߜݱj" xb0DQ؝=%ӏը{C,Fxf>v͂` $1ke268(9o/[3F`2`42d5kA0hZ[ 3uX ؊| ;`߹jU΍nh[=Mlm 14izCѻy̆{nhrdOhwdwOp42{ y MhipC`k! @Ѐ罧|jVnCwwYok0Y&bp˯PE3ZB́' ezw0IY@tY w?1 )s a:Tq1> Ry|y`FIX>4 gYbDʢMI `k+fSO0$6P4^PrƆsڞ1XMX2ei$qc?pyyK[Egc3nta#]EllzD>*~T٨1d!#vH=,no_o͕{0{.7ķ fl\ToyyxC)젒.a 6ͬr;(Z*5%O6NctGR7 ,6QuU(Z$ӁL"l-pN2s@*Q'֣Զ!t2 ®pY $r +KLzKCe#׶KpDK t˔dMu2AD'5IeDl_3%^V/i&ee?I}|۹8N!i%P4U(q4?,q1*qnCKphNk]@N #S^bh h ;3LO͵ {[hS7j:N +(f}V(N"nWYz㪇1q*7y%N,Ie֒t~/Σآq Eja]2ſfr=| % .#T䭽HnBXV$nuXQ\0C!(YCY\vVW~H +a dٚꈷ +xLH|bj-}(iqDhu~{l&5N\t%mg6xM<0dC6h⁡Dfh⁡D~g+FGs$If' +TnKۉGy$8^ -~bA9qV丿/![Xsv`/;g?!cOg?ma'CA._K~3WA/M}D||7[|$΋/-|pV02:P +~}>?~&I(M@S8R.gAؐ$IנSsIQ4"&8̏4 +_l<:b5<_,~ +//uty;# +endstream endobj 5 0 obj <> endobj 15 0 obj [/View/Design] endobj 16 0 obj <>>> endobj 27 0 obj [26 0 R] endobj 45 0 obj <> 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 +<]>> +startxref +261804 +%%EOF diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 0000000000..8e0077b4bf Binary files /dev/null and b/resources/icon.png differ 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 @@ + + + + + + + + + + + + + + + + + + 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 @@ + + + + + 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(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(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(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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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: [''], + post_tags: [''], + 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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +export default ( + app: any, + me?: any, + options?: { + includeSecret: boolean, + includeProfileImageIds: boolean + } +) => new Promise(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} + */ +export default ( + session: any, + me?: any +) => new Promise(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} + */ +export default ( + file: any, + options?: { + includeTags: boolean + } +) => new Promise(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} + */ +const self = ( + folder: any, + options?: { + includeParent: boolean + } +) => new Promise(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} + */ +const self = ( + tag: any +) => new Promise(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} + */ +export default ( + message: any, + me: any, + options?: { + populateRecipient: boolean + } +) => new Promise(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} + */ +export default (notification: any) => new Promise(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} + */ +const self = ( + post: any, + me?: any, + options?: { + serializeReplyTo: boolean, + serializeRepost: boolean, + includeIsLiked: boolean + } +) => new Promise(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} + */ +export default ( + record: any +) => new Promise(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} + */ +export default ( + user: any, + me?: any, + options?: { + detail: boolean, + includeSecrets: boolean + } +) => new Promise(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 { + 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 { + 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 new file mode 100644 index 0000000000..3c803f568e Binary files /dev/null and b/src/file/resources/avatar.jpg differ diff --git a/src/file/resources/bad-egg.png b/src/file/resources/bad-egg.png new file mode 100644 index 0000000000..a7c5930bd4 Binary files /dev/null and b/src/file/resources/bad-egg.png differ diff --git a/src/file/resources/dummy.png b/src/file/resources/dummy.png new file mode 100644 index 0000000000..39332b0c1b Binary files /dev/null and b/src/file/resources/dummy.png differ 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 { + 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 { + 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 => { + 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 => { + 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 new file mode 100644 index 0000000000..25cd91e954 Binary files /dev/null and b/src/himasaku/resources/himasaku.png differ 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 @@ + + + + + + + + ひまさく + + + + ひまさく + + 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 { + 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 { + console.log('Welcome to Misskey!\n'); + + console.log(chalk.bold('Misskey Core ')); + + 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 + | デベロッパーセンターにアクセスし、アプリ > アプリ作成 に進みます。 + 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 認証セッションを開始するには、#{config.api_url}/auth/session/generateへパラメータにapp_secretとしてApp Secretを含めたリクエストを送信します。 + p + | そうすると、レスポンスとして認証セッションのトークンや認証フォームのURLが取得できます。 + br + | この認証フォームのURLをブラウザで表示し、ユーザーにフォームを表示してください。 + section + h4 あなたのアプリがコールバックURLを設定している場合 + p ユーザーがアプリの連携を許可すると設定しているコールバックURLにtokenという名前でセッションのトークンが含まれたクエリを付けてリダイレクトします。 + section + h4 あなたのアプリがコールバックURLを設定していない場合 + p ユーザーがアプリの連携を許可したことを(何らかの方法で(たとえばボタンを押させるなど))確認出来るようにしてください。 + p + | 次に、#{config.api_url}/auth/session/userkeyapp_secretとしてApp Secretを、tokenとしてセッションのトークンをパラメータとして付与したリクエストを送信してください。 + br + | 上手くいけば、認証したユーザーのアクセストークンがレスポンスとして取得できます。おめでとうございます! + + p アクセストークンを取得できたら、あとは簡単です。REST APIなら、リクエストにアクセストークンを_userkey(「自分のアクセストークンを取得したい場合」の方法で取得したアクセストークンの場合はi)としてパラメータに含めるだけです。 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 @@ + + + + + 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\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 = '

致命的な問題が発生しました。

' 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(/' : ' '); + } else { + return token.content + .replace(/(\r\n|\n|\r)/g, canBreak ? '
' : ' '); + } + case 'bold': + return '' + token.bold + ''; + case 'link': + return ''; + case 'mention': + return '' + token.content + ''; + case 'hashtag': // TODO + return '' + token.content + ''; + } + }).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とは? +

Misskeyみすきーは、syuiloが2014年くらいからオープンソースで開発・運営を行っている、ミニブログベースのSNSです。

+

Twitter, Facebook, LINE, Google+ などをパクって参考にしています。

+

無料で誰でも利用でき、広告なども一切ありません。

+

もっと知りたい方はこちら

+ +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 + + +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 + 'Not implemented yet' + '要求された操作は実装されていません。
Misskeyの開発に参加する' + [ + 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 @@ + + + + + diff --git a/src/web/app/desktop/resources/remove.png b/src/web/app/desktop/resources/remove.png new file mode 100644 index 0000000000..8b1f4c06c9 Binary files /dev/null and b/src/web/app/desktop/resources/remove.png differ 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 + '広告ブロッカーを無効にしてください' + 'Misskeyは広告を掲載していませんが、広告をブロックする機能が有効だと一部の機能が利用できなかったり、不具合が発生する場合があります。' + [ + 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 + 'アバターを更新しました' + '新しいアバターが反映されるまで時間がかかる場合があります。' + [ + 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: 'アバターにする画像を選択' + .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 + 'バナーを更新しました' + '新しいバナーが反映されるまで時間がかかる場合があります。' + [ + 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: 'バナーにする画像を選択' + .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 + '壁紙を更新しました' + '新しい壁紙が反映されるまで時間がかかる場合があります。' + [ + 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: '壁紙にする画像を選択' + .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 + | + +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' }) + + i.fa.fa-crop + | { parent.title } + + + 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 }) 決定 + + +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' }) + + i.fa.fa-wrench + | Debugger + + + 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) + + +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' }) + + i.fa.fa-cloud + | ドライブ + + + mk-drive-browser(multiple={ true }, folder={ parent.folder }) + + +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.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 + '操作を完了できません' + '移動先のフォルダーは、移動するフォルダーのサブフォルダーです。' + [ + 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 + '操作を完了できません' + '移動先のフォルダーは、移動するフォルダーのサブフォルダーです。' + [ + 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 + + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + + +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 + +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 + + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + + +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 = [ + 'tでタイムラインにフォーカスできます' + 'pまたはnで投稿フォームを開きます' + '投稿フォームにはファイルをドラッグ&ドロップできます' + '投稿フォームにクリップボードにある画像データをペーストできます' + 'ドライブにファイルをドラッグ&ドロップしてアップロードできます' + 'ドライブでファイルをドラッグしてフォルダ移動できます' + 'ドライブでフォルダをドラッグしてフォルダ移動できます' + 'ホームをカスタマイズできます(準備中)' + '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' }) + + i.fa.fa-i-cursor + | { parent.title } + + + 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 }) 決定 + + +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' }) + + i.fa.fa-terminal + | Log + + + mk-log + + +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' }) + + i.fa.fa-comments + | メッセージ: { parent.user.name } + + + mk-messaging-room(user={ parent.user }) + + +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 = '' + 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' }) + + i.fa.fa-comments + | メッセージ + + + mk-messaging@index + + +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 }) + + + 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 + + + + div.ref(if={ parent.opts.reply }) + mk-post-preview(post={ parent.opts.reply }) + div.body + mk-post-form@form(reply={ parent.opts.reply }) + + +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' }) + + | { parent.title } + mk-ellipsis + + + 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 }) + + +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 }) + + i.fa.fa-retweet + | この投稿をRepostしますか? + + + mk-repost-form@form(post={ parent.opts.post }) + + +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 + + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + + +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' }) + + mk-raw(content={ parent.title }) + span.count(if={ parent.multiple && parent.file.length > 0 }) ({ parent.file.length }ファイル選択中) + + + 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 }) 決定 + + +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 || 'ファイルを選択' + + @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' }) + + i.fa.fa-cog + | 設定 + + + mk-settings + + +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') + | + +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 = "#yyyy/#mm/#dd" + + hh = (\0 + now.get-hours!).slice -2 + mm = (\0 + now.get-minutes!).slice -2 + hhmm = "#hh:#mm" + + if now.get-seconds! % 2 == 0 + hhmm .= replace \: ':' + else + hhmm .= replace \: ':' + + @refs.time.innerHTML = "#yyyymmdd
#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 + + + 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' }) + + img(src={ parent.user.avatar_url + '?thumbnail&size=64' }, alt='') + | { parent.user.name }のフォロワー + + + mk-user-followers(user={ parent.user }) + + +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' }) + + img(src={ parent.user.avatar_url + '?thumbnail&size=64' }, alt='') + | { parent.user.name }のフォロー + + + mk-user-following(user={ parent.user }) + + +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 + + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + + +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') + | + button.close(if={ can-close }, onmousedown={ repel-move }, onclick={ close }, title='閉じる'): i.fa.fa-times + div.content(data-yield='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 }). +
+
+
+
+ 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 'ドライブ' + + @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 'ドライブ' + + # 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 '' + 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 '' + 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 'ホーム' + + @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 '通知' + + @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 '投稿' + + @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 '' + @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 '' + 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 '' + 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 '' + 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 + + + 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