diff options
| author | syuilo <syuilotan@yahoo.co.jp> | 2018-03-29 20:32:18 +0900 |
|---|---|---|
| committer | syuilo <syuilotan@yahoo.co.jp> | 2018-03-29 20:32:18 +0900 |
| commit | cf33e483f7e6f40e8cbbbc0118a7df70bdaf651f (patch) | |
| tree | 318279530d3392ee40d91968477fc0e78d5cf0f7 /src/client/app/common | |
| parent | Update .travis.yml (diff) | |
| download | sharkey-cf33e483f7e6f40e8cbbbc0118a7df70bdaf651f.tar.gz sharkey-cf33e483f7e6f40e8cbbbc0118a7df70bdaf651f.tar.bz2 sharkey-cf33e483f7e6f40e8cbbbc0118a7df70bdaf651f.zip | |
整理した
Diffstat (limited to 'src/client/app/common')
84 files changed, 8707 insertions, 0 deletions
diff --git a/src/client/app/common/define-widget.ts b/src/client/app/common/define-widget.ts new file mode 100644 index 0000000000..27db59b5ee --- /dev/null +++ b/src/client/app/common/define-widget.ts @@ -0,0 +1,79 @@ +import Vue from 'vue'; + +export default function<T extends object>(data: { + name: string; + props?: () => T; +}) { + return Vue.extend({ + props: { + widget: { + type: Object + }, + isMobile: { + type: Boolean, + default: false + }, + isCustomizeMode: { + type: Boolean, + default: false + } + }, + computed: { + id(): string { + return this.widget.id; + } + }, + data() { + return { + props: data.props ? data.props() : {} as T, + bakedOldProps: null, + preventSave: false + }; + }, + created() { + if (this.props) { + Object.keys(this.props).forEach(prop => { + if (this.widget.data.hasOwnProperty(prop)) { + this.props[prop] = this.widget.data[prop]; + } + }); + } + + this.bakeProps(); + + this.$watch('props', newProps => { + if (this.preventSave) { + this.preventSave = false; + this.bakeProps(); + return; + } + if (this.bakedOldProps == JSON.stringify(newProps)) return; + + this.bakeProps(); + + if (this.isMobile) { + (this as any).api('i/update_mobile_home', { + id: this.id, + data: newProps + }).then(() => { + (this as any).os.i.account.clientSettings.mobile_home.find(w => w.id == this.id).data = newProps; + }); + } else { + (this as any).api('i/update_home', { + id: this.id, + data: newProps + }).then(() => { + (this as any).os.i.account.clientSettings.home.find(w => w.id == this.id).data = newProps; + }); + } + }, { + deep: true + }); + }, + methods: { + bakeProps() { + this.bakedOldProps = JSON.stringify(this.props); + } + } + }); +} diff --git a/src/client/app/common/mios.ts b/src/client/app/common/mios.ts new file mode 100644 index 0000000000..bcb8b60678 --- /dev/null +++ b/src/client/app/common/mios.ts @@ -0,0 +1,578 @@ +import Vue from 'vue'; +import { EventEmitter } from 'eventemitter3'; +import * as merge from 'object-assign-deep'; +import * as uuid from 'uuid'; + +import { hostname, apiUrl, swPublickey, version, lang, googleMapsApiKey } from '../config'; +import Progress from './scripts/loading'; +import Connection from './scripts/streaming/stream'; +import { HomeStreamManager } from './scripts/streaming/home'; +import { DriveStreamManager } from './scripts/streaming/drive'; +import { ServerStreamManager } from './scripts/streaming/server'; +import { RequestsStreamManager } from './scripts/streaming/requests'; +import { MessagingIndexStreamManager } from './scripts/streaming/messaging-index'; +import { OthelloStreamManager } from './scripts/streaming/othello'; + +import Err from '../common/views/components/connect-failed.vue'; + +//#region api requests +let spinner = null; +let pending = 0; +//#endregion + +export type API = { + chooseDriveFile: (opts: { + title?: string; + currentFolder?: any; + multiple?: boolean; + }) => Promise<any>; + + chooseDriveFolder: (opts: { + title?: string; + currentFolder?: any; + }) => Promise<any>; + + dialog: (opts: { + title: string; + text: string; + actions?: Array<{ + text: string; + id?: string; + }>; + }) => Promise<string>; + + input: (opts: { + title: string; + placeholder?: string; + default?: string; + }) => Promise<string>; + + post: (opts?: { + reply?: any; + repost?: any; + }) => void; + + notify: (message: string) => void; +}; + +/** + * Misskey Operating System + */ +export default class MiOS extends EventEmitter { + /** + * Misskeyの /meta で取得できるメタ情報 + */ + private meta: { + data: { [x: string]: any }; + chachedAt: Date; + }; + + private isMetaFetching = false; + + public app: Vue; + + public new(vm, props) { + const w = new vm({ + parent: this.app, + propsData: props + }).$mount(); + document.body.appendChild(w.$el); + } + + /** + * A signing user + */ + public i: { [x: string]: any }; + + /** + * Whether signed in + */ + public get isSignedIn() { + return this.i != null; + } + + /** + * Whether is debug mode + */ + public get debug() { + return localStorage.getItem('debug') == 'true'; + } + + /** + * Whether enable sounds + */ + public get isEnableSounds() { + return localStorage.getItem('enableSounds') == 'true'; + } + + public apis: API; + + /** + * A connection manager of home stream + */ + public stream: HomeStreamManager; + + /** + * Connection managers + */ + public streams: { + driveStream: DriveStreamManager; + serverStream: ServerStreamManager; + requestsStream: RequestsStreamManager; + messagingIndexStream: MessagingIndexStreamManager; + othelloStream: OthelloStreamManager; + } = { + driveStream: null, + serverStream: null, + requestsStream: null, + messagingIndexStream: null, + othelloStream: null + }; + + /** + * A registration of service worker + */ + private swRegistration: ServiceWorkerRegistration = null; + + /** + * Whether should register ServiceWorker + */ + private shouldRegisterSw: boolean; + + /** + * ウィンドウシステム + */ + public windows = new WindowSystem(); + + /** + * MiOSインスタンスを作成します + * @param shouldRegisterSw ServiceWorkerを登録するかどうか + */ + constructor(shouldRegisterSw = false) { + super(); + + this.shouldRegisterSw = shouldRegisterSw; + + //#region BIND + this.log = this.log.bind(this); + this.logInfo = this.logInfo.bind(this); + this.logWarn = this.logWarn.bind(this); + this.logError = this.logError.bind(this); + this.init = this.init.bind(this); + this.api = this.api.bind(this); + this.getMeta = this.getMeta.bind(this); + this.registerSw = this.registerSw.bind(this); + //#endregion + + if (this.debug) { + (window as any).os = this; + } + } + + private googleMapsIniting = false; + + public getGoogleMaps() { + return new Promise((res, rej) => { + if ((window as any).google && (window as any).google.maps) { + res((window as any).google.maps); + } else { + this.once('init-google-maps', () => { + res((window as any).google.maps); + }); + + //#region load google maps api + if (!this.googleMapsIniting) { + this.googleMapsIniting = true; + (window as any).initGoogleMaps = () => { + this.emit('init-google-maps'); + }; + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', `https://maps.googleapis.com/maps/api/js?key=${googleMapsApiKey}&callback=initGoogleMaps`); + script.setAttribute('async', 'true'); + script.setAttribute('defer', 'true'); + head.appendChild(script); + } + //#endregion + } + }); + } + + public log(...args) { + if (!this.debug) return; + console.log.apply(null, args); + } + + public logInfo(...args) { + if (!this.debug) return; + console.info.apply(null, args); + } + + public logWarn(...args) { + if (!this.debug) return; + console.warn.apply(null, args); + } + + public logError(...args) { + if (!this.debug) return; + console.error.apply(null, args); + } + + public signout() { + localStorage.removeItem('me'); + document.cookie = `i=; domain=.${hostname}; expires=Thu, 01 Jan 1970 00:00:01 GMT;`; + location.href = '/'; + } + + /** + * Initialize MiOS (boot) + * @param callback A function that call when initialized + */ + public async init(callback) { + //#region Init stream managers + this.streams.serverStream = new ServerStreamManager(this); + this.streams.requestsStream = new RequestsStreamManager(this); + + this.once('signedin', () => { + // Init home stream manager + this.stream = new HomeStreamManager(this, this.i); + + // Init other stream manager + this.streams.driveStream = new DriveStreamManager(this, this.i); + this.streams.messagingIndexStream = new MessagingIndexStreamManager(this, this.i); + this.streams.othelloStream = new OthelloStreamManager(this, this.i); + }); + //#endregion + + // ユーザーをフェッチしてコールバックする + const fetchme = (token, cb) => { + let me = null; + + // Return when not signed in + if (token == null) { + return done(); + } + + // Fetch user + fetch(`${apiUrl}/i`, { + method: 'POST', + body: JSON.stringify({ + i: token + }) + }) + // When success + .then(res => { + // When failed to authenticate user + if (res.status !== 200) { + return this.signout(); + } + + // Parse response + res.json().then(i => { + me = i; + me.account.token = token; + done(); + }); + }) + // When failure + .catch(() => { + // Render the error screen + document.body.innerHTML = '<div id="err"></div>'; + new Vue({ + render: createEl => createEl(Err) + }).$mount('#err'); + + Progress.done(); + }); + + function done() { + if (cb) cb(me); + } + }; + + // フェッチが完了したとき + const fetched = me => { + if (me) { + // デフォルトの設定をマージ + me.account.clientSettings = Object.assign({ + fetchOnScroll: true, + showMaps: true, + showPostFormOnTopOfTl: false, + gradientWindowHeader: false + }, me.account.clientSettings); + + // ローカルストレージにキャッシュ + localStorage.setItem('me', JSON.stringify(me)); + } + + this.i = me; + + this.emit('signedin'); + + // Finish init + callback(); + + //#region Post + + // Init service worker + if (this.shouldRegisterSw) this.registerSw(); + + //#endregion + }; + + // Get cached account data + const cachedMe = JSON.parse(localStorage.getItem('me')); + + // キャッシュがあったとき + if (cachedMe) { + // とりあえずキャッシュされたデータでお茶を濁して(?)おいて、 + fetched(cachedMe); + + // 後から新鮮なデータをフェッチ + fetchme(cachedMe.account.token, freshData => { + merge(cachedMe, freshData); + }); + } else { + // Get token from cookie + const i = (document.cookie.match(/i=(!\w+)/) || [null, null])[1]; + + fetchme(i, fetched); + } + } + + /** + * Register service worker + */ + private registerSw() { + // Check whether service worker and push manager supported + const isSwSupported = + ('serviceWorker' in navigator) && ('PushManager' in window); + + // Reject when browser not service worker supported + if (!isSwSupported) return; + + // Reject when not signed in to Misskey + if (!this.isSignedIn) return; + + // When service worker activated + navigator.serviceWorker.ready.then(registration => { + this.log('[sw] ready: ', registration); + + this.swRegistration = registration; + + // Options of pushManager.subscribe + // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters + const opts = { + // A boolean indicating that the returned push subscription + // will only be used for messages whose effect is made visible to the user. + userVisibleOnly: true, + + // A public key your push server will use to send + // messages to client apps via a push server. + applicationServerKey: urlBase64ToUint8Array(swPublickey) + }; + + // Subscribe push notification + this.swRegistration.pushManager.subscribe(opts).then(subscription => { + this.log('[sw] Subscribe OK:', subscription); + + function encode(buffer: ArrayBuffer) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); + } + + // Register + this.api('sw/register', { + endpoint: subscription.endpoint, + auth: encode(subscription.getKey('auth')), + publickey: encode(subscription.getKey('p256dh')) + }); + }) + // When subscribe failed + .catch(async (err: Error) => { + this.logError('[sw] Subscribe Error:', err); + + // 通知が許可されていなかったとき + if (err.name == 'NotAllowedError') { + this.logError('[sw] Subscribe failed due to notification not allowed'); + return; + } + + // 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが + // 既に存在していることが原因でエラーになった可能性があるので、 + // そのサブスクリプションを解除しておく + const subscription = await this.swRegistration.pushManager.getSubscription(); + if (subscription) subscription.unsubscribe(); + }); + }); + + // Whether use raw version script + const raw = (localStorage.getItem('useRawScript') == 'true' && this.debug) + || process.env.NODE_ENV != 'production'; + + // The path of service worker script + const sw = `/sw.${version}.${lang}.${raw ? 'raw' : 'min'}.js`; + + // Register service worker + navigator.serviceWorker.register(sw).then(registration => { + // 登録成功 + this.logInfo('[sw] Registration successful with scope: ', registration.scope); + }).catch(err => { + // 登録失敗 :( + this.logError('[sw] Registration failed: ', err); + }); + } + + public requests = []; + + /** + * Misskey APIにリクエストします + * @param endpoint エンドポイント名 + * @param data パラメータ + */ + public api(endpoint: string, data: { [x: string]: any } = {}): Promise<{ [x: string]: any }> { + if (++pending === 1) { + spinner = document.createElement('div'); + spinner.setAttribute('id', 'wait'); + document.body.appendChild(spinner); + } + + // Append a credential + if (this.isSignedIn) (data as any).i = this.i.account.token; + + // TODO + //const viaStream = localStorage.getItem('enableExperimental') == 'true'; + + return new Promise((resolve, reject) => { + /*if (viaStream) { + const stream = this.stream.borrow(); + const id = Math.random().toString(); + stream.once(`api-res:${id}`, res => { + resolve(res); + }); + stream.send({ + type: 'api', + id, + endpoint, + data + }); + } else {*/ + const req = { + id: uuid(), + date: new Date(), + name: endpoint, + data, + res: null, + status: null + }; + + if (this.debug) { + this.requests.push(req); + } + + // Send request + fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: endpoint === 'signin' ? 'include' : 'omit', + cache: 'no-cache' + }).then(async (res) => { + if (--pending === 0) spinner.parentNode.removeChild(spinner); + + const body = res.status === 204 ? null : await res.json(); + + if (this.debug) { + req.status = res.status; + req.res = body; + } + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + /*}*/ + }); + } + + /** + * Misskeyのメタ情報を取得します + * @param force キャッシュを無視するか否か + */ + public getMeta(force = false) { + return new Promise<{ [x: string]: any }>(async (res, rej) => { + if (this.isMetaFetching) { + this.once('_meta_fetched_', () => { + res(this.meta.data); + }); + return; + } + + const expire = 1000 * 60; // 1min + + // forceが有効, meta情報を保持していない or 期限切れ + if (force || this.meta == null || Date.now() - this.meta.chachedAt.getTime() > expire) { + this.isMetaFetching = true; + const meta = await this.api('meta'); + this.meta = { + data: meta, + chachedAt: new Date() + }; + this.isMetaFetching = false; + this.emit('_meta_fetched_'); + res(meta); + } else { + res(this.meta.data); + } + }); + } + + public connections: Connection[] = []; + + public registerStreamConnection(connection: Connection) { + this.connections.push(connection); + } + + public unregisterStreamConnection(connection: Connection) { + this.connections = this.connections.filter(c => c != connection); + } +} + +class WindowSystem extends EventEmitter { + public windows = new Set(); + + public add(window) { + this.windows.add(window); + this.emit('added', window); + } + + public remove(window) { + this.windows.delete(window); + this.emit('removed', window); + } + + public getAll() { + return this.windows; + } +} + +/** + * Convert the URL safe base64 string to a Uint8Array + * @param base64String base64 string + */ +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} diff --git a/src/client/app/common/scripts/check-for-update.ts b/src/client/app/common/scripts/check-for-update.ts new file mode 100644 index 0000000000..81c1eb9812 --- /dev/null +++ b/src/client/app/common/scripts/check-for-update.ts @@ -0,0 +1,33 @@ +import MiOS from '../mios'; +import { version as current } from '../../config'; + +export default async function(mios: MiOS, force = false, silent = false) { + const meta = await mios.getMeta(force); + const newer = meta.version; + + if (newer != current) { + localStorage.setItem('should-refresh', 'true'); + localStorage.setItem('v', newer); + + // Clear cache (serive worker) + try { + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage('clear'); + } + + navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => registration.unregister()); + }); + } catch (e) { + console.error(e); + } + + if (!silent) { + alert('%i18n:common.update-available%'.replace('{newer}', newer).replace('{current}', current)); + } + + return newer; + } else { + return null; + } +} diff --git a/src/client/app/common/scripts/compose-notification.ts b/src/client/app/common/scripts/compose-notification.ts new file mode 100644 index 0000000000..273579cbc6 --- /dev/null +++ b/src/client/app/common/scripts/compose-notification.ts @@ -0,0 +1,67 @@ +import getPostSummary from '../../../../common/get-post-summary'; +import getReactionEmoji from '../../../../common/get-reaction-emoji'; + +type Notification = { + title: string; + body: string; + icon: string; + onclick?: any; +}; + +// TODO: i18n + +export default function(type, data): Notification { + switch (type) { + case 'drive_file_created': + return { + title: 'ファイルがアップロードされました', + body: data.name, + icon: data.url + '?thumbnail&size=64' + }; + + case 'mention': + return { + title: `${data.user.name}さんから:`, + body: getPostSummary(data), + icon: data.user.avatarUrl + '?thumbnail&size=64' + }; + + case 'reply': + return { + title: `${data.user.name}さんから返信:`, + body: getPostSummary(data), + icon: data.user.avatarUrl + '?thumbnail&size=64' + }; + + case 'quote': + return { + title: `${data.user.name}さんが引用:`, + body: getPostSummary(data), + icon: data.user.avatarUrl + '?thumbnail&size=64' + }; + + case 'reaction': + return { + title: `${data.user.name}: ${getReactionEmoji(data.reaction)}:`, + body: getPostSummary(data.post), + icon: data.user.avatarUrl + '?thumbnail&size=64' + }; + + case 'unread_messaging_message': + return { + title: `${data.user.name}さんからメッセージ:`, + body: data.text, // TODO: getMessagingMessageSummary(data), + icon: data.user.avatarUrl + '?thumbnail&size=64' + }; + + case 'othello_invited': + return { + title: '対局への招待があります', + body: `${data.parent.name}さんから`, + icon: data.parent.avatarUrl + '?thumbnail&size=64' + }; + + default: + return null; + } +} diff --git a/src/client/app/common/scripts/contains.ts b/src/client/app/common/scripts/contains.ts new file mode 100644 index 0000000000..a5071b3f25 --- /dev/null +++ b/src/client/app/common/scripts/contains.ts @@ -0,0 +1,8 @@ +export default (parent, child) => { + let node = child.parentNode; + while (node) { + if (node == parent) return true; + node = node.parentNode; + } + return false; +}; diff --git a/src/client/app/common/scripts/copy-to-clipboard.ts b/src/client/app/common/scripts/copy-to-clipboard.ts new file mode 100644 index 0000000000..3d2741f8d7 --- /dev/null +++ b/src/client/app/common/scripts/copy-to-clipboard.ts @@ -0,0 +1,13 @@ +/** + * Clipboardに値をコピー(TODO: 文字列以外も対応) + */ +export default val => { + const form = document.createElement('textarea'); + form.textContent = val; + document.body.appendChild(form); + form.select(); + const result = document.execCommand('copy'); + document.body.removeChild(form); + + return result; +}; diff --git a/src/client/app/common/scripts/date-stringify.ts b/src/client/app/common/scripts/date-stringify.ts new file mode 100644 index 0000000000..e51de8833d --- /dev/null +++ b/src/client/app/common/scripts/date-stringify.ts @@ -0,0 +1,13 @@ +export default date => { + if (typeof date == 'string') date = new Date(date); + return ( + date.getFullYear() + '年' + + (date.getMonth() + 1) + '月' + + date.getDate() + '日' + + ' ' + + date.getHours() + '時' + + date.getMinutes() + '分' + + ' ' + + `(${['日', '月', '火', '水', '木', '金', '土'][date.getDay()]})` + ); +}; diff --git a/src/client/app/common/scripts/fuck-ad-block.ts b/src/client/app/common/scripts/fuck-ad-block.ts new file mode 100644 index 0000000000..9bcf7deeff --- /dev/null +++ b/src/client/app/common/scripts/fuck-ad-block.ts @@ -0,0 +1,21 @@ +require('fuckadblock'); + +declare const fuckAdBlock: any; + +export default (os) => { + function adBlockDetected() { + os.apis.dialog({ + title: '%fa:exclamation-triangle%広告ブロッカーを無効にしてください', + text: '<strong>Misskeyは広告を掲載していません</strong>が、広告をブロックする機能が有効だと一部の機能が利用できなかったり、不具合が発生する場合があります。', + actins: [{ + text: 'OK' + }] + }); + } + + if (fuckAdBlock === undefined) { + adBlockDetected(); + } else { + fuckAdBlock.onDetected(adBlockDetected); + } +}; diff --git a/src/client/app/common/scripts/gcd.ts b/src/client/app/common/scripts/gcd.ts new file mode 100644 index 0000000000..9a19f9da66 --- /dev/null +++ b/src/client/app/common/scripts/gcd.ts @@ -0,0 +1,2 @@ +const gcd = (a, b) => !b ? a : gcd(b, a % b); +export default gcd; diff --git a/src/client/app/common/scripts/get-kao.ts b/src/client/app/common/scripts/get-kao.ts new file mode 100644 index 0000000000..2168c5be88 --- /dev/null +++ b/src/client/app/common/scripts/get-kao.ts @@ -0,0 +1,5 @@ +export default () => [ + '(=^・・^=)', + 'v(‘ω’)v', + '🐡( \'-\' 🐡 )フグパンチ!!!!' +][Math.floor(Math.random() * 3)]; diff --git a/src/client/app/common/scripts/get-median.ts b/src/client/app/common/scripts/get-median.ts new file mode 100644 index 0000000000..91a415d5b2 --- /dev/null +++ b/src/client/app/common/scripts/get-median.ts @@ -0,0 +1,11 @@ +/** + * 中央値を求めます + * @param samples サンプル + */ +export default function(samples) { + if (!samples.length) return 0; + const numbers = samples.slice(0).sort((a, b) => a - b); + const middle = Math.floor(numbers.length / 2); + const isEven = numbers.length % 2 === 0; + return isEven ? (numbers[middle] + numbers[middle - 1]) / 2 : numbers[middle]; +} diff --git a/src/client/app/common/scripts/loading.ts b/src/client/app/common/scripts/loading.ts new file mode 100644 index 0000000000..c48e626648 --- /dev/null +++ b/src/client/app/common/scripts/loading.ts @@ -0,0 +1,21 @@ +const NProgress = require('nprogress'); +NProgress.configure({ + trickleSpeed: 500, + showSpinner: false +}); + +const root = document.getElementsByTagName('html')[0]; + +export default { + start: () => { + root.classList.add('progress'); + NProgress.start(); + }, + done: () => { + root.classList.remove('progress'); + NProgress.done(); + }, + set: val => { + NProgress.set(val); + } +}; diff --git a/src/client/app/common/scripts/parse-search-query.ts b/src/client/app/common/scripts/parse-search-query.ts new file mode 100644 index 0000000000..4f09d2b93f --- /dev/null +++ b/src/client/app/common/scripts/parse-search-query.ts @@ -0,0 +1,53 @@ +export default function(qs: string) { + const q = { + text: '' + }; + + qs.split(' ').forEach(x => { + if (/^([a-z_]+?):(.+?)$/.test(x)) { + const [key, value] = x.split(':'); + switch (key) { + case 'user': + q['includeUserUsernames'] = value.split(','); + break; + case 'exclude_user': + q['excludeUserUsernames'] = value.split(','); + break; + case 'follow': + q['following'] = value == 'null' ? null : value == 'true'; + break; + case 'reply': + q['reply'] = value == 'null' ? null : value == 'true'; + break; + case 'repost': + q['repost'] = value == 'null' ? null : value == 'true'; + break; + case 'media': + q['media'] = value == 'null' ? null : value == 'true'; + break; + case 'poll': + q['poll'] = value == 'null' ? null : value == 'true'; + break; + case 'until': + case 'since': + // YYYY-MM-DD + if (/^[0-9]+\-[0-9]+\-[0-9]+$/) { + const [yyyy, mm, dd] = value.split('-'); + q[`${key}_date`] = (new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, parseInt(dd, 10))).getTime(); + } + break; + default: + q[key] = value; + break; + } + } else { + q.text += x + ' '; + } + }); + + if (q.text) { + q.text = q.text.trim(); + } + + return q; +} diff --git a/src/client/app/common/scripts/streaming/channel.ts b/src/client/app/common/scripts/streaming/channel.ts new file mode 100644 index 0000000000..cab5f4edb4 --- /dev/null +++ b/src/client/app/common/scripts/streaming/channel.ts @@ -0,0 +1,13 @@ +import Stream from './stream'; +import MiOS from '../../mios'; + +/** + * Channel stream connection + */ +export default class Connection extends Stream { + constructor(os: MiOS, channelId) { + super(os, 'channel', { + channel: channelId + }); + } +} diff --git a/src/client/app/common/scripts/streaming/drive.ts b/src/client/app/common/scripts/streaming/drive.ts new file mode 100644 index 0000000000..f11573685e --- /dev/null +++ b/src/client/app/common/scripts/streaming/drive.ts @@ -0,0 +1,34 @@ +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Drive stream connection + */ +export class DriveStream extends Stream { + constructor(os: MiOS, me) { + super(os, 'drive', { + i: me.account.token + }); + } +} + +export class DriveStreamManager extends StreamManager<DriveStream> { + private me; + private os: MiOS; + + constructor(os: MiOS, me) { + super(); + + this.me = me; + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new DriveStream(this.os, this.me); + } + + return this.connection; + } +} diff --git a/src/client/app/common/scripts/streaming/home.ts b/src/client/app/common/scripts/streaming/home.ts new file mode 100644 index 0000000000..c198619400 --- /dev/null +++ b/src/client/app/common/scripts/streaming/home.ts @@ -0,0 +1,57 @@ +import * as merge from 'object-assign-deep'; + +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Home stream connection + */ +export class HomeStream extends Stream { + constructor(os: MiOS, me) { + super(os, '', { + i: me.account.token + }); + + // 最終利用日時を更新するため定期的にaliveメッセージを送信 + setInterval(() => { + this.send({ type: 'alive' }); + me.account.lastUsedAt = new Date(); + }, 1000 * 60); + + // 自分の情報が更新されたとき + this.on('i_updated', i => { + if (os.debug) { + console.log('I updated:', i); + } + merge(me, i); + }); + + // トークンが再生成されたとき + // このままではAPIが利用できないので強制的にサインアウトさせる + this.on('my_token_regenerated', () => { + alert('%i18n:common.my-token-regenerated%'); + os.signout(); + }); + } +} + +export class HomeStreamManager extends StreamManager<HomeStream> { + private me; + private os: MiOS; + + constructor(os: MiOS, me) { + super(); + + this.me = me; + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new HomeStream(this.os, this.me); + } + + return this.connection; + } +} diff --git a/src/client/app/common/scripts/streaming/messaging-index.ts b/src/client/app/common/scripts/streaming/messaging-index.ts new file mode 100644 index 0000000000..24f0ce0c9f --- /dev/null +++ b/src/client/app/common/scripts/streaming/messaging-index.ts @@ -0,0 +1,34 @@ +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Messaging index stream connection + */ +export class MessagingIndexStream extends Stream { + constructor(os: MiOS, me) { + super(os, 'messaging-index', { + i: me.account.token + }); + } +} + +export class MessagingIndexStreamManager extends StreamManager<MessagingIndexStream> { + private me; + private os: MiOS; + + constructor(os: MiOS, me) { + super(); + + this.me = me; + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new MessagingIndexStream(this.os, this.me); + } + + return this.connection; + } +} diff --git a/src/client/app/common/scripts/streaming/messaging.ts b/src/client/app/common/scripts/streaming/messaging.ts new file mode 100644 index 0000000000..4c593deb31 --- /dev/null +++ b/src/client/app/common/scripts/streaming/messaging.ts @@ -0,0 +1,20 @@ +import Stream from './stream'; +import MiOS from '../../mios'; + +/** + * Messaging stream connection + */ +export class MessagingStream extends Stream { + constructor(os: MiOS, me, otherparty) { + super(os, 'messaging', { + i: me.account.token, + otherparty + }); + + (this as any).on('_connected_', () => { + this.send({ + i: me.account.token + }); + }); + } +} diff --git a/src/client/app/common/scripts/streaming/othello-game.ts b/src/client/app/common/scripts/streaming/othello-game.ts new file mode 100644 index 0000000000..f34ef35147 --- /dev/null +++ b/src/client/app/common/scripts/streaming/othello-game.ts @@ -0,0 +1,11 @@ +import Stream from './stream'; +import MiOS from '../../mios'; + +export class OthelloGameStream extends Stream { + constructor(os: MiOS, me, game) { + super(os, 'othello-game', { + i: me ? me.account.token : null, + game: game.id + }); + } +} diff --git a/src/client/app/common/scripts/streaming/othello.ts b/src/client/app/common/scripts/streaming/othello.ts new file mode 100644 index 0000000000..8c6f4b9c3c --- /dev/null +++ b/src/client/app/common/scripts/streaming/othello.ts @@ -0,0 +1,31 @@ +import StreamManager from './stream-manager'; +import Stream from './stream'; +import MiOS from '../../mios'; + +export class OthelloStream extends Stream { + constructor(os: MiOS, me) { + super(os, 'othello', { + i: me.account.token + }); + } +} + +export class OthelloStreamManager extends StreamManager<OthelloStream> { + private me; + private os: MiOS; + + constructor(os: MiOS, me) { + super(); + + this.me = me; + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new OthelloStream(this.os, this.me); + } + + return this.connection; + } +} diff --git a/src/client/app/common/scripts/streaming/requests.ts b/src/client/app/common/scripts/streaming/requests.ts new file mode 100644 index 0000000000..5bec30143f --- /dev/null +++ b/src/client/app/common/scripts/streaming/requests.ts @@ -0,0 +1,30 @@ +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Requests stream connection + */ +export class RequestsStream extends Stream { + constructor(os: MiOS) { + super(os, 'requests'); + } +} + +export class RequestsStreamManager extends StreamManager<RequestsStream> { + private os: MiOS; + + constructor(os: MiOS) { + super(); + + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new RequestsStream(this.os); + } + + return this.connection; + } +} diff --git a/src/client/app/common/scripts/streaming/server.ts b/src/client/app/common/scripts/streaming/server.ts new file mode 100644 index 0000000000..3d35ef4d9d --- /dev/null +++ b/src/client/app/common/scripts/streaming/server.ts @@ -0,0 +1,30 @@ +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Server stream connection + */ +export class ServerStream extends Stream { + constructor(os: MiOS) { + super(os, 'server'); + } +} + +export class ServerStreamManager extends StreamManager<ServerStream> { + private os: MiOS; + + constructor(os: MiOS) { + super(); + + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new ServerStream(this.os); + } + + return this.connection; + } +} diff --git a/src/client/app/common/scripts/streaming/stream-manager.ts b/src/client/app/common/scripts/streaming/stream-manager.ts new file mode 100644 index 0000000000..568b8b0372 --- /dev/null +++ b/src/client/app/common/scripts/streaming/stream-manager.ts @@ -0,0 +1,108 @@ +import { EventEmitter } from 'eventemitter3'; +import * as uuid from 'uuid'; +import Connection from './stream'; + +/** + * ストリーム接続を管理するクラス + * 複数の場所から同じストリームを利用する際、接続をまとめたりする + */ +export default abstract class StreamManager<T extends Connection> extends EventEmitter { + private _connection: T = null; + + private disposeTimerId: any; + + /** + * コネクションを必要としているユーザー + */ + private users = []; + + protected set connection(connection: T) { + this._connection = connection; + + if (this._connection == null) { + this.emit('disconnected'); + } else { + this.emit('connected', this._connection); + + this._connection.on('_connected_', () => { + this.emit('_connected_'); + }); + + this._connection.on('_disconnected_', () => { + this.emit('_disconnected_'); + }); + + this._connection.user = 'Managed'; + } + } + + protected get connection() { + return this._connection; + } + + /** + * コネクションを持っているか否か + */ + public get hasConnection() { + return this._connection != null; + } + + public get state(): string { + if (!this.hasConnection) return 'no-connection'; + return this._connection.state; + } + + /** + * コネクションを要求します + */ + public abstract getConnection(): T; + + /** + * 現在接続しているコネクションを取得します + */ + public borrow() { + return this._connection; + } + + /** + * コネクションを要求するためのユーザーIDを発行します + */ + public use() { + // タイマー解除 + if (this.disposeTimerId) { + clearTimeout(this.disposeTimerId); + this.disposeTimerId = null; + } + + // ユーザーID生成 + const userId = uuid(); + + this.users.push(userId); + + this._connection.user = `Managed (${ this.users.length })`; + + return userId; + } + + /** + * コネクションを利用し終わってもう必要ないことを通知します + * @param userId use で発行したユーザーID + */ + public dispose(userId) { + this.users = this.users.filter(id => id != userId); + + this._connection.user = `Managed (${ this.users.length })`; + + // 誰もコネクションの利用者がいなくなったら + if (this.users.length == 0) { + // また直ぐに再利用される可能性があるので、一定時間待ち、 + // 新たな利用者が現れなければコネクションを切断する + this.disposeTimerId = setTimeout(() => { + this.disposeTimerId = null; + + this.connection.close(); + this.connection = null; + }, 3000); + } + } +} diff --git a/src/client/app/common/scripts/streaming/stream.ts b/src/client/app/common/scripts/streaming/stream.ts new file mode 100644 index 0000000000..3912186ad3 --- /dev/null +++ b/src/client/app/common/scripts/streaming/stream.ts @@ -0,0 +1,137 @@ +import { EventEmitter } from 'eventemitter3'; +import * as uuid from 'uuid'; +import * as ReconnectingWebsocket from 'reconnecting-websocket'; +import { wsUrl } from '../../../config'; +import MiOS from '../../mios'; + +/** + * Misskey stream connection + */ +export default class Connection extends EventEmitter { + public state: string; + private buffer: any[]; + public socket: ReconnectingWebsocket; + public name: string; + public connectedAt: Date; + public user: string = null; + public in: number = 0; + public out: number = 0; + public inout: Array<{ + type: 'in' | 'out', + at: Date, + data: string + }> = []; + public id: string; + public isSuspended = false; + private os: MiOS; + + constructor(os: MiOS, endpoint, params?) { + super(); + + //#region BIND + this.onOpen = this.onOpen.bind(this); + this.onClose = this.onClose.bind(this); + this.onMessage = this.onMessage.bind(this); + this.send = this.send.bind(this); + this.close = this.close.bind(this); + //#endregion + + this.id = uuid(); + this.os = os; + this.name = endpoint; + this.state = 'initializing'; + this.buffer = []; + + const query = params + ? Object.keys(params) + .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) + .join('&') + : null; + + this.socket = new ReconnectingWebsocket(`${wsUrl}/${endpoint}${query ? '?' + query : ''}`); + this.socket.addEventListener('open', this.onOpen); + this.socket.addEventListener('close', this.onClose); + this.socket.addEventListener('message', this.onMessage); + + // Register this connection for debugging + this.os.registerStreamConnection(this); + } + + /** + * Callback of when open connection + */ + private onOpen() { + this.state = 'connected'; + this.emit('_connected_'); + + this.connectedAt = new Date(); + + // バッファーを処理 + const _buffer = [].concat(this.buffer); // Shallow copy + this.buffer = []; // Clear buffer + _buffer.forEach(data => { + this.send(data); // Resend each buffered messages + + if (this.os.debug) { + this.out++; + this.inout.push({ type: 'out', at: new Date(), data }); + } + }); + } + + /** + * Callback of when close connection + */ + private onClose() { + this.state = 'reconnecting'; + this.emit('_disconnected_'); + } + + /** + * Callback of when received a message from connection + */ + private onMessage(message) { + if (this.isSuspended) return; + + if (this.os.debug) { + this.in++; + this.inout.push({ type: 'in', at: new Date(), data: message.data }); + } + + try { + const msg = JSON.parse(message.data); + if (msg.type) this.emit(msg.type, msg.body); + } catch (e) { + // noop + } + } + + /** + * Send a message to connection + */ + public send(data) { + if (this.isSuspended) return; + + // まだ接続が確立されていなかったらバッファリングして次に接続した時に送信する + if (this.state != 'connected') { + this.buffer.push(data); + return; + } + + if (this.os.debug) { + this.out++; + this.inout.push({ type: 'out', at: new Date(), data }); + } + + this.socket.send(JSON.stringify(data)); + } + + /** + * Close this connection + */ + public close() { + this.os.unregisterStreamConnection(this); + this.socket.removeEventListener('open', this.onOpen); + this.socket.removeEventListener('message', this.onMessage); + } +} diff --git a/src/client/app/common/views/components/autocomplete.vue b/src/client/app/common/views/components/autocomplete.vue new file mode 100644 index 0000000000..79bd2ba023 --- /dev/null +++ b/src/client/app/common/views/components/autocomplete.vue @@ -0,0 +1,306 @@ +<template> +<div class="mk-autocomplete" @contextmenu.prevent="() => {}"> + <ol class="users" ref="suggests" v-if="users.length > 0"> + <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1"> + <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=32`" alt=""/> + <span class="name">{{ user.name }}</span> + <span class="username">@{{ getAcct(user) }}</span> + </li> + </ol> + <ol class="emojis" ref="suggests" v-if="emojis.length > 0"> + <li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1"> + <span class="emoji">{{ emoji.emoji }}</span> + <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span> + <span class="alias" v-if="emoji.alias">({{ emoji.alias }})</span> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as emojilib from 'emojilib'; +import contains from '../../../common/scripts/contains'; +import getAcct from '../../../../../common/user/get-acct'; + +const lib = Object.entries(emojilib.lib).filter((x: any) => { + return x[1].category != 'flags'; +}); +const emjdb = lib.map((x: any) => ({ + emoji: x[1].char, + name: x[0], + alias: null +})); +lib.forEach((x: any) => { + if (x[1].keywords) { + x[1].keywords.forEach(k => { + emjdb.push({ + emoji: x[1].char, + name: k, + alias: x[0] + }); + }); + } +}); +emjdb.sort((a, b) => a.name.length - b.name.length); + +export default Vue.extend({ + props: ['type', 'q', 'textarea', 'complete', 'close', 'x', 'y'], + data() { + return { + fetching: true, + users: [], + emojis: [], + select: -1, + emojilib + } + }, + computed: { + items(): HTMLCollection { + return (this.$refs.suggests as Element).children; + } + }, + updated() { + //#region 位置調整 + const margin = 32; + + if (this.x + this.$el.offsetWidth > window.innerWidth - margin) { + this.$el.style.left = (this.x - this.$el.offsetWidth) + 'px'; + this.$el.style.marginLeft = '-16px'; + } else { + this.$el.style.left = this.x + 'px'; + this.$el.style.marginLeft = '0'; + } + + if (this.y + this.$el.offsetHeight > window.innerHeight - margin) { + this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px'; + this.$el.style.marginTop = '0'; + } else { + this.$el.style.top = this.y + 'px'; + this.$el.style.marginTop = 'calc(1em + 8px)'; + } + //#endregion + }, + mounted() { + this.textarea.addEventListener('keydown', this.onKeydown); + + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + + this.$nextTick(() => { + this.exec(); + + this.$watch('q', () => { + this.$nextTick(() => { + this.exec(); + }); + }); + }); + }, + beforeDestroy() { + this.textarea.removeEventListener('keydown', this.onKeydown); + + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + }, + methods: { + getAcct, + exec() { + this.select = -1; + if (this.$refs.suggests) { + Array.from(this.items).forEach(el => { + el.removeAttribute('data-selected'); + }); + } + + if (this.type == 'user') { + const cache = sessionStorage.getItem(this.q); + if (cache) { + const users = JSON.parse(cache); + this.users = users; + this.fetching = false; + } else { + (this as any).api('users/search_by_username', { + query: this.q, + limit: 30 + }).then(users => { + this.users = users; + this.fetching = false; + + // キャッシュ + sessionStorage.setItem(this.q, JSON.stringify(users)); + }); + } + } else if (this.type == 'emoji') { + const matched = []; + emjdb.some(x => { + if (x.name.indexOf(this.q) == 0 && !x.alias && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == 30; + }); + if (matched.length < 30) { + emjdb.some(x => { + if (x.name.indexOf(this.q) == 0 && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == 30; + }); + } + if (matched.length < 30) { + emjdb.some(x => { + if (x.name.indexOf(this.q) > -1 && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == 30; + }); + } + this.emojis = matched; + } + }, + + onMousedown(e) { + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); + }, + + onKeydown(e) { + const cancel = () => { + e.preventDefault(); + e.stopPropagation(); + }; + + switch (e.which) { + case 10: // [ENTER] + case 13: // [ENTER] + if (this.select !== -1) { + cancel(); + (this.items[this.select] as any).click(); + } else { + this.close(); + } + break; + + case 27: // [ESC] + cancel(); + this.close(); + break; + + case 38: // [↑] + if (this.select !== -1) { + cancel(); + this.selectPrev(); + } else { + this.close(); + } + break; + + case 9: // [TAB] + case 40: // [↓] + cancel(); + this.selectNext(); + break; + + default: + e.stopPropagation(); + this.textarea.focus(); + } + }, + + selectNext() { + if (++this.select >= this.items.length) this.select = 0; + this.applySelect(); + }, + + selectPrev() { + if (--this.select < 0) this.select = this.items.length - 1; + this.applySelect(); + }, + + applySelect() { + Array.from(this.items).forEach(el => { + el.removeAttribute('data-selected'); + }); + + this.items[this.select].setAttribute('data-selected', 'true'); + (this.items[this.select] as any).focus(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-autocomplete + position fixed + z-index 65535 + margin-top calc(1em + 8px) + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + transition top 0.1s ease, left 0.1s ease + + > ol + display block + margin 0 + padding 4px 0 + max-height 190px + max-width 500px + overflow auto + list-style none + + > li + display block + padding 4px 12px + white-space nowrap + overflow hidden + font-size 0.9em + color rgba(0, 0, 0, 0.8) + cursor default + + &, * + user-select none + + &:hover + &[data-selected='true'] + background $theme-color + + &, * + color #fff !important + + &:active + background darken($theme-color, 10%) + + &, * + color #fff !important + + > .users > li + + .avatar + vertical-align middle + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 100% + + .name + margin 0 8px 0 0 + color rgba(0, 0, 0, 0.8) + + .username + color rgba(0, 0, 0, 0.3) + + > .emojis > li + + .emoji + display inline-block + margin 0 4px 0 0 + width 24px + + .name + color rgba(0, 0, 0, 0.8) + + .alias + margin 0 0 0 8px + color rgba(0, 0, 0, 0.3) + +</style> diff --git a/src/client/app/common/views/components/connect-failed.troubleshooter.vue b/src/client/app/common/views/components/connect-failed.troubleshooter.vue new file mode 100644 index 0000000000..cadbd36ba4 --- /dev/null +++ b/src/client/app/common/views/components/connect-failed.troubleshooter.vue @@ -0,0 +1,137 @@ +<template> +<div class="troubleshooter"> + <h1>%fa:wrench%%i18n:common.tags.mk-error.troubleshooter.title%</h1> + <div> + <p :data-wip="network == null"> + <template v-if="network != null"> + <template v-if="network">%fa:check%</template> + <template v-if="!network">%fa:times%</template> + </template> + {{ network == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-network%' : '%i18n:common.tags.mk-error.troubleshooter.network%' }}<mk-ellipsis v-if="network == null"/> + </p> + <p v-if="network == true" :data-wip="internet == null"> + <template v-if="internet != null"> + <template v-if="internet">%fa:check%</template> + <template v-if="!internet">%fa:times%</template> + </template> + {{ internet == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-internet%' : '%i18n:common.tags.mk-error.troubleshooter.internet%' }}<mk-ellipsis v-if="internet == null"/> + </p> + <p v-if="internet == true" :data-wip="server == null"> + <template v-if="server != null"> + <template v-if="server">%fa:check%</template> + <template v-if="!server">%fa:times%</template> + </template> + {{ server == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-server%' : '%i18n:common.tags.mk-error.troubleshooter.server%' }}<mk-ellipsis v-if="server == null"/> + </p> + </div> + <p v-if="!end">%i18n:common.tags.mk-error.troubleshooter.finding%<mk-ellipsis/></p> + <p v-if="network === false"><b>%fa:exclamation-triangle%%i18n:common.tags.mk-error.troubleshooter.no-network%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-network-desc%</p> + <p v-if="internet === false"><b>%fa:exclamation-triangle%%i18n:common.tags.mk-error.troubleshooter.no-internet%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-internet-desc%</p> + <p v-if="server === false"><b>%fa:exclamation-triangle%%i18n:common.tags.mk-error.troubleshooter.no-server%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-server-desc%</p> + <p v-if="server === true" class="success"><b>%fa:info-circle%%i18n:common.tags.mk-error.troubleshooter.success%</b><br>%i18n:common.tags.mk-error.troubleshooter.success-desc%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { apiUrl } from '../../../config'; + +export default Vue.extend({ + data() { + return { + network: navigator.onLine, + end: false, + internet: null, + server: null + }; + }, + mounted() { + if (!this.network) { + this.end = true; + return; + } + + // Check internet connection + fetch('https://google.com?rand=' + Math.random(), { + mode: 'no-cors' + }).then(() => { + this.internet = true; + + // Check misskey server is available + fetch(`${apiUrl}/meta`).then(() => { + this.end = true; + this.server = true; + }) + .catch(() => { + this.end = true; + this.server = false; + }); + }) + .catch(() => { + this.end = true; + this.internet = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.troubleshooter + width 100% + max-width 500px + text-align left + background #fff + border-radius 8px + border solid 1px #ddd + + > h1 + margin 0 + padding 0.6em 1.2em + font-size 1em + color #444 + border-bottom solid 1px #eee + + > [data-fa] + margin-right 0.25em + + > div + overflow hidden + padding 0.6em 1.2em + + > p + margin 0.5em 0 + font-size 0.9em + color #444 + + &[data-wip] + color #888 + + > [data-fa] + margin-right 0.25em + + &.times + color #e03524 + + &.check + color #84c32f + + > p + margin 0 + padding 0.7em 1.2em + font-size 1em + color #444 + border-top solid 1px #eee + + > b + > [data-fa] + margin-right 0.25em + + &.success + > b + color #39adad + + &:not(.success) + > b + color #ad4339 + +</style> diff --git a/src/client/app/common/views/components/connect-failed.vue b/src/client/app/common/views/components/connect-failed.vue new file mode 100644 index 0000000000..185250dbd8 --- /dev/null +++ b/src/client/app/common/views/components/connect-failed.vue @@ -0,0 +1,106 @@ +<template> +<div class="mk-connect-failed"> + <img src="data:image/jpeg;base64,%base64:/assets/error.jpg%" alt=""/> + <h1>%i18n:common.tags.mk-error.title%</h1> + <p class="text"> + {{ '%i18n:common.tags.mk-error.description%'.substr(0, '%i18n:common.tags.mk-error.description%'.indexOf('{')) }} + <a @click="reload">{{ '%i18n:common.tags.mk-error.description%'.match(/\{(.+?)\}/)[1] }}</a> + {{ '%i18n:common.tags.mk-error.description%'.substr('%i18n:common.tags.mk-error.description%'.indexOf('}') + 1) }} + </p> + <button v-if="!troubleshooting" @click="troubleshooting = true">%i18n:common.tags.mk-error.troubleshoot%</button> + <x-troubleshooter v-if="troubleshooting"/> + <p class="thanks">%i18n:common.tags.mk-error.thanks%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XTroubleshooter from './connect-failed.troubleshooter.vue'; + +export default Vue.extend({ + components: { + XTroubleshooter + }, + data() { + return { + troubleshooting: false + }; + }, + mounted() { + document.title = 'Oops!'; + document.documentElement.style.background = '#f8f8f8'; + }, + methods: { + reload() { + location.reload(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-connect-failed + width 100% + padding 32px 18px + text-align center + + > img + display block + height 200px + margin 0 auto + pointer-events none + user-select none + + > h1 + display block + margin 1.25em auto 0.65em auto + font-size 1.5em + color #555 + + > .text + display block + margin 0 auto + max-width 600px + font-size 1em + color #666 + + > button + display block + margin 1em auto 0 auto + padding 8px 10px + color $theme-color-foreground + background $theme-color + + &:focus + outline solid 3px rgba($theme-color, 0.3) + + &:hover + background lighten($theme-color, 10%) + + &:active + background darken($theme-color, 10%) + + > .troubleshooter + margin 1em auto 0 auto + + > .thanks + display block + margin 2em auto 0 auto + padding 2em 0 0 0 + max-width 600px + font-size 0.9em + font-style oblique + color #aaa + border-top solid 1px #eee + + @media (max-width 500px) + padding 24px 18px + font-size 80% + + > img + height 150px + +</style> + diff --git a/src/client/app/common/views/components/ellipsis.vue b/src/client/app/common/views/components/ellipsis.vue new file mode 100644 index 0000000000..07349902de --- /dev/null +++ b/src/client/app/common/views/components/ellipsis.vue @@ -0,0 +1,26 @@ +<template> + <span class="mk-ellipsis"> + <span>.</span><span>.</span><span>.</span> + </span> +</template> + +<style lang="stylus" scoped> +.mk-ellipsis + > span + animation ellipsis 1.4s infinite ease-in-out both + + &:nth-child(1) + animation-delay 0s + + &:nth-child(2) + animation-delay 0.16s + + &:nth-child(3) + animation-delay 0.32s + + @keyframes ellipsis + 0%, 80%, 100% + opacity 1 + 40% + opacity 0 +</style> diff --git a/src/client/app/common/views/components/file-type-icon.vue b/src/client/app/common/views/components/file-type-icon.vue new file mode 100644 index 0000000000..b7e868d1f7 --- /dev/null +++ b/src/client/app/common/views/components/file-type-icon.vue @@ -0,0 +1,17 @@ +<template> +<span class="mk-file-type-icon"> + <template v-if="kind == 'image'">%fa:file-image%</template> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['type'], + computed: { + kind(): string { + return this.type.split('/')[0]; + } + } +}); +</script> diff --git a/src/client/app/common/views/components/forkit.vue b/src/client/app/common/views/components/forkit.vue new file mode 100644 index 0000000000..6f334b965a --- /dev/null +++ b/src/client/app/common/views/components/forkit.vue @@ -0,0 +1,42 @@ +<template> +<a class="a" href="https://github.com/syuilo/misskey" target="_blank" title="%i18n:common.tags.mk-forkit.open-github-link%" aria-label="%i18n:common.tags.mk-forkit.open-github-link%"> + <svg width="80" height="80" viewBox="0 0 250 250" aria-hidden="aria-hidden"> + <path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path> + <path class="octo-arm" d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor"></path> + <path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor"></path> + </svg> +</a> +</template> + +<style lang="stylus" scoped> +@import '~const.styl' + +.a + display block + position absolute + top 0 + right 0 + + > svg + display block + //fill #151513 + //color #fff + fill $theme-color + color $theme-color-foreground + + .octo-arm + transform-origin 130px 106px + + &:hover + .octo-arm + animation octocat-wave 560ms ease-in-out + + @keyframes octocat-wave + 0%, 100% + transform rotate(0) + 20%, 60% + transform rotate(-25deg) + 40%, 80% + transform rotate(10deg) + +</style> diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts new file mode 100644 index 0000000000..b58ba37ecb --- /dev/null +++ b/src/client/app/common/views/components/index.ts @@ -0,0 +1,51 @@ +import Vue from 'vue'; + +import signin from './signin.vue'; +import signup from './signup.vue'; +import forkit from './forkit.vue'; +import nav from './nav.vue'; +import postHtml from './post-html'; +import poll from './poll.vue'; +import pollEditor from './poll-editor.vue'; +import reactionIcon from './reaction-icon.vue'; +import reactionsViewer from './reactions-viewer.vue'; +import time from './time.vue'; +import timer from './timer.vue'; +import mediaList from './media-list.vue'; +import uploader from './uploader.vue'; +import specialMessage from './special-message.vue'; +import streamIndicator from './stream-indicator.vue'; +import ellipsis from './ellipsis.vue'; +import messaging from './messaging.vue'; +import messagingRoom from './messaging-room.vue'; +import urlPreview from './url-preview.vue'; +import twitterSetting from './twitter-setting.vue'; +import fileTypeIcon from './file-type-icon.vue'; +import Switch from './switch.vue'; +import Othello from './othello.vue'; +import welcomeTimeline from './welcome-timeline.vue'; + +Vue.component('mk-signin', signin); +Vue.component('mk-signup', signup); +Vue.component('mk-forkit', forkit); +Vue.component('mk-nav', nav); +Vue.component('mk-post-html', postHtml); +Vue.component('mk-poll', poll); +Vue.component('mk-poll-editor', pollEditor); +Vue.component('mk-reaction-icon', reactionIcon); +Vue.component('mk-reactions-viewer', reactionsViewer); +Vue.component('mk-time', time); +Vue.component('mk-timer', timer); +Vue.component('mk-media-list', mediaList); +Vue.component('mk-uploader', uploader); +Vue.component('mk-special-message', specialMessage); +Vue.component('mk-stream-indicator', streamIndicator); +Vue.component('mk-ellipsis', ellipsis); +Vue.component('mk-messaging', messaging); +Vue.component('mk-messaging-room', messagingRoom); +Vue.component('mk-url-preview', urlPreview); +Vue.component('mk-twitter-setting', twitterSetting); +Vue.component('mk-file-type-icon', fileTypeIcon); +Vue.component('mk-switch', Switch); +Vue.component('mk-othello', Othello); +Vue.component('mk-welcome-timeline', welcomeTimeline); diff --git a/src/client/app/common/views/components/media-list.vue b/src/client/app/common/views/components/media-list.vue new file mode 100644 index 0000000000..64172ad0b4 --- /dev/null +++ b/src/client/app/common/views/components/media-list.vue @@ -0,0 +1,57 @@ +<template> +<div class="mk-media-list" :data-count="mediaList.length"> + <template v-for="media in mediaList"> + <mk-media-video :video="media" :key="media.id" v-if="media.type.startsWith('video')" :inline-playable="mediaList.length === 1"/> + <mk-media-image :image="media" :key="media.id" v-else /> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['mediaList'], +}); +</script> + +<style lang="stylus" scoped> +.mk-media-list + display grid + grid-gap 4px + height 256px + + @media (max-width 500px) + height 192px + + &[data-count="1"] + grid-template-rows 1fr + &[data-count="2"] + grid-template-columns 1fr 1fr + grid-template-rows 1fr + &[data-count="3"] + grid-template-columns 1fr 0.5fr + grid-template-rows 1fr 1fr + :nth-child(1) + grid-row 1 / 3 + :nth-child(3) + grid-column 2 / 3 + grid-row 2/3 + &[data-count="4"] + grid-template-columns 1fr 1fr + grid-template-rows 1fr 1fr + + :nth-child(1) + grid-column 1 / 2 + grid-row 1 / 2 + :nth-child(2) + grid-column 2 / 3 + grid-row 1 / 2 + :nth-child(3) + grid-column 1 / 2 + grid-row 2 / 3 + :nth-child(4) + grid-column 2 / 3 + grid-row 2 / 3 + +</style> diff --git a/src/client/app/common/views/components/messaging-room.form.vue b/src/client/app/common/views/components/messaging-room.form.vue new file mode 100644 index 0000000000..704f2016d8 --- /dev/null +++ b/src/client/app/common/views/components/messaging-room.form.vue @@ -0,0 +1,305 @@ +<template> +<div class="mk-messaging-form" + @dragover.stop="onDragover" + @drop.stop="onDrop" +> + <textarea + v-model="text" + ref="textarea" + @keypress="onKeypress" + @paste="onPaste" + placeholder="%i18n:common.input-message-here%" + v-autocomplete="'text'" + ></textarea> + <div class="file" @click="file = null" v-if="file">{{ file.name }}</div> + <mk-uploader ref="uploader" @uploaded="onUploaded"/> + <button class="send" @click="send" :disabled="!canSend || sending" title="%i18n:common.send%"> + <template v-if="!sending">%fa:paper-plane%</template><template v-if="sending">%fa:spinner .spin%</template> + </button> + <button class="attach-from-local" @click="chooseFile" title="%i18n:common.tags.mk-messaging-form.attach-from-local%"> + %fa:upload% + </button> + <button class="attach-from-drive" @click="chooseFileFromDrive" title="%i18n:common.tags.mk-messaging-form.attach-from-drive%"> + %fa:R folder-open% + </button> + <input ref="file" type="file" @change="onChangeFile"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as autosize from 'autosize'; + +export default Vue.extend({ + props: ['user'], + data() { + return { + text: null, + file: null, + sending: false + }; + }, + computed: { + draftId(): string { + return this.user.id; + }, + canSend(): boolean { + return (this.text != null && this.text != '') || this.file != null; + }, + room(): any { + return this.$parent; + } + }, + watch: { + text() { + this.saveDraft(); + }, + file() { + this.saveDraft(); + + if (this.room.isBottom()) { + this.room.scrollToBottom(); + } + } + }, + mounted() { + autosize(this.$refs.textarea); + + // 書きかけの投稿を復元 + const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftId]; + if (draft) { + this.text = draft.data.text; + this.file = draft.data.file; + } + }, + methods: { + onPaste(e) { + const data = e.clipboardData; + const items = data.items; + + if (items.length == 1) { + if (items[0].kind == 'file') { + this.upload(items[0].getAsFile()); + } + } else { + if (items[0].kind == 'file') { + alert('メッセージに添付できるのはひとつのファイルのみです'); + } + } + }, + + onDragover(e) { + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + if (isFile || isDriveFile) { + e.preventDefault(); + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } + }, + + onDrop(e): void { + // ファイルだったら + if (e.dataTransfer.files.length == 1) { + e.preventDefault(); + this.upload(e.dataTransfer.files[0]); + return; + } else if (e.dataTransfer.files.length > 1) { + e.preventDefault(); + alert('メッセージに添付できるのはひとつのファイルのみです'); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + this.file = JSON.parse(driveFile); + e.preventDefault(); + } + //#endregion + }, + + onKeypress(e) { + if ((e.which == 10 || e.which == 13) && e.ctrlKey) { + this.send(); + } + }, + + chooseFile() { + (this.$refs.file as any).click(); + }, + + chooseFileFromDrive() { + (this as any).apis.chooseDriveFile({ + multiple: false + }).then(file => { + this.file = file; + }); + }, + + onChangeFile() { + this.upload((this.$refs.file as any).files[0]); + }, + + upload(file) { + (this.$refs.uploader as any).upload(file); + }, + + onUploaded(file) { + this.file = file; + }, + + send() { + this.sending = true; + (this as any).api('messaging/messages/create', { + userId: this.user.id, + text: this.text ? this.text : undefined, + fileId: this.file ? this.file.id : undefined + }).then(message => { + this.clear(); + }).catch(err => { + console.error(err); + }).then(() => { + this.sending = false; + }); + }, + + clear() { + this.text = ''; + this.file = null; + this.deleteDraft(); + }, + + saveDraft() { + const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + data[this.draftId] = { + updatedAt: new Date(), + data: { + text: this.text, + file: this.file + } + } + + localStorage.setItem('message_drafts', JSON.stringify(data)); + }, + + deleteDraft() { + const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + delete data[this.draftId]; + + localStorage.setItem('message_drafts', JSON.stringify(data)); + }, + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-messaging-form + > textarea + cursor auto + display block + width 100% + min-width 100% + max-width 100% + height 64px + margin 0 + padding 8px + resize none + font-size 1em + color #000 + outline none + border none + border-top solid 1px #eee + border-radius 0 + box-shadow none + background transparent + + > .file + padding 8px + color #444 + background #eee + cursor pointer + + > .send + position absolute + bottom 0 + right 0 + margin 0 + padding 10px 14px + font-size 1em + color #aaa + transition color 0.1s ease + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + + .files + display block + margin 0 + padding 0 8px + list-style none + + &:after + content '' + display block + clear both + + > li + display block + float left + margin 4px + padding 0 + width 64px + height 64px + background-color #eee + background-repeat no-repeat + background-position center center + background-size cover + cursor move + + &:hover + > .remove + display block + + > .remove + display none + position absolute + right -6px + top -6px + margin 0 + padding 0 + background transparent + outline none + border none + border-radius 0 + box-shadow none + cursor pointer + + .attach-from-local + .attach-from-drive + margin 0 + padding 10px 14px + font-size 1em + font-weight normal + text-decoration none + color #aaa + transition color 0.1s ease + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + + input[type=file] + display none + +</style> diff --git a/src/client/app/common/views/components/messaging-room.message.vue b/src/client/app/common/views/components/messaging-room.message.vue new file mode 100644 index 0000000000..94f87fd709 --- /dev/null +++ b/src/client/app/common/views/components/messaging-room.message.vue @@ -0,0 +1,263 @@ +<template> +<div class="message" :data-is-me="isMe"> + <router-link class="avatar-anchor" :to="`/@${acct}`" :title="acct" target="_blank"> + <img class="avatar" :src="`${message.user.avatarUrl}?thumbnail&size=80`" alt=""/> + </router-link> + <div class="content"> + <div class="balloon" :data-no-text="message.text == null"> + <p class="read" v-if="isMe && message.isRead">%i18n:common.tags.mk-messaging-message.is-read%</p> + <button class="delete-button" v-if="isMe" title="%i18n:common.delete%"> + <img src="/assets/desktop/messaging/delete.png" alt="Delete"/> + </button> + <div class="content" v-if="!message.isDeleted"> + <mk-post-html class="text" v-if="message.ast" :ast="message.ast" :i="os.i"/> + <div class="file" v-if="message.file"> + <a :href="message.file.url" target="_blank" :title="message.file.name"> + <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/> + <p v-else>{{ message.file.name }}</p> + </a> + </div> + </div> + <div class="content" v-if="message.isDeleted"> + <p class="is-deleted">%i18n:common.tags.mk-messaging-message.deleted%</p> + </div> + </div> + <div></div> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <footer> + <mk-time :time="message.createdAt"/> + <template v-if="message.is_edited">%fa:pencil-alt%</template> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['message'], + computed: { + acct() { + return getAcct(this.message.user); + }, + isMe(): boolean { + return this.message.userId == (this as any).os.i.id; + }, + urls(): string[] { + if (this.message.ast) { + return this.message.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.message + $me-balloon-color = #23A7B6 + + padding 10px 12px 10px 12px + background-color transparent + + > .avatar-anchor + display block + position absolute + top 10px + + > .avatar + display block + min-width 54px + min-height 54px + max-width 54px + max-height 54px + margin 0 + border-radius 8px + transition all 0.1s ease + + > .content + + > .balloon + display block + padding 0 + max-width calc(100% - 16px) + min-height 38px + border-radius 16px + + &:before + content "" + pointer-events none + display block + position absolute + top 12px + + & + * + clear both + + &:hover + > .delete-button + display block + + > .delete-button + display none + position absolute + z-index 1 + top -4px + right -4px + margin 0 + padding 0 + cursor pointer + outline none + border none + border-radius 0 + box-shadow none + background transparent + + > img + vertical-align bottom + width 16px + height 16px + cursor pointer + + > .read + user-select none + display block + position absolute + z-index 1 + bottom -4px + left -12px + margin 0 + color rgba(0, 0, 0, 0.5) + font-size 11px + + > .content + + > .is-deleted + display block + margin 0 + padding 0 + overflow hidden + overflow-wrap break-word + font-size 1em + color rgba(0, 0, 0, 0.5) + + > .text + display block + margin 0 + padding 8px 16px + overflow hidden + overflow-wrap break-word + font-size 1em + color rgba(0, 0, 0, 0.8) + + & + .file + > a + border-radius 0 0 16px 16px + + > .file + > a + display block + max-width 100% + max-height 512px + border-radius 16px + overflow hidden + text-decoration none + + &:hover + text-decoration none + + > p + background #ccc + + > * + display block + margin 0 + width 100% + height 100% + + > p + padding 30px + text-align center + color #555 + background #ddd + + > .mk-url-preview + margin 8px 0 + + > footer + display block + margin 2px 0 0 0 + font-size 10px + color rgba(0, 0, 0, 0.4) + + > [data-fa] + margin-left 4px + + &:not([data-is-me]) + > .avatar-anchor + left 12px + + > .content + padding-left 66px + + > .balloon + float left + background #eee + + &[data-no-text] + background transparent + + &:not([data-no-text]):before + left -14px + border-top solid 8px transparent + border-right solid 8px #eee + border-bottom solid 8px transparent + border-left solid 8px transparent + + > footer + text-align left + + &[data-is-me] + > .avatar-anchor + right 12px + + > .content + padding-right 66px + + > .balloon + float right + background $me-balloon-color + + &[data-no-text] + background transparent + + &:not([data-no-text]):before + right -14px + left auto + border-top solid 8px transparent + border-right solid 8px transparent + border-bottom solid 8px transparent + border-left solid 8px $me-balloon-color + + > .content + + > p.is-deleted + color rgba(255, 255, 255, 0.5) + + > .text >>> + &, * + color #fff !important + + > footer + text-align right + + &[data-is-deleted] + > .baloon + opacity 0.5 + +</style> diff --git a/src/client/app/common/views/components/messaging-room.vue b/src/client/app/common/views/components/messaging-room.vue new file mode 100644 index 0000000000..d30c64d74a --- /dev/null +++ b/src/client/app/common/views/components/messaging-room.vue @@ -0,0 +1,377 @@ +<template> +<div class="mk-messaging-room" + @dragover.prevent.stop="onDragover" + @drop.prevent.stop="onDrop" +> + <div class="stream"> + <p class="init" v-if="init">%fa:spinner .spin%%i18n:common.loading%</p> + <p class="empty" v-if="!init && messages.length == 0">%fa:info-circle%%i18n:common.tags.mk-messaging-room.empty%</p> + <p class="no-history" v-if="!init && messages.length > 0 && !existMoreMessages">%fa:flag%%i18n:common.tags.mk-messaging-room.no-history%</p> + <button class="more" :class="{ fetching: fetchingMoreMessages }" v-if="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages"> + <template v-if="fetchingMoreMessages">%fa:spinner .pulse .fw%</template>{{ fetchingMoreMessages ? '%i18n:common.loading%' : '%i18n:common.tags.mk-messaging-room.more%' }} + </button> + <template v-for="(message, i) in _messages"> + <x-message :message="message" :key="message.id"/> + <p class="date" v-if="i != messages.length - 1 && message._date != _messages[i + 1]._date"> + <span>{{ _messages[i + 1]._datetext }}</span> + </p> + </template> + </div> + <footer> + <div ref="notifications" class="notifications"></div> + <x-form :user="user" ref="form"/> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { MessagingStream } from '../../scripts/streaming/messaging'; +import XMessage from './messaging-room.message.vue'; +import XForm from './messaging-room.form.vue'; +import { url } from '../../../config'; + +export default Vue.extend({ + components: { + XMessage, + XForm + }, + + props: ['user', 'isNaked'], + + data() { + return { + init: true, + fetchingMoreMessages: false, + messages: [], + existMoreMessages: false, + connection: null + }; + }, + + computed: { + _messages(): any[] { + return (this.messages as any).map(message => { + const date = new Date(message.createdAt).getDate(); + const month = new Date(message.createdAt).getMonth() + 1; + message._date = date; + message._datetext = `${month}月 ${date}日`; + return message; + }); + }, + + form(): any { + return this.$refs.form; + } + }, + + mounted() { + this.connection = new MessagingStream((this as any).os, (this as any).os.i, this.user.id); + + this.connection.on('message', this.onMessage); + this.connection.on('read', this.onRead); + + document.addEventListener('visibilitychange', this.onVisibilitychange); + + this.fetchMessages().then(() => { + this.init = false; + this.scrollToBottom(); + }); + }, + + beforeDestroy() { + this.connection.off('message', this.onMessage); + this.connection.off('read', this.onRead); + this.connection.close(); + + document.removeEventListener('visibilitychange', this.onVisibilitychange); + }, + + methods: { + onDragover(e) { + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + + if (isFile || isDriveFile) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + }, + + onDrop(e): void { + // ファイルだったら + if (e.dataTransfer.files.length == 1) { + this.form.upload(e.dataTransfer.files[0]); + return; + } else if (e.dataTransfer.files.length > 1) { + alert('メッセージに添付できるのはひとつのファイルのみです'); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.form.file = file; + } + //#endregion + }, + + fetchMessages() { + return new Promise((resolve, reject) => { + const max = this.existMoreMessages ? 20 : 10; + + (this as any).api('messaging/messages', { + userId: this.user.id, + limit: max + 1, + untilId: this.existMoreMessages ? this.messages[0].id : undefined + }).then(messages => { + if (messages.length == max + 1) { + this.existMoreMessages = true; + messages.pop(); + } else { + this.existMoreMessages = false; + } + + this.messages.unshift.apply(this.messages, messages.reverse()); + resolve(); + }); + }); + }, + + fetchMoreMessages() { + this.fetchingMoreMessages = true; + this.fetchMessages().then(() => { + this.fetchingMoreMessages = false; + }); + }, + + onMessage(message) { + // サウンドを再生する + if ((this as any).os.isEnableSounds) { + const sound = new Audio(`${url}/assets/message.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + + const isBottom = this.isBottom(); + + this.messages.push(message); + if (message.userId != (this as any).os.i.id && !document.hidden) { + this.connection.send({ + type: 'read', + id: message.id + }); + } + + if (isBottom) { + // Scroll to bottom + this.$nextTick(() => { + this.scrollToBottom(); + }); + } else if (message.userId != (this as any).os.i.id) { + // Notify + this.notify('%i18n:common.tags.mk-messaging-room.new-message%'); + } + }, + + onRead(ids) { + if (!Array.isArray(ids)) ids = [ids]; + ids.forEach(id => { + if (this.messages.some(x => x.id == id)) { + const exist = this.messages.map(x => x.id).indexOf(id); + this.messages[exist].isRead = true; + } + }); + }, + + isBottom() { + const asobi = 64; + const current = this.isNaked + ? window.scrollY + window.innerHeight + : this.$el.scrollTop + this.$el.offsetHeight; + const max = this.isNaked + ? document.body.offsetHeight + : this.$el.scrollHeight; + return current > (max - asobi); + }, + + scrollToBottom() { + if (this.isNaked) { + window.scroll(0, document.body.offsetHeight); + } else { + this.$el.scrollTop = this.$el.scrollHeight; + } + }, + + notify(message) { + const n = document.createElement('p') as any; + n.innerHTML = '%fa:arrow-circle-down%' + message; + n.onclick = () => { + this.scrollToBottom(); + n.parentNode.removeChild(n); + }; + (this.$refs.notifications as any).appendChild(n); + + setTimeout(() => { + n.style.opacity = 0; + setTimeout(() => n.parentNode.removeChild(n), 1000); + }, 4000); + }, + + onVisibilitychange() { + if (document.hidden) return; + this.messages.forEach(message => { + if (message.userId !== (this as any).os.i.id && !message.isRead) { + this.connection.send({ + type: 'read', + id: message.id + }); + } + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-messaging-room + display flex + flex 1 + flex-direction column + height 100% + + > .stream + width 100% + max-width 600px + margin 0 auto + flex 1 + + > .init + width 100% + margin 0 + padding 16px 8px 8px 8px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + [data-fa] + margin-right 4px + + > .empty + width 100% + margin 0 + padding 16px 8px 8px 8px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + [data-fa] + margin-right 4px + + > .no-history + display block + margin 0 + padding 16px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + [data-fa] + margin-right 4px + + > .more + display block + margin 16px auto + padding 0 12px + line-height 24px + color #fff + background rgba(0, 0, 0, 0.3) + border-radius 12px + + &:hover + background rgba(0, 0, 0, 0.4) + + &:active + background rgba(0, 0, 0, 0.5) + + &.fetching + cursor wait + + > [data-fa] + margin-right 4px + + > .message + // something + + > .date + display block + margin 8px 0 + text-align center + + &:before + content '' + display block + position absolute + height 1px + width 90% + top 16px + left 0 + right 0 + margin 0 auto + background rgba(0, 0, 0, 0.1) + + > span + display inline-block + margin 0 + padding 0 16px + //font-weight bold + line-height 32px + color rgba(0, 0, 0, 0.3) + background #fff + + > footer + position -webkit-sticky + position sticky + z-index 2 + bottom 0 + width 100% + max-width 600px + margin 0 auto + padding 0 + background rgba(255, 255, 255, 0.95) + background-clip content-box + + > .notifications + position absolute + top -48px + width 100% + padding 8px 0 + text-align center + + &:empty + display none + + > p + display inline-block + margin 0 + padding 0 12px 0 28px + cursor pointer + line-height 32px + font-size 12px + color $theme-color-foreground + background $theme-color + border-radius 16px + transition opacity 1s ease + + > [data-fa] + position absolute + top 0 + left 10px + line-height 32px + font-size 16px + +</style> diff --git a/src/client/app/common/views/components/messaging.vue b/src/client/app/common/views/components/messaging.vue new file mode 100644 index 0000000000..8317c3738a --- /dev/null +++ b/src/client/app/common/views/components/messaging.vue @@ -0,0 +1,463 @@ +<template> +<div class="mk-messaging" :data-compact="compact"> + <div class="search" v-if="!compact" :style="{ top: headerTop + 'px' }"> + <div class="form"> + <label for="search-input">%fa:search%</label> + <input v-model="q" type="search" @input="search" @keydown="onSearchKeydown" placeholder="%i18n:common.tags.mk-messaging.search-user%"/> + </div> + <div class="result"> + <ol class="users" v-if="result.length > 0" ref="searchResult"> + <li v-for="(user, i) in result" + @keydown.enter="navigate(user)" + @keydown="onSearchResultKeydown(i)" + @click="navigate(user)" + tabindex="-1" + > + <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=32`" alt=""/> + <span class="name">{{ user.name }}</span> + <span class="username">@{{ getAcct(user) }}</span> + </li> + </ol> + </div> + </div> + <div class="history" v-if="messages.length > 0"> + <template> + <a v-for="message in messages" + class="user" + :href="`/i/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" + :data-is-me="isMe(message)" + :data-is-read="message.isRead" + @click.prevent="navigate(isMe(message) ? message.recipient : message.user)" + :key="message.id" + > + <div> + <img class="avatar" :src="`${isMe(message) ? message.recipient.avatarUrl : message.user.avatarUrl}?thumbnail&size=64`" alt=""/> + <header> + <span class="name">{{ isMe(message) ? message.recipient.name : message.user.name }}</span> + <span class="username">@{{ getAcct(isMe(message) ? message.recipient : message.user) }}</span> + <mk-time :time="message.createdAt"/> + </header> + <div class="body"> + <p class="text"><span class="me" v-if="isMe(message)">%i18n:common.tags.mk-messaging.you%:</span>{{ message.text }}</p> + </div> + </div> + </a> + </template> + </div> + <p class="no-history" v-if="!fetching && messages.length == 0">%i18n:common.tags.mk-messaging.no-history%</p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: { + compact: { + type: Boolean, + default: false + }, + headerTop: { + type: Number, + default: 0 + } + }, + data() { + return { + fetching: true, + moreFetching: false, + messages: [], + q: null, + result: [], + connection: null, + connectionId: null + }; + }, + mounted() { + this.connection = (this as any).os.streams.messagingIndexStream.getConnection(); + this.connectionId = (this as any).os.streams.messagingIndexStream.use(); + + this.connection.on('message', this.onMessage); + this.connection.on('read', this.onRead); + + (this as any).api('messaging/history').then(messages => { + this.messages = messages; + this.fetching = false; + }); + }, + beforeDestroy() { + this.connection.off('message', this.onMessage); + this.connection.off('read', this.onRead); + (this as any).os.streams.messagingIndexStream.dispose(this.connectionId); + }, + methods: { + getAcct, + isMe(message) { + return message.userId == (this as any).os.i.id; + }, + onMessage(message) { + this.messages = this.messages.filter(m => !( + (m.recipientId == message.recipientId && m.userId == message.userId) || + (m.recipientId == message.userId && m.userId == message.recipientId))); + + this.messages.unshift(message); + }, + onRead(ids) { + ids.forEach(id => { + const found = this.messages.find(m => m.id == id); + if (found) found.isRead = true; + }); + }, + search() { + if (this.q == '') { + this.result = []; + return; + } + (this as any).api('users/search', { + query: this.q, + max: 5 + }).then(users => { + this.result = users; + }); + }, + navigate(user) { + this.$emit('navigate', user); + }, + onSearchKeydown(e) { + switch (e.which) { + case 9: // [TAB] + case 40: // [↓] + e.preventDefault(); + e.stopPropagation(); + (this.$refs.searchResult as any).childNodes[0].focus(); + break; + } + }, + onSearchResultKeydown(i, e) { + const list = this.$refs.searchResult as any; + + const cancel = () => { + e.preventDefault(); + e.stopPropagation(); + }; + + switch (true) { + case e.which == 27: // [ESC] + cancel(); + (this.$refs.search as any).focus(); + break; + + case e.which == 9 && e.shiftKey: // [TAB] + [Shift] + case e.which == 38: // [↑] + cancel(); + (list.childNodes[i].previousElementSibling || list.childNodes[this.result.length - 1]).focus(); + break; + + case e.which == 9: // [TAB] + case e.which == 40: // [↓] + cancel(); + (list.childNodes[i].nextElementSibling || list.childNodes[0]).focus(); + break; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-messaging + + &[data-compact] + font-size 0.8em + + > .history + > a + &:last-child + border-bottom none + + &:not([data-is-me]):not([data-is-read]) + > div + background-image none + border-left solid 4px #3aa2dc + + > div + padding 16px + + > header + > .mk-time + font-size 1em + + > .avatar + width 42px + height 42px + margin 0 12px 0 0 + + > .search + display block + position -webkit-sticky + position sticky + top 0 + left 0 + z-index 1 + width 100% + background #fff + box-shadow 0 0px 2px rgba(0, 0, 0, 0.2) + + > .form + padding 8px + background #f7f7f7 + + > label + display block + position absolute + top 0 + left 8px + z-index 1 + height 100% + width 38px + pointer-events none + + > [data-fa] + display block + position absolute + top 0 + right 0 + bottom 0 + left 0 + width 1em + line-height 56px + margin auto + color #555 + + > input + margin 0 + padding 0 0 0 32px + width 100% + font-size 1em + line-height 38px + color #000 + outline none + border solid 1px #eee + border-radius 5px + box-shadow none + transition color 0.5s ease, border 0.5s ease + + &:hover + border solid 1px #ddd + transition border 0.2s ease + + &:focus + color darken($theme-color, 20%) + border solid 1px $theme-color + transition color 0, border 0 + + > .result + display block + top 0 + left 0 + z-index 2 + width 100% + margin 0 + padding 0 + background #fff + + > .users + margin 0 + padding 0 + list-style none + + > li + display inline-block + z-index 1 + width 100% + padding 8px 32px + vertical-align top + white-space nowrap + overflow hidden + color rgba(0, 0, 0, 0.8) + text-decoration none + transition none + cursor pointer + + &:hover + &:focus + color #fff + background $theme-color + + .name + color #fff + + .username + color #fff + + &:active + color #fff + background darken($theme-color, 10%) + + .name + color #fff + + .username + color #fff + + .avatar + vertical-align middle + min-width 32px + min-height 32px + max-width 32px + max-height 32px + margin 0 8px 0 0 + border-radius 6px + + .name + margin 0 8px 0 0 + /*font-weight bold*/ + font-weight normal + color rgba(0, 0, 0, 0.8) + + .username + font-weight normal + color rgba(0, 0, 0, 0.3) + + > .history + + > a + display block + text-decoration none + background #fff + border-bottom solid 1px #eee + + * + pointer-events none + user-select none + + &:hover + background #fafafa + + > .avatar + filter saturate(200%) + + &:active + background #eee + + &[data-is-read] + &[data-is-me] + opacity 0.8 + + &:not([data-is-me]):not([data-is-read]) + > div + background-image url("/assets/unread.svg") + background-repeat no-repeat + background-position 0 center + + &:after + content "" + display block + clear both + + > div + max-width 500px + margin 0 auto + padding 20px 30px + + &:after + content "" + display block + clear both + + > header + display flex + align-items center + margin-bottom 2px + white-space nowrap + overflow hidden + + > .name + margin 0 + padding 0 + overflow hidden + text-overflow ellipsis + font-size 1em + color rgba(0, 0, 0, 0.9) + font-weight bold + transition all 0.1s ease + + > .username + margin 0 8px + color rgba(0, 0, 0, 0.5) + + > .mk-time + margin 0 0 0 auto + color rgba(0, 0, 0, 0.5) + font-size 80% + + > .avatar + float left + width 54px + height 54px + margin 0 16px 0 0 + border-radius 8px + transition all 0.1s ease + + > .body + + > .text + display block + margin 0 0 0 0 + padding 0 + overflow hidden + overflow-wrap break-word + font-size 1.1em + color rgba(0, 0, 0, 0.8) + + .me + color rgba(0, 0, 0, 0.4) + + > .image + display block + max-width 100% + max-height 512px + + > .no-history + margin 0 + padding 2em 1em + text-align center + color #999 + font-weight 500 + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + + // TODO: element base media query + @media (max-width 400px) + > .search + > .result + > .users + > li + padding 8px 16px + + > .history + > a + &:not([data-is-me]):not([data-is-read]) + > div + background-image none + border-left solid 4px #3aa2dc + + > div + padding 16px + font-size 14px + + > .avatar + margin 0 12px 0 0 + +</style> diff --git a/src/client/app/common/views/components/nav.vue b/src/client/app/common/views/components/nav.vue new file mode 100644 index 0000000000..8ce75d3529 --- /dev/null +++ b/src/client/app/common/views/components/nav.vue @@ -0,0 +1,41 @@ +<template> +<span class="mk-nav"> + <a :href="aboutUrl">%i18n:common.tags.mk-nav-links.about%</a> + <i>・</i> + <a :href="statsUrl">%i18n:common.tags.mk-nav-links.stats%</a> + <i>・</i> + <a :href="statusUrl">%i18n:common.tags.mk-nav-links.status%</a> + <i>・</i> + <a href="http://zawazawa.jp/misskey/">%i18n:common.tags.mk-nav-links.wiki%</a> + <i>・</i> + <a href="https://github.com/syuilo/misskey/blob/master/DONORS.md">%i18n:common.tags.mk-nav-links.donors%</a> + <i>・</i> + <a href="https://github.com/syuilo/misskey">%i18n:common.tags.mk-nav-links.repository%</a> + <i>・</i> + <a :href="devUrl">%i18n:common.tags.mk-nav-links.develop%</a> + <i>・</i> + <a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on %fa:B twitter%</a> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { docsUrl, statsUrl, statusUrl, devUrl, lang } from '../../../config'; + +export default Vue.extend({ + data() { + return { + aboutUrl: `${docsUrl}/${lang}/about`, + statsUrl, + statusUrl, + devUrl + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-nav + a + color inherit +</style> diff --git a/src/client/app/common/views/components/othello.game.vue b/src/client/app/common/views/components/othello.game.vue new file mode 100644 index 0000000000..f08742ad10 --- /dev/null +++ b/src/client/app/common/views/components/othello.game.vue @@ -0,0 +1,324 @@ +<template> +<div class="root"> + <header><b>{{ blackUser.name }}</b>(黒) vs <b>{{ whiteUser.name }}</b>(白)</header> + + <div style="overflow: hidden"> + <p class="turn" v-if="!iAmPlayer && !game.isEnded">{{ turnUser.name }}のターンです<mk-ellipsis/></p> + <p class="turn" v-if="logPos != logs.length">{{ turnUser.name }}のターン</p> + <p class="turn1" v-if="iAmPlayer && !game.isEnded && !isMyTurn">相手のターンです<mk-ellipsis/></p> + <p class="turn2" v-if="iAmPlayer && !game.isEnded && isMyTurn" v-animate-css="{ classes: 'tada', iteration: 'infinite' }">あなたのターンです</p> + <p class="result" v-if="game.isEnded && logPos == logs.length"> + <template v-if="game.winner"><b>{{ game.winner.name }}</b>の勝ち{{ game.settings.isLlotheo ? ' (ロセオ)' : '' }}</template> + <template v-else>引き分け</template> + </p> + </div> + + <div class="board" :style="{ 'grid-template-rows': `repeat(${ game.settings.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.settings.map[0].length }, 1fr)` }"> + <div v-for="(stone, i) in o.board" + :class="{ empty: stone == null, none: o.map[i] == 'null', isEnded: game.isEnded, myTurn: !game.isEnded && isMyTurn, can: turnUser ? o.canPut(turnUser.id == blackUser.id, i) : null, prev: o.prevPos == i }" + @click="set(i)" + :title="'[' + (o.transformPosToXy(i)[0] + 1) + ', ' + (o.transformPosToXy(i)[1] + 1) + '] (' + i + ')'" + > + <img v-if="stone === true" :src="`${blackUser.avatarUrl}?thumbnail&size=128`" alt=""> + <img v-if="stone === false" :src="`${whiteUser.avatarUrl}?thumbnail&size=128`" alt=""> + </div> + </div> + + <p class="status"><b>{{ logPos }}ターン目</b> 黒:{{ o.blackCount }} 白:{{ o.whiteCount }} 合計:{{ o.blackCount + o.whiteCount }}</p> + + <div class="player" v-if="game.isEnded"> + <el-button-group> + <el-button type="primary" @click="logPos = 0" :disabled="logPos == 0">%fa:angle-double-left%</el-button> + <el-button type="primary" @click="logPos--" :disabled="logPos == 0">%fa:angle-left%</el-button> + </el-button-group> + <span>{{ logPos }} / {{ logs.length }}</span> + <el-button-group> + <el-button type="primary" @click="logPos++" :disabled="logPos == logs.length">%fa:angle-right%</el-button> + <el-button type="primary" @click="logPos = logs.length" :disabled="logPos == logs.length">%fa:angle-double-right%</el-button> + </el-button-group> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as CRC32 from 'crc-32'; +import Othello, { Color } from '../../../../../common/othello/core'; +import { url } from '../../../config'; + +export default Vue.extend({ + props: ['initGame', 'connection'], + + data() { + return { + game: null, + o: null as Othello, + logs: [], + logPos: 0, + pollingClock: null + }; + }, + + computed: { + iAmPlayer(): boolean { + if (!(this as any).os.isSignedIn) return false; + return this.game.user1Id == (this as any).os.i.id || this.game.user2Id == (this as any).os.i.id; + }, + myColor(): Color { + if (!this.iAmPlayer) return null; + if (this.game.user1Id == (this as any).os.i.id && this.game.black == 1) return true; + if (this.game.user2Id == (this as any).os.i.id && this.game.black == 2) return true; + return false; + }, + opColor(): Color { + if (!this.iAmPlayer) return null; + return this.myColor === true ? false : true; + }, + blackUser(): any { + return this.game.black == 1 ? this.game.user1 : this.game.user2; + }, + whiteUser(): any { + return this.game.black == 1 ? this.game.user2 : this.game.user1; + }, + turnUser(): any { + if (this.o.turn === true) { + return this.game.black == 1 ? this.game.user1 : this.game.user2; + } else if (this.o.turn === false) { + return this.game.black == 1 ? this.game.user2 : this.game.user1; + } else { + return null; + } + }, + isMyTurn(): boolean { + if (this.turnUser == null) return null; + return this.turnUser.id == (this as any).os.i.id; + } + }, + + watch: { + logPos(v) { + if (!this.game.isEnded) return; + this.o = new Othello(this.game.settings.map, { + isLlotheo: this.game.settings.isLlotheo, + canPutEverywhere: this.game.settings.canPutEverywhere, + loopedBoard: this.game.settings.loopedBoard + }); + this.logs.forEach((log, i) => { + if (i < v) { + this.o.put(log.color, log.pos); + } + }); + this.$forceUpdate(); + } + }, + + created() { + this.game = this.initGame; + + this.o = new Othello(this.game.settings.map, { + isLlotheo: this.game.settings.isLlotheo, + canPutEverywhere: this.game.settings.canPutEverywhere, + loopedBoard: this.game.settings.loopedBoard + }); + + this.game.logs.forEach(log => { + this.o.put(log.color, log.pos); + }); + + this.logs = this.game.logs; + this.logPos = this.logs.length; + + // 通信を取りこぼしてもいいように定期的にポーリングさせる + if (this.game.isStarted && !this.game.isEnded) { + this.pollingClock = setInterval(() => { + const crc32 = CRC32.str(this.logs.map(x => x.pos.toString()).join('')); + this.connection.send({ + type: 'check', + crc32 + }); + }, 3000); + } + }, + + mounted() { + this.connection.on('set', this.onSet); + this.connection.on('rescue', this.onRescue); + }, + + beforeDestroy() { + this.connection.off('set', this.onSet); + this.connection.off('rescue', this.onRescue); + + clearInterval(this.pollingClock); + }, + + methods: { + set(pos) { + if (this.game.isEnded) return; + if (!this.iAmPlayer) return; + if (!this.isMyTurn) return; + if (!this.o.canPut(this.myColor, pos)) return; + + this.o.put(this.myColor, pos); + + // サウンドを再生する + if ((this as any).os.isEnableSounds) { + const sound = new Audio(`${url}/assets/othello-put-me.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + + this.connection.send({ + type: 'set', + pos + }); + + this.checkEnd(); + + this.$forceUpdate(); + }, + + onSet(x) { + this.logs.push(x); + this.logPos++; + this.o.put(x.color, x.pos); + this.checkEnd(); + this.$forceUpdate(); + + // サウンドを再生する + if ((this as any).os.isEnableSounds && x.color != this.myColor) { + const sound = new Audio(`${url}/assets/othello-put-you.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + }, + + checkEnd() { + this.game.isEnded = this.o.isEnded; + if (this.game.isEnded) { + if (this.o.winner === true) { + this.game.winnerId = this.game.black == 1 ? this.game.user1Id : this.game.user2Id; + this.game.winner = this.game.black == 1 ? this.game.user1 : this.game.user2; + } else if (this.o.winner === false) { + this.game.winnerId = this.game.black == 1 ? this.game.user2Id : this.game.user1Id; + this.game.winner = this.game.black == 1 ? this.game.user2 : this.game.user1; + } else { + this.game.winnerId = null; + this.game.winner = null; + } + } + }, + + // 正しいゲーム情報が送られてきたとき + onRescue(game) { + this.game = game; + + this.o = new Othello(this.game.settings.map, { + isLlotheo: this.game.settings.isLlotheo, + canPutEverywhere: this.game.settings.canPutEverywhere, + loopedBoard: this.game.settings.loopedBoard + }); + + this.game.logs.forEach(log => { + this.o.put(log.color, log.pos, true); + }); + + this.logs = this.game.logs; + this.logPos = this.logs.length; + + this.checkEnd(); + this.$forceUpdate(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.root + text-align center + + > header + padding 8px + border-bottom dashed 1px #c4cdd4 + + > .board + display grid + grid-gap 4px + width 350px + height 350px + margin 0 auto + + > div + background transparent + border-radius 6px + overflow hidden + + * + pointer-events none + user-select none + + &.empty + border solid 2px #eee + + &.empty.can + background #eee + + &.empty.myTurn + border-color #ddd + + &.can + background #eee + cursor pointer + + &:hover + border-color darken($theme-color, 10%) + background $theme-color + + &:active + background darken($theme-color, 10%) + + &.prev + box-shadow 0 0 0 4px rgba($theme-color, 0.7) + + &.isEnded + border-color #ddd + + &.none + border-color transparent !important + + > img + display block + width 100% + height 100% + + > .graph + display grid + grid-template-columns repeat(61, 1fr) + width 300px + height 38px + margin 0 auto 16px auto + + > div + &:not(:empty) + background #ccc + + > div:first-child + background #333 + + > div:last-child + background #ccc + + > .status + margin 0 + padding 16px 0 + + > .player + padding-bottom 32px + + > span + display inline-block + margin 0 8px + min-width 70px +</style> diff --git a/src/client/app/common/views/components/othello.gameroom.vue b/src/client/app/common/views/components/othello.gameroom.vue new file mode 100644 index 0000000000..dba9ccd16d --- /dev/null +++ b/src/client/app/common/views/components/othello.gameroom.vue @@ -0,0 +1,42 @@ +<template> +<div> + <x-room v-if="!g.isStarted" :game="g" :connection="connection"/> + <x-game v-else :init-game="g" :connection="connection"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XGame from './othello.game.vue'; +import XRoom from './othello.room.vue'; +import { OthelloGameStream } from '../../scripts/streaming/othello-game'; + +export default Vue.extend({ + components: { + XGame, + XRoom + }, + props: ['game'], + data() { + return { + connection: null, + g: null + }; + }, + created() { + this.g = this.game; + this.connection = new OthelloGameStream((this as any).os, (this as any).os.i, this.game); + this.connection.on('started', this.onStarted); + }, + beforeDestroy() { + this.connection.off('started', this.onStarted); + this.connection.close(); + }, + methods: { + onStarted(game) { + Object.assign(this.g, game); + this.$forceUpdate(); + } + } +}); +</script> diff --git a/src/client/app/common/views/components/othello.room.vue b/src/client/app/common/views/components/othello.room.vue new file mode 100644 index 0000000000..a32be6b74f --- /dev/null +++ b/src/client/app/common/views/components/othello.room.vue @@ -0,0 +1,297 @@ +<template> +<div class="root"> + <header><b>{{ game.user1.name }}</b> vs <b>{{ game.user2.name }}</b></header> + + <div> + <p>ゲームの設定</p> + + <el-card class="map"> + <div slot="header"> + <el-select :class="$style.mapSelect" v-model="mapName" placeholder="マップを選択" @change="onMapChange"> + <el-option label="ランダム" :value="null"/> + <el-option-group v-for="c in mapCategories" :key="c" :label="c"> + <el-option v-for="m in maps" v-if="m.category == c" :key="m.name" :label="m.name" :value="m.name"> + <span style="float: left">{{ m.name }}</span> + <span style="float: right; color: #8492a6; font-size: 13px" v-if="m.author">(by <i>{{ m.author }}</i>)</span> + </el-option> + </el-option-group> + </el-select> + </div> + <div :class="$style.board" v-if="game.settings.map != null" :style="{ 'grid-template-rows': `repeat(${ game.settings.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.settings.map[0].length }, 1fr)` }"> + <div v-for="(x, i) in game.settings.map.join('')" + :data-none="x == ' '" + @click="onPixelClick(i, x)" + > + <template v-if="x == 'b'">%fa:circle%</template> + <template v-if="x == 'w'">%fa:circle R%</template> + </div> + </div> + </el-card> + + <el-card class="bw"> + <div slot="header"> + <span>先手/後手</span> + </div> + <el-radio v-model="game.settings.bw" label="random" @change="updateSettings">ランダム</el-radio> + <el-radio v-model="game.settings.bw" :label="1" @change="updateSettings">{{ game.user1.name }}が黒</el-radio> + <el-radio v-model="game.settings.bw" :label="2" @change="updateSettings">{{ game.user2.name }}が黒</el-radio> + </el-card> + + <el-card class="rules"> + <div slot="header"> + <span>ルール</span> + </div> + <mk-switch v-model="game.settings.isLlotheo" @change="updateSettings" text="石の少ない方が勝ち(ロセオ)"/> + <mk-switch v-model="game.settings.loopedBoard" @change="updateSettings" text="ループマップ"/> + <mk-switch v-model="game.settings.canPutEverywhere" @change="updateSettings" text="どこでも置けるモード"/> + </el-card> + + <el-card class="bot-form" v-if="form"> + <div slot="header"> + <span>Botの設定</span> + </div> + <el-alert v-for="message in messages" + :title="message.text" + :type="message.type" + :key="message.id" + /> + <template v-for="item in form"> + <mk-switch v-if="item.type == 'button'" v-model="item.value" :key="item.id" :text="item.label" @change="onChangeForm($event, item)">{{ item.desc || '' }}</mk-switch> + + <el-card v-if="item.type == 'radio'" :key="item.id"> + <div slot="header"> + <span>{{ item.label }}</span> + </div> + <el-radio v-for="(r, i) in item.items" :key="item.id + ':' + i" v-model="item.value" :label="r.value" @change="onChangeForm($event, item)">{{ r.label }}</el-radio> + </el-card> + + <el-card v-if="item.type == 'textbox'" :key="item.id"> + <div slot="header"> + <span>{{ item.label }}</span> + </div> + <el-input v-model="item.value" @change="onChangeForm($event, item)"/> + </el-card> + </template> + </el-card> + </div> + + <footer> + <p class="status"> + <template v-if="isAccepted && isOpAccepted">ゲームは数秒後に開始されます<mk-ellipsis/></template> + <template v-if="isAccepted && !isOpAccepted">相手の準備が完了するのを待っています<mk-ellipsis/></template> + <template v-if="!isAccepted && isOpAccepted">あなたの準備が完了するのを待っています</template> + <template v-if="!isAccepted && !isOpAccepted">準備中<mk-ellipsis/></template> + </p> + + <div class="actions"> + <el-button @click="exit">キャンセル</el-button> + <el-button type="primary" @click="accept" v-if="!isAccepted">準備完了</el-button> + <el-button type="primary" @click="cancel" v-if="isAccepted">準備続行</el-button> + </div> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as maps from '../../../../../common/othello/maps'; + +export default Vue.extend({ + props: ['game', 'connection'], + + data() { + return { + o: null, + isLlotheo: false, + mapName: maps.eighteight.name, + maps: maps, + form: null, + messages: [] + }; + }, + + computed: { + mapCategories(): string[] { + const categories = Object.entries(maps).map(x => x[1].category); + return categories.filter((item, pos) => categories.indexOf(item) == pos); + }, + isAccepted(): boolean { + if (this.game.user1Id == (this as any).os.i.id && this.game.user1Accepted) return true; + if (this.game.user2Id == (this as any).os.i.id && this.game.user2Accepted) return true; + return false; + }, + isOpAccepted(): boolean { + if (this.game.user1Id != (this as any).os.i.id && this.game.user1Accepted) return true; + if (this.game.user2Id != (this as any).os.i.id && this.game.user2Accepted) return true; + return false; + } + }, + + created() { + this.connection.on('change-accepts', this.onChangeAccepts); + this.connection.on('update-settings', this.onUpdateSettings); + this.connection.on('init-form', this.onInitForm); + this.connection.on('message', this.onMessage); + + if (this.game.user1Id != (this as any).os.i.id && this.game.settings.form1) this.form = this.game.settings.form1; + if (this.game.user2Id != (this as any).os.i.id && this.game.settings.form2) this.form = this.game.settings.form2; + }, + + beforeDestroy() { + this.connection.off('change-accepts', this.onChangeAccepts); + this.connection.off('update-settings', this.onUpdateSettings); + this.connection.off('init-form', this.onInitForm); + this.connection.off('message', this.onMessage); + }, + + methods: { + exit() { + + }, + + accept() { + this.connection.send({ + type: 'accept' + }); + }, + + cancel() { + this.connection.send({ + type: 'cancel-accept' + }); + }, + + onChangeAccepts(accepts) { + this.game.user1Accepted = accepts.user1; + this.game.user2Accepted = accepts.user2; + this.$forceUpdate(); + }, + + updateSettings() { + this.connection.send({ + type: 'update-settings', + settings: this.game.settings + }); + }, + + onUpdateSettings(settings) { + this.game.settings = settings; + if (this.game.settings.map == null) { + this.mapName = null; + } else { + const foundMap = Object.entries(maps).find(x => x[1].data.join('') == this.game.settings.map.join('')); + this.mapName = foundMap ? foundMap[1].name : '-Custom-'; + } + }, + + onInitForm(x) { + if (x.userId == (this as any).os.i.id) return; + this.form = x.form; + }, + + onMessage(x) { + if (x.userId == (this as any).os.i.id) return; + this.messages.unshift(x.message); + }, + + onChangeForm(v, item) { + this.connection.send({ + type: 'update-form', + id: item.id, + value: v + }); + }, + + onMapChange(v) { + if (v == null) { + this.game.settings.map = null; + } else { + this.game.settings.map = Object.entries(maps).find(x => x[1].name == v)[1].data; + } + this.$forceUpdate(); + this.updateSettings(); + }, + + onPixelClick(pos, pixel) { + const x = pos % this.game.settings.map[0].length; + const y = Math.floor(pos / this.game.settings.map[0].length); + const newPixel = + pixel == ' ' ? '-' : + pixel == '-' ? 'b' : + pixel == 'b' ? 'w' : + ' '; + const line = this.game.settings.map[y].split(''); + line[x] = newPixel; + this.$set(this.game.settings.map, y, line.join('')); + this.$forceUpdate(); + this.updateSettings(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.root + text-align center + background #f9f9f9 + + > header + padding 8px + border-bottom dashed 1px #c4cdd4 + + > div + padding 0 16px + + > .map + > .bw + > .rules + > .bot-form + max-width 400px + margin 0 auto 16px auto + + > footer + position sticky + bottom 0 + padding 16px + background rgba(255, 255, 255, 0.9) + border-top solid 1px #c4cdd4 + + > .status + margin 0 0 16px 0 +</style> + +<style lang="stylus" module> +.mapSelect + width 100% + +.board + display grid + grid-gap 4px + width 300px + height 300px + margin 0 auto + + > div + background transparent + border solid 2px #ddd + border-radius 6px + overflow hidden + cursor pointer + + * + pointer-events none + user-select none + width 100% + height 100% + + &[data-none] + border-color transparent + +</style> + +<style lang="stylus"> +.el-alert__content + position initial !important +</style> diff --git a/src/client/app/common/views/components/othello.vue b/src/client/app/common/views/components/othello.vue new file mode 100644 index 0000000000..8f7d9dfd6a --- /dev/null +++ b/src/client/app/common/views/components/othello.vue @@ -0,0 +1,311 @@ +<template> +<div class="mk-othello"> + <div v-if="game"> + <x-gameroom :game="game"/> + </div> + <div class="matching" v-else-if="matching"> + <h1><b>{{ matching.name }}</b>を待っています<mk-ellipsis/></h1> + <div class="cancel"> + <el-button round @click="cancel">キャンセル</el-button> + </div> + </div> + <div class="index" v-else> + <h1>Misskey %fa:circle%thell%fa:circle R%</h1> + <p>他のMisskeyユーザーとオセロで対戦しよう</p> + <div class="play"> + <el-button round>フリーマッチ(準備中)</el-button> + <el-button type="primary" round @click="match">指名</el-button> + <details> + <summary>遊び方</summary> + <div> + <p>オセロは、相手と交互に石をボードに置いてゆき、相手の石を挟んでひっくり返しながら、最終的に残った石が多い方が勝ちというボードゲームです。</p> + <dl> + <dt><b>フリーマッチ</b></dt> + <dd>ランダムなユーザーと対戦するモードです。</dd> + <dt><b>指名</b></dt> + <dd>指定したユーザーと対戦するモードです。</dd> + </dl> + </div> + </details> + </div> + <section v-if="invitations.length > 0"> + <h2>対局の招待があります!:</h2> + <div class="invitation" v-for="i in invitations" tabindex="-1" @click="accept(i)"> + <img :src="`${i.parent.avatarUrl}?thumbnail&size=32`" alt=""> + <span class="name"><b>{{ i.parent.name }}</b></span> + <span class="username">@{{ i.parent.username }}</span> + <mk-time :time="i.createdAt"/> + </div> + </section> + <section v-if="myGames.length > 0"> + <h2>自分の対局</h2> + <a class="game" v-for="g in myGames" tabindex="-1" @click.prevent="go(g)" :href="`/othello/${g.id}`"> + <img :src="`${g.user1.avatarUrl}?thumbnail&size=32`" alt=""> + <img :src="`${g.user2.avatarUrl}?thumbnail&size=32`" alt=""> + <span><b>{{ g.user1.name }}</b> vs <b>{{ g.user2.name }}</b></span> + <span class="state">{{ g.isEnded ? '終了' : '進行中' }}</span> + </a> + </section> + <section v-if="games.length > 0"> + <h2>みんなの対局</h2> + <a class="game" v-for="g in games" tabindex="-1" @click.prevent="go(g)" :href="`/othello/${g.id}`"> + <img :src="`${g.user1.avatarUrl}?thumbnail&size=32`" alt=""> + <img :src="`${g.user2.avatarUrl}?thumbnail&size=32`" alt=""> + <span><b>{{ g.user1.name }}</b> vs <b>{{ g.user2.name }}</b></span> + <span class="state">{{ g.isEnded ? '終了' : '進行中' }}</span> + </a> + </section> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XGameroom from './othello.gameroom.vue'; + +export default Vue.extend({ + components: { + XGameroom + }, + props: ['initGame'], + data() { + return { + game: null, + games: [], + gamesFetching: true, + gamesMoreFetching: false, + myGames: [], + matching: null, + invitations: [], + connection: null, + connectionId: null, + pingClock: null + }; + }, + watch: { + game(g) { + this.$emit('gamed', g); + } + }, + created() { + if (this.initGame) { + this.game = this.initGame; + } + }, + mounted() { + this.connection = (this as any).os.streams.othelloStream.getConnection(); + this.connectionId = (this as any).os.streams.othelloStream.use(); + + this.connection.on('matched', this.onMatched); + this.connection.on('invited', this.onInvited); + + (this as any).api('othello/games', { + my: true + }).then(games => { + this.myGames = games; + }); + + (this as any).api('othello/games').then(games => { + this.games = games; + this.gamesFetching = false; + }); + + (this as any).api('othello/invitations').then(invitations => { + this.invitations = this.invitations.concat(invitations); + }); + + this.pingClock = setInterval(() => { + if (this.matching) { + this.connection.send({ + type: 'ping', + id: this.matching.id + }); + } + }, 3000); + }, + beforeDestroy() { + this.connection.off('matched', this.onMatched); + this.connection.off('invited', this.onInvited); + (this as any).os.streams.othelloStream.dispose(this.connectionId); + + clearInterval(this.pingClock); + }, + methods: { + go(game) { + (this as any).api('othello/games/show', { + gameId: game.id + }).then(game => { + this.matching = null; + this.game = game; + }); + }, + match() { + (this as any).apis.input({ + title: 'ユーザー名を入力してください' + }).then(username => { + (this as any).api('users/show', { + username + }).then(user => { + (this as any).api('othello/match', { + userId: user.id + }).then(res => { + if (res == null) { + this.matching = user; + } else { + this.game = res; + } + }); + }); + }); + }, + cancel() { + this.matching = null; + (this as any).api('othello/match/cancel'); + }, + accept(invitation) { + (this as any).api('othello/match', { + userId: invitation.parent.id + }).then(game => { + if (game) { + this.matching = null; + this.game = game; + } + }); + }, + onMatched(game) { + this.matching = null; + this.game = game; + }, + onInvited(invite) { + this.invitations.unshift(invite); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-othello + color #677f84 + background #fff + + > .matching + > h1 + margin 0 + padding 24px + font-size 20px + text-align center + font-weight normal + + > .cancel + margin 0 auto + padding 24px 0 0 0 + max-width 200px + text-align center + border-top dashed 1px #c4cdd4 + + > .index + > h1 + margin 0 + padding 24px + font-size 24px + text-align center + font-weight normal + color #fff + background linear-gradient(to bottom, #8bca3e, #d6cf31) + + & + p + margin 0 + padding 12px + margin-bottom 12px + text-align center + font-size 14px + border-bottom solid 1px #d3d9dc + + > .play + margin 0 auto + padding 0 16px + max-width 500px + text-align center + + > details + margin 8px 0 + + > div + padding 16px + font-size 14px + text-align left + background #f5f5f5 + border-radius 8px + + > section + margin 0 auto + padding 0 16px 16px 16px + max-width 500px + border-top solid 1px #d3d9dc + + > h2 + margin 0 + padding 16px 0 8px 0 + font-size 16px + font-weight bold + + .invitation + margin 8px 0 + padding 8px + border solid 1px #e1e5e8 + border-radius 6px + cursor pointer + + * + pointer-events none + user-select none + + &:focus + border-color $theme-color + + &:hover + background #f5f5f5 + + &:active + background #eee + + > img + vertical-align bottom + border-radius 100% + + > span + margin 0 8px + line-height 32px + + .game + display block + margin 8px 0 + padding 8px + color #677f84 + border solid 1px #e1e5e8 + border-radius 6px + cursor pointer + + * + pointer-events none + user-select none + + &:focus + border-color $theme-color + + &:hover + background #f5f5f5 + + &:active + background #eee + + > img + vertical-align bottom + border-radius 100% + + > span + margin 0 8px + line-height 32px +</style> diff --git a/src/client/app/common/views/components/poll-editor.vue b/src/client/app/common/views/components/poll-editor.vue new file mode 100644 index 0000000000..47d901d7b1 --- /dev/null +++ b/src/client/app/common/views/components/poll-editor.vue @@ -0,0 +1,142 @@ +<template> +<div class="mk-poll-editor"> + <p class="caution" v-if="choices.length < 2"> + %fa:exclamation-triangle%%i18n:common.tags.mk-poll-editor.no-only-one-choice% + </p> + <ul ref="choices"> + <li v-for="(choice, i) in choices"> + <input :value="choice" @input="onInput(i, $event)" :placeholder="'%i18n:common.tags.mk-poll-editor.choice-n%'.replace('{}', i + 1)"> + <button @click="remove(i)" title="%i18n:common.tags.mk-poll-editor.remove%"> + %fa:times% + </button> + </li> + </ul> + <button class="add" v-if="choices.length < 10" @click="add">%i18n:common.tags.mk-poll-editor.add%</button> + <button class="destroy" @click="destroy" title="%i18n:common.tags.mk-poll-editor.destroy%"> + %fa:times% + </button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + choices: ['', ''] + }; + }, + watch: { + choices() { + this.$emit('updated'); + } + }, + methods: { + onInput(i, e) { + Vue.set(this.choices, i, e.target.value); + }, + + add() { + this.choices.push(''); + this.$nextTick(() => { + (this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus(); + }); + }, + + remove(i) { + this.choices = this.choices.filter((_, _i) => _i != i); + }, + + destroy() { + this.$emit('destroyed'); + }, + + get() { + return { + choices: this.choices.filter(choice => choice != '') + } + }, + + set(data) { + if (data.choices.length == 0) return; + this.choices = data.choices; + if (data.choices.length == 1) this.choices = this.choices.concat(''); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-poll-editor + padding 8px + + > .caution + margin 0 0 8px 0 + font-size 0.8em + color #f00 + + > [data-fa] + margin-right 4px + + > ul + display block + margin 0 + padding 0 + list-style none + + > li + display block + margin 8px 0 + padding 0 + width 100% + + &:first-child + margin-top 0 + + &:last-child + margin-bottom 0 + + > input + padding 6px 8px + width 300px + font-size 14px + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + + &:hover + border-color rgba($theme-color, 0.2) + + &:focus + border-color rgba($theme-color, 0.5) + + > button + padding 4px 8px + color rgba($theme-color, 0.4) + + &:hover + color rgba($theme-color, 0.6) + + &:active + color darken($theme-color, 30%) + + > .add + margin 8px 0 0 0 + vertical-align top + color $theme-color + + > .destroy + position absolute + top 0 + right 0 + padding 4px 8px + color rgba($theme-color, 0.4) + + &:hover + color rgba($theme-color, 0.6) + + &:active + color darken($theme-color, 30%) + +</style> diff --git a/src/client/app/common/views/components/poll.vue b/src/client/app/common/views/components/poll.vue new file mode 100644 index 0000000000..711d89720e --- /dev/null +++ b/src/client/app/common/views/components/poll.vue @@ -0,0 +1,124 @@ +<template> +<div class="mk-poll" :data-is-voted="isVoted"> + <ul> + <li v-for="choice in poll.choices" :key="choice.id" @click="vote(choice.id)" :class="{ voted: choice.voted }" :title="!isVoted ? '%i18n:common.tags.mk-poll.vote-to%'.replace('{}', choice.text) : ''"> + <div class="backdrop" :style="{ 'width': (showResult ? (choice.votes / total * 100) : 0) + '%' }"></div> + <span> + <template v-if="choice.isVoted">%fa:check%</template> + <span>{{ choice.text }}</span> + <span class="votes" v-if="showResult">({{ '%i18n:common.tags.mk-poll.vote-count%'.replace('{}', choice.votes) }})</span> + </span> + </li> + </ul> + <p v-if="total > 0"> + <span>{{ '%i18n:common.tags.mk-poll.total-users%'.replace('{}', total) }}</span> + <span>・</span> + <a v-if="!isVoted" @click="toggleShowResult">{{ showResult ? '%i18n:common.tags.mk-poll.vote%' : '%i18n:common.tags.mk-poll.show-result%' }}</a> + <span v-if="isVoted">%i18n:common.tags.mk-poll.voted%</span> + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['post'], + data() { + return { + showResult: false + }; + }, + computed: { + poll(): any { + return this.post.poll; + }, + total(): number { + return this.poll.choices.reduce((a, b) => a + b.votes, 0); + }, + isVoted(): boolean { + return this.poll.choices.some(c => c.isVoted); + } + }, + created() { + this.showResult = this.isVoted; + }, + methods: { + toggleShowResult() { + this.showResult = !this.showResult; + }, + vote(id) { + if (this.poll.choices.some(c => c.isVoted)) return; + (this as any).api('posts/polls/vote', { + postId: this.post.id, + choice: id + }).then(() => { + this.poll.choices.forEach(c => { + if (c.id == id) { + c.votes++; + Vue.set(c, 'isVoted', true); + } + }); + this.showResult = true; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-poll + + > ul + display block + margin 0 + padding 0 + list-style none + + > li + display block + margin 4px 0 + padding 4px 8px + width 100% + border solid 1px #eee + border-radius 4px + overflow hidden + cursor pointer + + &:hover + background rgba(0, 0, 0, 0.05) + + &:active + background rgba(0, 0, 0, 0.1) + + > .backdrop + position absolute + top 0 + left 0 + height 100% + background $theme-color + transition width 1s ease + + > span + > [data-fa] + margin-right 4px + + > .votes + margin-left 4px + + > p + a + color inherit + + &[data-is-voted] + > ul > li + cursor default + + &:hover + background transparent + + &:active + background transparent + +</style> diff --git a/src/client/app/common/views/components/post-html.ts b/src/client/app/common/views/components/post-html.ts new file mode 100644 index 0000000000..98da86617d --- /dev/null +++ b/src/client/app/common/views/components/post-html.ts @@ -0,0 +1,137 @@ +import Vue from 'vue'; +import * as emojilib from 'emojilib'; +import getAcct from '../../../../../common/user/get-acct'; +import { url } from '../../../config'; +import MkUrl from './url.vue'; + +const flatten = list => list.reduce( + (a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), [] +); + +export default Vue.component('mk-post-html', { + props: { + ast: { + type: Array, + required: true + }, + shouldBreak: { + type: Boolean, + default: true + }, + i: { + type: Object, + default: null + } + }, + render(createElement) { + const els = flatten((this as any).ast.map(token => { + switch (token.type) { + case 'text': + const text = token.content.replace(/(\r\n|\n|\r)/g, '\n'); + + if ((this as any).shouldBreak) { + const x = text.split('\n') + .map(t => t == '' ? [createElement('br')] : [createElement('span', t), createElement('br')]); + x[x.length - 1].pop(); + return x; + } else { + return createElement('span', text.replace(/\n/g, ' ')); + } + + case 'bold': + return createElement('strong', token.bold); + + case 'url': + return createElement(MkUrl, { + props: { + url: token.content, + target: '_blank' + } + }); + + case 'link': + return createElement('a', { + attrs: { + class: 'link', + href: token.url, + target: '_blank', + title: token.url + } + }, token.title); + + case 'mention': + return (createElement as any)('a', { + attrs: { + href: `${url}/@${getAcct(token)}`, + target: '_blank', + dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token) + }, + directives: [{ + name: 'user-preview', + value: token.content + }] + }, token.content); + + case 'hashtag': + return createElement('a', { + attrs: { + href: `${url}/search?q=${token.content}`, + target: '_blank' + } + }, token.content); + + case 'code': + return createElement('pre', [ + createElement('code', { + domProps: { + innerHTML: token.html + } + }) + ]); + + case 'inline-code': + return createElement('code', token.html); + + case 'quote': + const text2 = token.quote.replace(/(\r\n|\n|\r)/g, '\n'); + + if ((this as any).shouldBreak) { + const x = text2.split('\n') + .map(t => [createElement('span', t), createElement('br')]); + x[x.length - 1].pop(); + return createElement('div', { + attrs: { + class: 'quote' + } + }, x); + } else { + return createElement('span', { + attrs: { + class: 'quote' + } + }, text2.replace(/\n/g, ' ')); + } + + case 'emoji': + const emoji = emojilib.lib[token.emoji]; + return createElement('span', emoji ? emoji.char : token.content); + + default: + console.log('unknown ast type:', token.type); + } + })); + + const _els = []; + els.forEach((el, i) => { + if (el.tag == 'br') { + if (els[i - 1].tag != 'div') { + _els.push(el); + } + } else { + _els.push(el); + } + }); + + return createElement('span', _els); + } +}); diff --git a/src/client/app/common/views/components/post-menu.vue b/src/client/app/common/views/components/post-menu.vue new file mode 100644 index 0000000000..35116db7e2 --- /dev/null +++ b/src/client/app/common/views/components/post-menu.vue @@ -0,0 +1,141 @@ +<template> +<div class="mk-post-menu"> + <div class="backdrop" ref="backdrop" @click="close"></div> + <div class="popover" :class="{ compact }" ref="popover"> + <button v-if="post.userId == os.i.id" @click="pin">%i18n:common.tags.mk-post-menu.pin%</button> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['post', 'source', 'compact'], + mounted() { + this.$nextTick(() => { + const popover = this.$refs.popover as any; + + const rect = this.source.getBoundingClientRect(); + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + if (this.compact) { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); + popover.style.left = (x - (width / 2)) + 'px'; + popover.style.top = (y - (height / 2)) + 'px'; + } else { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + this.source.offsetHeight; + popover.style.left = (x - (width / 2)) + 'px'; + popover.style.top = y + 'px'; + } + + anime({ + targets: this.$refs.backdrop, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.$refs.popover, + opacity: 1, + scale: [0.5, 1], + duration: 500 + }); + }); + }, + methods: { + pin() { + (this as any).api('i/pin', { + postId: this.post.id + }).then(() => { + this.$destroy(); + }); + }, + + close() { + (this.$refs.backdrop as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.backdrop, + opacity: 0, + duration: 200, + easing: 'linear' + }); + + (this.$refs.popover as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.popover, + opacity: 0, + scale: 0.5, + duration: 200, + easing: 'easeInBack', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +$border-color = rgba(27, 31, 35, 0.15) + +.mk-post-menu + position initial + + > .backdrop + position fixed + top 0 + left 0 + z-index 10000 + width 100% + height 100% + background rgba(0, 0, 0, 0.1) + opacity 0 + + > .popover + position absolute + z-index 10001 + background #fff + border 1px solid $border-color + border-radius 4px + box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) + transform scale(0.5) + opacity 0 + + $balloon-size = 16px + + &:not(.compact) + margin-top $balloon-size + transform-origin center -($balloon-size) + + &:before + content "" + display block + position absolute + top -($balloon-size * 2) + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size $border-color + + &:after + content "" + display block + position absolute + top -($balloon-size * 2) + 1.5px + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size #fff + + > button + display block + padding 16px + +</style> diff --git a/src/client/app/common/views/components/reaction-icon.vue b/src/client/app/common/views/components/reaction-icon.vue new file mode 100644 index 0000000000..7d24f4f9e9 --- /dev/null +++ b/src/client/app/common/views/components/reaction-icon.vue @@ -0,0 +1,28 @@ +<template> +<span class="mk-reaction-icon"> + <img v-if="reaction == 'like'" src="/assets/reactions/like.png" alt="%i18n:common.reactions.like%"> + <img v-if="reaction == 'love'" src="/assets/reactions/love.png" alt="%i18n:common.reactions.love%"> + <img v-if="reaction == 'laugh'" src="/assets/reactions/laugh.png" alt="%i18n:common.reactions.laugh%"> + <img v-if="reaction == 'hmm'" src="/assets/reactions/hmm.png" alt="%i18n:common.reactions.hmm%"> + <img v-if="reaction == 'surprise'" src="/assets/reactions/surprise.png" alt="%i18n:common.reactions.surprise%"> + <img v-if="reaction == 'congrats'" src="/assets/reactions/congrats.png" alt="%i18n:common.reactions.congrats%"> + <img v-if="reaction == 'angry'" src="/assets/reactions/angry.png" alt="%i18n:common.reactions.angry%"> + <img v-if="reaction == 'confused'" src="/assets/reactions/confused.png" alt="%i18n:common.reactions.confused%"> + <img v-if="reaction == 'pudding'" src="/assets/reactions/pudding.png" alt="%i18n:common.reactions.pudding%"> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['reaction'] +}); +</script> + +<style lang="stylus" scoped> +.mk-reaction-icon + img + vertical-align middle + width 1em + height 1em +</style> diff --git a/src/client/app/common/views/components/reaction-picker.vue b/src/client/app/common/views/components/reaction-picker.vue new file mode 100644 index 0000000000..bcb6b2b965 --- /dev/null +++ b/src/client/app/common/views/components/reaction-picker.vue @@ -0,0 +1,191 @@ +<template> +<div class="mk-reaction-picker"> + <div class="backdrop" ref="backdrop" @click="close"></div> + <div class="popover" :class="{ compact }" ref="popover"> + <p v-if="!compact">{{ title }}</p> + <div> + <button @click="react('like')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="1" title="%i18n:common.reactions.like%"><mk-reaction-icon reaction='like'/></button> + <button @click="react('love')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="2" title="%i18n:common.reactions.love%"><mk-reaction-icon reaction='love'/></button> + <button @click="react('laugh')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="3" title="%i18n:common.reactions.laugh%"><mk-reaction-icon reaction='laugh'/></button> + <button @click="react('hmm')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" title="%i18n:common.reactions.hmm%"><mk-reaction-icon reaction='hmm'/></button> + <button @click="react('surprise')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" title="%i18n:common.reactions.surprise%"><mk-reaction-icon reaction='surprise'/></button> + <button @click="react('congrats')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" title="%i18n:common.reactions.congrats%"><mk-reaction-icon reaction='congrats'/></button> + <button @click="react('angry')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" title="%i18n:common.reactions.angry%"><mk-reaction-icon reaction='angry'/></button> + <button @click="react('confused')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" title="%i18n:common.reactions.confused%"><mk-reaction-icon reaction='confused'/></button> + <button @click="react('pudding')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" title="%i18n:common.reactions.pudding%"><mk-reaction-icon reaction='pudding'/></button> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +const placeholder = '%i18n:common.tags.mk-reaction-picker.choose-reaction%'; + +export default Vue.extend({ + props: ['post', 'source', 'compact', 'cb'], + data() { + return { + title: placeholder + }; + }, + mounted() { + this.$nextTick(() => { + const popover = this.$refs.popover as any; + + const rect = this.source.getBoundingClientRect(); + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + if (this.compact) { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); + popover.style.left = (x - (width / 2)) + 'px'; + popover.style.top = (y - (height / 2)) + 'px'; + } else { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + this.source.offsetHeight; + popover.style.left = (x - (width / 2)) + 'px'; + popover.style.top = y + 'px'; + } + + anime({ + targets: this.$refs.backdrop, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.$refs.popover, + opacity: 1, + scale: [0.5, 1], + duration: 500 + }); + }); + }, + methods: { + react(reaction) { + (this as any).api('posts/reactions/create', { + postId: this.post.id, + reaction: reaction + }).then(() => { + if (this.cb) this.cb(); + this.$destroy(); + }); + }, + onMouseover(e) { + this.title = e.target.title; + }, + onMouseout(e) { + this.title = placeholder; + }, + close() { + (this.$refs.backdrop as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.backdrop, + opacity: 0, + duration: 200, + easing: 'linear' + }); + + (this.$refs.popover as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.popover, + opacity: 0, + scale: 0.5, + duration: 200, + easing: 'easeInBack', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +$border-color = rgba(27, 31, 35, 0.15) + +.mk-reaction-picker + position initial + + > .backdrop + position fixed + top 0 + left 0 + z-index 10000 + width 100% + height 100% + background rgba(0, 0, 0, 0.1) + opacity 0 + + > .popover + position absolute + z-index 10001 + background #fff + border 1px solid $border-color + border-radius 4px + box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) + transform scale(0.5) + opacity 0 + + $balloon-size = 16px + + &:not(.compact) + margin-top $balloon-size + transform-origin center -($balloon-size) + + &:before + content "" + display block + position absolute + top -($balloon-size * 2) + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size $border-color + + &:after + content "" + display block + position absolute + top -($balloon-size * 2) + 1.5px + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size #fff + + > p + display block + margin 0 + padding 8px 10px + font-size 14px + color #586069 + border-bottom solid 1px #e1e4e8 + + > div + padding 4px + width 240px + text-align center + + > button + padding 0 + width 40px + height 40px + font-size 24px + border-radius 2px + + &:hover + background #eee + + &:active + background $theme-color + box-shadow inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15) + +</style> diff --git a/src/client/app/common/views/components/reactions-viewer.vue b/src/client/app/common/views/components/reactions-viewer.vue new file mode 100644 index 0000000000..246451008f --- /dev/null +++ b/src/client/app/common/views/components/reactions-viewer.vue @@ -0,0 +1,49 @@ +<template> +<div class="mk-reactions-viewer"> + <template v-if="reactions"> + <span v-if="reactions.like"><mk-reaction-icon reaction='like'/><span>{{ reactions.like }}</span></span> + <span v-if="reactions.love"><mk-reaction-icon reaction='love'/><span>{{ reactions.love }}</span></span> + <span v-if="reactions.laugh"><mk-reaction-icon reaction='laugh'/><span>{{ reactions.laugh }}</span></span> + <span v-if="reactions.hmm"><mk-reaction-icon reaction='hmm'/><span>{{ reactions.hmm }}</span></span> + <span v-if="reactions.surprise"><mk-reaction-icon reaction='surprise'/><span>{{ reactions.surprise }}</span></span> + <span v-if="reactions.congrats"><mk-reaction-icon reaction='congrats'/><span>{{ reactions.congrats }}</span></span> + <span v-if="reactions.angry"><mk-reaction-icon reaction='angry'/><span>{{ reactions.angry }}</span></span> + <span v-if="reactions.confused"><mk-reaction-icon reaction='confused'/><span>{{ reactions.confused }}</span></span> + <span v-if="reactions.pudding"><mk-reaction-icon reaction='pudding'/><span>{{ reactions.pudding }}</span></span> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['post'], + computed: { + reactions(): number { + return this.post.reactionCounts; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-reactions-viewer + border-top dashed 1px #eee + border-bottom dashed 1px #eee + margin 4px 0 + + &:empty + display none + + > span + margin-right 8px + + > .mk-reaction-icon + font-size 1.4em + + > span + margin-left 4px + font-size 1.2em + color #444 + +</style> diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue new file mode 100644 index 0000000000..17154e6b31 --- /dev/null +++ b/src/client/app/common/views/components/signin.vue @@ -0,0 +1,142 @@ +<template> +<form class="mk-signin" :class="{ signing }" @submit.prevent="onSubmit"> + <label class="user-name"> + <input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" placeholder="%i18n:common.tags.mk-signin.username%" autofocus required @change="onUsernameChange"/>%fa:at% + </label> + <label class="password"> + <input v-model="password" type="password" placeholder="%i18n:common.tags.mk-signin.password%" required/>%fa:lock% + </label> + <label class="token" v-if="user && user.account.twoFactorEnabled"> + <input v-model="token" type="number" placeholder="%i18n:common.tags.mk-signin.token%" required/>%fa:lock% + </label> + <button type="submit" :disabled="signing">{{ signing ? '%i18n:common.tags.mk-signin.signing-in%' : '%i18n:common.tags.mk-signin.signin%' }}</button> + もしくは <a :href="`${apiUrl}/signin/twitter`">Twitterでログイン</a> +</form> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { apiUrl } from '../../../config'; + +export default Vue.extend({ + data() { + return { + signing: false, + user: null, + username: '', + password: '', + token: '', + apiUrl, + }; + }, + methods: { + onUsernameChange() { + (this as any).api('users/show', { + username: this.username + }).then(user => { + this.user = user; + }); + }, + onSubmit() { + this.signing = true; + + (this as any).api('signin', { + username: this.username, + password: this.password, + token: this.user && this.user.account.twoFactorEnabled ? this.token : undefined + }).then(() => { + location.reload(); + }).catch(() => { + alert('something happened'); + this.signing = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-signin + &.signing + &, * + cursor wait !important + + label + display block + margin 12px 0 + + [data-fa] + display block + pointer-events none + position absolute + bottom 0 + top 0 + left 0 + z-index 1 + margin auto + padding 0 16px + height 1em + color #898786 + + input[type=text] + input[type=password] + input[type=number] + user-select text + display inline-block + cursor auto + padding 0 0 0 38px + margin 0 + width 100% + line-height 44px + font-size 1em + color rgba(0, 0, 0, 0.7) + background #fff + outline none + border solid 1px #eee + border-radius 4px + + &:hover + background rgba(255, 255, 255, 0.7) + border-color #ddd + + & + i + color #797776 + + &:focus + background #fff + border-color #ccc + + & + i + color #797776 + + [type=submit] + cursor pointer + padding 16px + margin -6px 0 0 0 + width 100% + font-size 1.2em + color rgba(0, 0, 0, 0.5) + outline none + border none + border-radius 0 + background transparent + transition all .5s ease + + &:hover + color $theme-color + transition all .2s ease + + &:focus + color $theme-color + transition all .2s ease + + &:active + color darken($theme-color, 30%) + transition all .2s ease + + &:disabled + opacity 0.7 + +</style> diff --git a/src/client/app/common/views/components/signup.vue b/src/client/app/common/views/components/signup.vue new file mode 100644 index 0000000000..e77d849ade --- /dev/null +++ b/src/client/app/common/views/components/signup.vue @@ -0,0 +1,287 @@ +<template> +<form class="mk-signup" @submit.prevent="onSubmit" autocomplete="off"> + <label class="username"> + <p class="caption">%fa:at%%i18n:common.tags.mk-signup.username%</p> + <input v-model="username" type="text" pattern="^[a-zA-Z0-9_]{3,20}$" placeholder="a~z、A~Z、0~9、-" autocomplete="off" required @input="onChangeUsername"/> + <p class="profile-page-url-preview" v-if="shouldShowProfileUrl">{{ `${url}/@${username}` }}</p> + <p class="info" v-if="usernameState == 'wait'" style="color:#999">%fa:spinner .pulse .fw%%i18n:common.tags.mk-signup.checking%</p> + <p class="info" v-if="usernameState == 'ok'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.available%</p> + <p class="info" v-if="usernameState == 'unavailable'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.unavailable%</p> + <p class="info" v-if="usernameState == 'error'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.error%</p> + <p class="info" v-if="usernameState == 'invalid-format'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.invalid-format%</p> + <p class="info" v-if="usernameState == 'min-range'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.too-short%</p> + <p class="info" v-if="usernameState == 'max-range'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.too-long%</p> + </label> + <label class="password"> + <p class="caption">%fa:lock%%i18n:common.tags.mk-signup.password%</p> + <input v-model="password" type="password" placeholder="%i18n:common.tags.mk-signup.password-placeholder%" autocomplete="off" required @input="onChangePassword"/> + <div class="meter" v-show="passwordStrength != ''" :data-strength="passwordStrength"> + <div class="value" ref="passwordMetar"></div> + </div> + <p class="info" v-if="passwordStrength == 'low'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.weak-password%</p> + <p class="info" v-if="passwordStrength == 'medium'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.normal-password%</p> + <p class="info" v-if="passwordStrength == 'high'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.strong-password%</p> + </label> + <label class="retype-password"> + <p class="caption">%fa:lock%%i18n:common.tags.mk-signup.password%(%i18n:common.tags.mk-signup.retype%)</p> + <input v-model="retypedPassword" type="password" placeholder="%i18n:common.tags.mk-signup.retype-placeholder%" autocomplete="off" required @input="onChangePasswordRetype"/> + <p class="info" v-if="passwordRetypeState == 'match'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.password-matched%</p> + <p class="info" v-if="passwordRetypeState == 'not-match'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.password-not-matched%</p> + </label> + <label class="recaptcha"> + <p class="caption"><template v-if="recaptchaed">%fa:toggle-on%</template><template v-if="!recaptchaed">%fa:toggle-off%</template>%i18n:common.tags.mk-signup.recaptcha%</p> + <div class="g-recaptcha" data-callback="onRecaptchaed" data-expired-callback="onRecaptchaExpired" :data-sitekey="recaptchaSitekey"></div> + </label> + <label class="agree-tou"> + <input name="agree-tou" type="checkbox" autocomplete="off" required/> + <p><a :href="touUrl" target="_blank">利用規約</a>に同意する</p> + </label> + <button type="submit">%i18n:common.tags.mk-signup.create%</button> +</form> +</template> + +<script lang="ts"> +import Vue from 'vue'; +const getPasswordStrength = require('syuilo-password-strength'); +import { url, docsUrl, lang, recaptchaSitekey } from '../../../config'; + +export default Vue.extend({ + data() { + return { + username: '', + password: '', + retypedPassword: '', + url, + touUrl: `${docsUrl}/${lang}/tou`, + recaptchaSitekey, + recaptchaed: false, + usernameState: null, + passwordStrength: '', + passwordRetypeState: null + } + }, + computed: { + shouldShowProfileUrl(): boolean { + return (this.username != '' && + this.usernameState != 'invalid-format' && + this.usernameState != 'min-range' && + this.usernameState != 'max-range'); + } + }, + methods: { + onChangeUsername() { + if (this.username == '') { + this.usernameState = null; + return; + } + + const err = + !this.username.match(/^[a-zA-Z0-9\-]+$/) ? 'invalid-format' : + this.username.length < 3 ? 'min-range' : + this.username.length > 20 ? 'max-range' : + null; + + if (err) { + this.usernameState = err; + return; + } + + this.usernameState = 'wait'; + + (this as any).api('username/available', { + username: this.username + }).then(result => { + this.usernameState = result.available ? 'ok' : 'unavailable'; + }).catch(err => { + this.usernameState = 'error'; + }); + }, + onChangePassword() { + if (this.password == '') { + this.passwordStrength = ''; + return; + } + + const strength = getPasswordStrength(this.password); + this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; + (this.$refs.passwordMetar as any).style.width = `${strength * 100}%`; + }, + onChangePasswordRetype() { + if (this.retypedPassword == '') { + this.passwordRetypeState = null; + return; + } + + this.passwordRetypeState = this.password == this.retypedPassword ? 'match' : 'not-match'; + }, + onSubmit() { + (this as any).api('signup', { + username: this.username, + password: this.password, + 'g-recaptcha-response': (window as any).grecaptcha.getResponse() + }).then(() => { + (this as any).api('signin', { + username: this.username, + password: this.password + }).then(() => { + location.href = '/'; + }); + }).catch(() => { + alert('%i18n:common.tags.mk-signup.some-error%'); + + (window as any).grecaptcha.reset(); + this.recaptchaed = false; + }); + } + }, + created() { + (window as any).onRecaptchaed = () => { + this.recaptchaed = true; + }; + + (window as any).onRecaptchaExpired = () => { + this.recaptchaed = false; + }; + }, + mounted() { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://www.google.com/recaptcha/api.js'); + head.appendChild(script); + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-signup + min-width 302px + + label + display block + margin 0 0 16px 0 + + > .caption + margin 0 0 4px 0 + color #828888 + font-size 0.95em + + > [data-fa] + margin-right 0.25em + color #96adac + + > .info + display block + margin 4px 0 + font-size 0.8em + + > [data-fa] + margin-right 0.3em + + &.username + .profile-page-url-preview + display block + margin 4px 8px 0 4px + font-size 0.8em + color #888 + + &:empty + display none + + &:not(:empty) + .info + margin-top 0 + + &.password + .meter + display block + margin-top 8px + width 100% + height 8px + + &[data-strength=''] + display none + + &[data-strength='low'] + > .value + background #d73612 + + &[data-strength='medium'] + > .value + background #d7ca12 + + &[data-strength='high'] + > .value + background #61bb22 + + > .value + display block + width 0% + height 100% + background transparent + border-radius 4px + transition all 0.1s ease + + [type=text], [type=password] + user-select text + display inline-block + cursor auto + padding 0 12px + margin 0 + width 100% + line-height 44px + font-size 1em + color #333 !important + background #fff !important + outline none + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + box-shadow 0 0 0 114514px #fff inset + transition all .3s ease + + &:hover + border-color rgba(0, 0, 0, 0.2) + transition all .1s ease + + &:focus + color $theme-color !important + border-color $theme-color + box-shadow 0 0 0 1024px #fff inset, 0 0 0 4px rgba($theme-color, 10%) + transition all 0s ease + + &:disabled + opacity 0.5 + + .agree-tou + padding 4px + border-radius 4px + + &:hover + background #f4f4f4 + + &:active + background #eee + + &, * + cursor pointer + + p + display inline + color #555 + + button + margin 0 + padding 16px + width 100% + font-size 1em + color #fff + background $theme-color + border-radius 3px + + &:hover + background lighten($theme-color, 5%) + + &:active + background darken($theme-color, 5%) + +</style> diff --git a/src/client/app/common/views/components/special-message.vue b/src/client/app/common/views/components/special-message.vue new file mode 100644 index 0000000000..2fd4d6515e --- /dev/null +++ b/src/client/app/common/views/components/special-message.vue @@ -0,0 +1,42 @@ +<template> +<div class="mk-special-message"> + <p v-if="m == 1 && d == 1">%i18n:common.tags.mk-special-message.new-year%</p> + <p v-if="m == 12 && d == 25">%i18n:common.tags.mk-special-message.christmas%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + now: new Date() + }; + }, + computed: { + d(): number { + return this.now.getDate(); + }, + m(): number { + return this.now.getMonth() + 1; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-special-message + &:empty + display none + + > p + margin 0 + padding 4px + text-align center + font-size 14px + font-weight bold + text-transform uppercase + color #fff + background #ff1036 + +</style> diff --git a/src/client/app/common/views/components/stream-indicator.vue b/src/client/app/common/views/components/stream-indicator.vue new file mode 100644 index 0000000000..1f18fa76ed --- /dev/null +++ b/src/client/app/common/views/components/stream-indicator.vue @@ -0,0 +1,86 @@ +<template> +<div class="mk-stream-indicator"> + <p v-if=" stream.state == 'initializing' "> + %fa:spinner .pulse% + <span>%i18n:common.tags.mk-stream-indicator.connecting%<mk-ellipsis/></span> + </p> + <p v-if=" stream.state == 'reconnecting' "> + %fa:spinner .pulse% + <span>%i18n:common.tags.mk-stream-indicator.reconnecting%<mk-ellipsis/></span> + </p> + <p v-if=" stream.state == 'connected' "> + %fa:check% + <span>%i18n:common.tags.mk-stream-indicator.connected%</span> + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + computed: { + stream() { + return (this as any).os.stream; + } + }, + created() { + (this as any).os.stream.on('_connected_', this.onConnected); + (this as any).os.stream.on('_disconnected_', this.onDisconnected); + + this.$nextTick(() => { + if (this.stream.state == 'connected') { + this.$el.style.opacity = '0'; + } + }); + }, + beforeDestroy() { + (this as any).os.stream.off('_connected_', this.onConnected); + (this as any).os.stream.off('_disconnected_', this.onDisconnected); + }, + methods: { + onConnected() { + setTimeout(() => { + anime({ + targets: this.$el, + opacity: 0, + easing: 'linear', + duration: 200 + }); + }, 1000); + }, + onDisconnected() { + anime({ + targets: this.$el, + opacity: 1, + easing: 'linear', + duration: 100 + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-stream-indicator + pointer-events none + position fixed + z-index 16384 + bottom 8px + right 8px + margin 0 + padding 6px 12px + font-size 0.9em + color #fff + background rgba(0, 0, 0, 0.8) + border-radius 4px + + > p + display block + margin 0 + + > [data-fa] + margin-right 0.25em + +</style> diff --git a/src/client/app/common/views/components/switch.vue b/src/client/app/common/views/components/switch.vue new file mode 100644 index 0000000000..19a4adc3de --- /dev/null +++ b/src/client/app/common/views/components/switch.vue @@ -0,0 +1,190 @@ +<template> +<div + class="mk-switch" + :class="{ disabled, checked }" + role="switch" + :aria-checked="checked" + :aria-disabled="disabled" + @click="switchValue" + @mouseover="mouseenter" +> + <input + type="checkbox" + @change="handleChange" + ref="input" + :disabled="disabled" + @keydown.enter="switchValue" + > + <span class="button"> + <span :style="{ transform }"></span> + </span> + <span class="label"> + <span :aria-hidden="!checked">{{ text }}</span> + <p :aria-hidden="!checked"> + <slot></slot> + </p> + </span> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + value: { + type: Boolean, + default: false + }, + disabled: { + type: Boolean, + default: false + }, + text: String + },/* + created() { + if (!~[true, false].indexOf(this.value)) { + this.$emit('input', false); + } + },*/ + computed: { + checked(): boolean { + return this.value; + }, + transform(): string { + return this.checked ? 'translate3d(20px, 0, 0)' : ''; + } + }, + watch: { + value() { + (this.$el).style.transition = 'all 0.3s'; + (this.$refs.input as any).checked = this.checked; + } + }, + mounted() { + (this.$refs.input as any).checked = this.checked; + }, + methods: { + mouseenter() { + (this.$el).style.transition = 'all 0s'; + }, + handleChange() { + (this.$el).style.transition = 'all 0.3s'; + this.$emit('input', !this.checked); + this.$emit('change', !this.checked); + this.$nextTick(() => { + // set input's checked property + // in case parent refuses to change component's value + (this.$refs.input as any).checked = this.checked; + }); + }, + switchValue() { + !this.disabled && this.handleChange(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-switch + display flex + margin 12px 0 + cursor pointer + transition all 0.3s + + > * + user-select none + + &.disabled + opacity 0.6 + cursor not-allowed + + &.checked + > .button + background-color $theme-color + border-color $theme-color + + > .label + > span + color $theme-color + + &:hover + > .label + > span + color darken($theme-color, 10%) + + > .button + background darken($theme-color, 10%) + border-color darken($theme-color, 10%) + + &:hover + > .label + > span + color #2e3338 + + > .button + background #ced2da + border-color #ced2da + + > input + position absolute + width 0 + height 0 + opacity 0 + margin 0 + + &:focus + .button + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 14px + + > .button + display inline-block + margin 0 + width 40px + min-width 40px + height 20px + min-height 20px + background #dcdfe6 + border 1px solid #dcdfe6 + outline none + border-radius 10px + transition inherit + + > * + position absolute + top 1px + left 1px + border-radius 100% + transition transform 0.3s + width 16px + height 16px + background-color #fff + + > .label + margin-left 8px + display block + font-size 15px + cursor pointer + transition inherit + + > span + display block + line-height 20px + color #4a535a + transition inherit + + > p + margin 0 + //font-size 90% + color #9daab3 + +</style> diff --git a/src/client/app/common/views/components/time.vue b/src/client/app/common/views/components/time.vue new file mode 100644 index 0000000000..6e0d2b0dcb --- /dev/null +++ b/src/client/app/common/views/components/time.vue @@ -0,0 +1,76 @@ +<template> +<time class="mk-time"> + <span v-if=" mode == 'relative' ">{{ relative }}</span> + <span v-if=" mode == 'absolute' ">{{ absolute }}</span> + <span v-if=" mode == 'detail' ">{{ absolute }} ({{ relative }})</span> +</time> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + time: { + type: [Date, String], + required: true + }, + mode: { + type: String, + default: 'relative' + } + }, + data() { + return { + tickId: null, + now: new Date() + }; + }, + computed: { + _time(): Date { + return typeof this.time == 'string' ? new Date(this.time) : this.time; + }, + absolute(): string { + const time = this._time; + return ( + time.getFullYear() + '年' + + (time.getMonth() + 1) + '月' + + time.getDate() + '日' + + ' ' + + time.getHours() + '時' + + time.getMinutes() + '分'); + }, + relative(): string { + const time = this._time; + const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/; + return ( + ago >= 31536000 ? '%i18n:common.time.years_ago%' .replace('{}', (~~(ago / 31536000)).toString()) : + ago >= 2592000 ? '%i18n:common.time.months_ago%' .replace('{}', (~~(ago / 2592000)).toString()) : + ago >= 604800 ? '%i18n:common.time.weeks_ago%' .replace('{}', (~~(ago / 604800)).toString()) : + ago >= 86400 ? '%i18n:common.time.days_ago%' .replace('{}', (~~(ago / 86400)).toString()) : + ago >= 3600 ? '%i18n:common.time.hours_ago%' .replace('{}', (~~(ago / 3600)).toString()) : + ago >= 60 ? '%i18n:common.time.minutes_ago%'.replace('{}', (~~(ago / 60)).toString()) : + ago >= 10 ? '%i18n:common.time.seconds_ago%'.replace('{}', (~~(ago % 60)).toString()) : + ago >= 0 ? '%i18n:common.time.just_now%' : + ago < 0 ? '%i18n:common.time.future%' : + '%i18n:common.time.unknown%'); + } + }, + created() { + if (this.mode == 'relative' || this.mode == 'detail') { + this.tick(); + this.tickId = setInterval(this.tick, 1000); + } + }, + destroyed() { + if (this.mode === 'relative' || this.mode === 'detail') { + clearInterval(this.tickId); + } + }, + methods: { + tick() { + this.now = new Date(); + } + } +}); +</script> diff --git a/src/client/app/common/views/components/timer.vue b/src/client/app/common/views/components/timer.vue new file mode 100644 index 0000000000..a3c4f01b77 --- /dev/null +++ b/src/client/app/common/views/components/timer.vue @@ -0,0 +1,49 @@ +<template> +<time class="mk-time"> + {{ hh }}:{{ mm }}:{{ ss }} +</time> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + time: { + type: [Date, String], + required: true + } + }, + data() { + return { + tickId: null, + hh: null, + mm: null, + ss: null + }; + }, + computed: { + _time(): Date { + return typeof this.time == 'string' ? new Date(this.time) : this.time; + } + }, + created() { + this.tick(); + this.tickId = setInterval(this.tick, 1000); + }, + destroyed() { + clearInterval(this.tickId); + }, + methods: { + tick() { + const now = new Date().getTime(); + const start = this._time.getTime(); + const ago = Math.floor((now - start) / 1000); + + this.hh = Math.floor(ago / (60 * 60)).toString().padStart(2, '0'); + this.mm = Math.floor(ago / 60).toString().padStart(2, '0'); + this.ss = (ago % 60).toString().padStart(2, '0'); + } + } +}); +</script> diff --git a/src/client/app/common/views/components/twitter-setting.vue b/src/client/app/common/views/components/twitter-setting.vue new file mode 100644 index 0000000000..082d2b435d --- /dev/null +++ b/src/client/app/common/views/components/twitter-setting.vue @@ -0,0 +1,66 @@ +<template> +<div class="mk-twitter-setting"> + <p>%i18n:common.tags.mk-twitter-setting.description%<a :href="`${docsUrl}/link-to-twitter`" target="_blank">%i18n:common.tags.mk-twitter-setting.detail%</a></p> + <p class="account" v-if="os.i.account.twitter" :title="`Twitter ID: ${os.i.account.twitter.userId}`">%i18n:common.tags.mk-twitter-setting.connected-to%: <a :href="`https://twitter.com/${os.i.account.twitter.screenName}`" target="_blank">@{{ os.i.account.twitter.screenName }}</a></p> + <p> + <a :href="`${apiUrl}/connect/twitter`" target="_blank" @click.prevent="connect">{{ os.i.account.twitter ? '%i18n:common.tags.mk-twitter-setting.reconnect%' : '%i18n:common.tags.mk-twitter-setting.connect%' }}</a> + <span v-if="os.i.account.twitter"> or </span> + <a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="os.i.account.twitter" @click.prevent="disconnect">%i18n:common.tags.mk-twitter-setting.disconnect%</a> + </p> + <p class="id" v-if="os.i.account.twitter">Twitter ID: {{ os.i.account.twitter.userId }}</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { apiUrl, docsUrl } from '../../../config'; + +export default Vue.extend({ + data() { + return { + form: null, + apiUrl, + docsUrl + }; + }, + mounted() { + this.$watch('os.i', () => { + if ((this as any).os.i.account.twitter) { + if (this.form) this.form.close(); + } + }, { + deep: true + }); + }, + methods: { + connect() { + this.form = window.open(apiUrl + '/connect/twitter', + 'twitter_connect_window', + 'height=570, width=520'); + }, + + disconnect() { + window.open(apiUrl + '/disconnect/twitter', + 'twitter_disconnect_window', + 'height=570, width=520'); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-twitter-setting + color #4a535a + + .account + border solid 1px #e1e8ed + border-radius 4px + padding 16px + + a + font-weight bold + color inherit + + .id + color #8899a6 +</style> diff --git a/src/client/app/common/views/components/uploader.vue b/src/client/app/common/views/components/uploader.vue new file mode 100644 index 0000000000..c74a1edb41 --- /dev/null +++ b/src/client/app/common/views/components/uploader.vue @@ -0,0 +1,212 @@ +<template> +<div class="mk-uploader"> + <ol v-if="uploads.length > 0"> + <li v-for="ctx in uploads" :key="ctx.id"> + <div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div> + <p class="name">%fa:spinner .pulse%{{ ctx.name }}</p> + <p class="status"> + <span class="initing" v-if="ctx.progress == undefined">%i18n:common.tags.mk-uploader.waiting%<mk-ellipsis/></span> + <span class="kb" v-if="ctx.progress != undefined">{{ String(Math.floor(ctx.progress.value / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progress.max / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> + <span class="percentage" v-if="ctx.progress != undefined">{{ Math.floor((ctx.progress.value / ctx.progress.max) * 100) }}</span> + </p> + <progress v-if="ctx.progress != undefined && ctx.progress.value != ctx.progress.max" :value="ctx.progress.value" :max="ctx.progress.max"></progress> + <div class="progress initing" v-if="ctx.progress == undefined"></div> + <div class="progress waiting" v-if="ctx.progress != undefined && ctx.progress.value == ctx.progress.max"></div> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { apiUrl } from '../../../config'; + +export default Vue.extend({ + data() { + return { + uploads: [] + }; + }, + methods: { + upload(file, folder) { + if (folder && typeof folder == 'object') folder = folder.id; + + const id = Math.random(); + + const ctx = { + id: id, + name: file.name || 'untitled', + progress: undefined, + img: undefined + }; + + this.uploads.push(ctx); + this.$emit('change', this.uploads); + + const reader = new FileReader(); + reader.onload = (e: any) => { + ctx.img = e.target.result; + }; + reader.readAsDataURL(file); + + const data = new FormData(); + data.append('i', (this as any).os.i.account.token); + data.append('file', file); + + if (folder) data.append('folderId', folder); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = (e: any) => { + const driveFile = JSON.parse(e.target.response); + + this.$emit('uploaded', driveFile); + + this.uploads = this.uploads.filter(x => x.id != id); + this.$emit('change', this.uploads); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) { + if (ctx.progress == undefined) ctx.progress = {}; + ctx.progress.max = e.total; + ctx.progress.value = e.loaded; + } + }; + + xhr.send(data); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-uploader + overflow auto + + &:empty + display none + + > ol + display block + margin 0 + padding 0 + list-style none + + > li + display block + margin 8px 0 0 0 + padding 0 + height 36px + box-shadow 0 -1px 0 rgba($theme-color, 0.1) + border-top solid 8px transparent + + &:first-child + margin 0 + box-shadow none + border-top none + + > .img + display block + position absolute + top 0 + left 0 + width 36px + height 36px + background-size cover + background-position center center + + > .name + display block + position absolute + top 0 + left 44px + margin 0 + padding 0 + max-width 256px + font-size 0.8em + color rgba($theme-color, 0.7) + white-space nowrap + text-overflow ellipsis + overflow hidden + + > [data-fa] + margin-right 4px + + > .status + display block + position absolute + top 0 + right 0 + margin 0 + padding 0 + font-size 0.8em + + > .initing + color rgba($theme-color, 0.5) + + > .kb + color rgba($theme-color, 0.5) + + > .percentage + display inline-block + width 48px + text-align right + + color rgba($theme-color, 0.7) + + &:after + content '%' + + > progress + display block + position absolute + bottom 0 + right 0 + margin 0 + width calc(100% - 44px) + height 8px + background transparent + border none + border-radius 4px + overflow hidden + + &::-webkit-progress-value + background $theme-color + + &::-webkit-progress-bar + background rgba($theme-color, 0.1) + + > .progress + display block + position absolute + bottom 0 + right 0 + margin 0 + width calc(100% - 44px) + height 8px + border none + border-radius 4px + background linear-gradient( + 45deg, + lighten($theme-color, 30%) 25%, + $theme-color 25%, + $theme-color 50%, + lighten($theme-color, 30%) 50%, + lighten($theme-color, 30%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation bg 1.5s linear infinite + + &.initing + opacity 0.3 + + @keyframes bg + from {background-position: 0 0;} + to {background-position: -64px 32px;} + +</style> diff --git a/src/client/app/common/views/components/url-preview.vue b/src/client/app/common/views/components/url-preview.vue new file mode 100644 index 0000000000..e91e510550 --- /dev/null +++ b/src/client/app/common/views/components/url-preview.vue @@ -0,0 +1,142 @@ +<template> +<iframe v-if="youtubeId" type="text/html" height="250" + :src="`https://www.youtube.com/embed/${youtubeId}?origin=${misskeyUrl}`" + frameborder="0"/> +<div v-else> + <a class="mk-url-preview" :href="url" target="_blank" :title="url" v-if="!fetching"> + <div class="thumbnail" v-if="thumbnail" :style="`background-image: url(${thumbnail})`"></div> + <article> + <header> + <h1>{{ title }}</h1> + </header> + <p>{{ description }}</p> + <footer> + <img class="icon" v-if="icon" :src="icon"/> + <p>{{ sitename }}</p> + </footer> + </article> + </a> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url as misskeyUrl } from '../../../config'; + +export default Vue.extend({ + props: ['url'], + data() { + return { + fetching: true, + title: null, + description: null, + thumbnail: null, + icon: null, + sitename: null, + youtubeId: null, + misskeyUrl + }; + }, + created() { + const url = new URL(this.url); + + if (url.hostname == 'www.youtube.com') { + this.youtubeId = url.searchParams.get('v'); + } else if (url.hostname == 'youtu.be') { + this.youtubeId = url.pathname; + } else { + fetch('/api:url?url=' + this.url).then(res => { + res.json().then(info => { + this.title = info.title; + this.description = info.description; + this.thumbnail = info.thumbnail; + this.icon = info.icon; + this.sitename = info.sitename; + + this.fetching = false; + }); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +iframe + width 100% + +.mk-url-preview + display block + font-size 16px + border solid 1px #eee + border-radius 4px + overflow hidden + + &:hover + text-decoration none + border-color #ddd + + > article > header > h1 + text-decoration underline + + > .thumbnail + position absolute + width 100px + height 100% + background-position center + background-size cover + + & + article + left 100px + width calc(100% - 100px) + + > article + padding 16px + + > header + margin-bottom 8px + + > h1 + margin 0 + font-size 1em + color #555 + + > p + margin 0 + color #777 + font-size 0.8em + + > footer + margin-top 8px + height 16px + + > img + display inline-block + width 16px + height 16px + margin-right 4px + vertical-align top + + > p + display inline-block + margin 0 + color #666 + font-size 0.8em + line-height 16px + vertical-align top + + @media (max-width 500px) + font-size 8px + border none + + > .thumbnail + width 70px + + & + article + left 70px + width calc(100% - 70px) + + > article + padding 8px + +</style> diff --git a/src/client/app/common/views/components/url.vue b/src/client/app/common/views/components/url.vue new file mode 100644 index 0000000000..14d4fc82f3 --- /dev/null +++ b/src/client/app/common/views/components/url.vue @@ -0,0 +1,66 @@ +<template> +<a class="mk-url" :href="url" :target="target"> + <span class="schema">{{ schema }}//</span> + <span class="hostname">{{ hostname }}</span> + <span class="port" v-if="port != ''">:{{ port }}</span> + <span class="pathname" v-if="pathname != ''">{{ pathname }}</span> + <span class="query">{{ query }}</span> + <span class="hash">{{ hash }}</span> + %fa:external-link-square-alt% +</a> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['url', 'target'], + data() { + return { + schema: null, + hostname: null, + port: null, + pathname: null, + query: null, + hash: null + }; + }, + created() { + const url = new URL(this.url); + + this.schema = url.protocol; + this.hostname = url.hostname; + this.port = url.port; + this.pathname = url.pathname; + this.query = url.search; + this.hash = url.hash; + } +}); +</script> + +<style lang="stylus" scoped> +.mk-url + word-break break-all + + > [data-fa] + padding-left 2px + font-size .9em + font-weight 400 + font-style normal + + > .schema + opacity 0.5 + + > .hostname + font-weight bold + + > .pathname + opacity 0.8 + + > .query + opacity 0.5 + + > .hash + font-style italic + +</style> diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue new file mode 100644 index 0000000000..8f6199732a --- /dev/null +++ b/src/client/app/common/views/components/welcome-timeline.vue @@ -0,0 +1,118 @@ +<template> +<div class="mk-welcome-timeline"> + <div v-for="post in posts"> + <router-link class="avatar-anchor" :to="`/@${getAcct(post.user)}`" v-user-preview="post.user.id"> + <img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=96`" alt="avatar"/> + </router-link> + <div class="body"> + <header> + <router-link class="name" :to="`/@${getAcct(post.user)}`" v-user-preview="post.user.id">{{ post.user.name }}</router-link> + <span class="username">@{{ getAcct(post.user) }}</span> + <div class="info"> + <router-link class="created-at" :to="`/@${getAcct(post.user)}/${post.id}`"> + <mk-time :time="post.createdAt"/> + </router-link> + </div> + </header> + <div class="text"> + <mk-post-html :ast="post.ast"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + data() { + return { + fetching: true, + posts: [] + }; + }, + mounted() { + this.fetch(); + }, + methods: { + getAcct, + fetch(cb?) { + this.fetching = true; + (this as any).api('posts', { + reply: false, + repost: false, + media: false, + poll: false, + bot: false + }).then(posts => { + this.posts = posts; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-welcome-timeline + background #fff + + > div + padding 16px + overflow-wrap break-word + font-size .9em + color #4C4C4C + border-bottom 1px solid rgba(0, 0, 0, 0.05) + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + position -webkit-sticky + position sticky + top 16px + + > img + display block + width 42px + height 42px + border-radius 6px + + > .body + float right + width calc(100% - 42px) + padding-left 12px + + > header + display flex + align-items center + margin-bottom 4px + white-space nowrap + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + font-weight bold + text-overflow ellipsis + color #627079 + + > .username + margin 0 .5em 0 0 + color #ccc + + > .info + margin-left auto + font-size 0.9em + + > .created-at + color #c0c0c0 + +</style> diff --git a/src/client/app/common/views/directives/autocomplete.ts b/src/client/app/common/views/directives/autocomplete.ts new file mode 100644 index 0000000000..94635d301a --- /dev/null +++ b/src/client/app/common/views/directives/autocomplete.ts @@ -0,0 +1,194 @@ +import * as getCaretCoordinates from 'textarea-caret'; +import MkAutocomplete from '../components/autocomplete.vue'; + +export default { + bind(el, binding, vn) { + const self = el._autoCompleteDirective_ = {} as any; + self.x = new Autocomplete(el, vn.context, binding.value); + self.x.attach(); + }, + + unbind(el, binding, vn) { + const self = el._autoCompleteDirective_; + self.x.detach(); + } +}; + +/** + * オートコンプリートを管理するクラス。 + */ +class Autocomplete { + private suggestion: any; + private textarea: any; + private vm: any; + private model: any; + private currentType: string; + + private get text(): string { + return this.vm[this.model]; + } + + private set text(text: string) { + this.vm[this.model] = text; + } + + /** + * 対象のテキストエリアを与えてインスタンスを初期化します。 + */ + constructor(textarea, vm, model) { + //#region BIND + this.onInput = this.onInput.bind(this); + this.complete = this.complete.bind(this); + this.close = this.close.bind(this); + //#endregion + + this.suggestion = null; + this.textarea = textarea; + this.vm = vm; + this.model = model; + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。 + */ + public attach() { + this.textarea.addEventListener('input', this.onInput); + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。 + */ + public detach() { + this.textarea.removeEventListener('input', this.onInput); + this.close(); + } + + /** + * テキスト入力時 + */ + private onInput() { + const caret = this.textarea.selectionStart; + const text = this.text.substr(0, caret); + + const mentionIndex = text.lastIndexOf('@'); + const emojiIndex = text.lastIndexOf(':'); + + let opened = false; + + if (mentionIndex != -1 && mentionIndex > emojiIndex) { + const username = text.substr(mentionIndex + 1); + if (username != '' && username.match(/^[a-zA-Z0-9_]+$/)) { + this.open('user', username); + opened = true; + } + } + + if (emojiIndex != -1 && emojiIndex > mentionIndex) { + const emoji = text.substr(emojiIndex + 1); + if (emoji != '' && emoji.match(/^[\+\-a-z0-9_]+$/)) { + this.open('emoji', emoji); + opened = true; + } + } + + if (!opened) { + this.close(); + } + } + + /** + * サジェストを提示します。 + */ + private open(type, q) { + if (type != this.currentType) { + this.close(); + } + this.currentType = type; + + //#region サジェストを表示すべき位置を計算 + const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart); + + const rect = this.textarea.getBoundingClientRect(); + + const x = rect.left + caretPosition.left - this.textarea.scrollLeft; + const y = rect.top + caretPosition.top - this.textarea.scrollTop; + //#endregion + + if (this.suggestion) { + this.suggestion.x = x; + this.suggestion.y = y; + this.suggestion.q = q; + } else { + // サジェスト要素作成 + this.suggestion = new MkAutocomplete({ + propsData: { + textarea: this.textarea, + complete: this.complete, + close: this.close, + type: type, + q: q, + x, + y + } + }).$mount(); + + // 要素追加 + document.body.appendChild(this.suggestion.$el); + } + } + + /** + * サジェストを閉じます。 + */ + private close() { + if (this.suggestion == null) return; + + this.suggestion.$destroy(); + this.suggestion = null; + + this.textarea.focus(); + } + + /** + * オートコンプリートする + */ + private complete(type, value) { + this.close(); + + const caret = this.textarea.selectionStart; + + if (type == 'user') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('@')); + const after = source.substr(caret); + + // 挿入 + this.text = trimmedBefore + '@' + value.username + ' ' + after; + + // キャレットを戻す + this.vm.$nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.username.length + 2); + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type == 'emoji') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf(':')); + const after = source.substr(caret); + + // 挿入 + this.text = trimmedBefore + value + after; + + // キャレットを戻す + this.vm.$nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + 1; + this.textarea.setSelectionRange(pos, pos); + }); + } + } +} diff --git a/src/client/app/common/views/directives/index.ts b/src/client/app/common/views/directives/index.ts new file mode 100644 index 0000000000..268f07a950 --- /dev/null +++ b/src/client/app/common/views/directives/index.ts @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +import autocomplete from './autocomplete'; + +Vue.directive('autocomplete', autocomplete); diff --git a/src/client/app/common/views/filters/bytes.ts b/src/client/app/common/views/filters/bytes.ts new file mode 100644 index 0000000000..3afb11e9ae --- /dev/null +++ b/src/client/app/common/views/filters/bytes.ts @@ -0,0 +1,8 @@ +import Vue from 'vue'; + +Vue.filter('bytes', (v, digits = 0) => { + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + if (v == 0) return '0Byte'; + const i = Math.floor(Math.log(v) / Math.log(1024)); + return (v / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i]; +}); diff --git a/src/client/app/common/views/filters/index.ts b/src/client/app/common/views/filters/index.ts new file mode 100644 index 0000000000..3a1d1ac235 --- /dev/null +++ b/src/client/app/common/views/filters/index.ts @@ -0,0 +1,2 @@ +require('./bytes'); +require('./number'); diff --git a/src/client/app/common/views/filters/number.ts b/src/client/app/common/views/filters/number.ts new file mode 100644 index 0000000000..d9f48229dd --- /dev/null +++ b/src/client/app/common/views/filters/number.ts @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +Vue.filter('number', (n) => { + return n.toLocaleString(); +}); diff --git a/src/client/app/common/views/widgets/access-log.vue b/src/client/app/common/views/widgets/access-log.vue new file mode 100644 index 0000000000..f7bb17d833 --- /dev/null +++ b/src/client/app/common/views/widgets/access-log.vue @@ -0,0 +1,90 @@ +<template> +<div class="mkw-access-log"> + <mk-widget-container :show-header="props.design == 0"> + <template slot="header">%fa:server%%i18n:desktop.tags.mk-access-log-home-widget.title%</template> + + <div :class="$style.logs" ref="log"> + <p v-for="req in requests"> + <span :class="$style.ip" :style="`color:${ req.fg }; background:${ req.bg }`">{{ req.ip }}</span> + <b>{{ req.method }}</b> + <span>{{ req.path }}</span> + </p> + </div> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import * as seedrandom from 'seedrandom'; + +export default define({ + name: 'broadcast', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + requests: [], + connection: null, + connectionId: null + }; + }, + mounted() { + this.connection = (this as any).os.streams.requestsStream.getConnection(); + this.connectionId = (this as any).os.streams.requestsStream.use(); + this.connection.on('request', this.onRequest); + }, + beforeDestroy() { + this.connection.off('request', this.onRequest); + (this as any).os.streams.requestsStream.dispose(this.connectionId); + }, + methods: { + onRequest(request) { + const random = seedrandom(request.ip); + const r = Math.floor(random() * 255); + const g = Math.floor(random() * 255); + const b = Math.floor(random() * 255); + const luma = (0.2126 * r) + (0.7152 * g) + (0.0722 * b); // SMPTE C, Rec. 709 weightings + request.bg = `rgb(${r}, ${g}, ${b})`; + request.fg = luma >= 165 ? '#000' : '#fff'; + + this.requests.push(request); + if (this.requests.length > 30) this.requests.shift(); + + (this.$refs.log as any).scrollTop = (this.$refs.log as any).scrollHeight; + }, + func() { + if (this.props.design == 1) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" module> +.logs + max-height 250px + overflow auto + + > p + margin 0 + padding 8px + font-size 0.8em + color #555 + + &:nth-child(odd) + background rgba(0, 0, 0, 0.025) + + > b + margin-right 4px + +.ip + margin-right 4px + padding 0 4px + +</style> diff --git a/src/client/app/common/views/widgets/broadcast.vue b/src/client/app/common/views/widgets/broadcast.vue new file mode 100644 index 0000000000..bf41a5fc67 --- /dev/null +++ b/src/client/app/common/views/widgets/broadcast.vue @@ -0,0 +1,161 @@ +<template> +<div class="mkw-broadcast" + :data-found="broadcasts.length != 0" + :data-melt="props.design == 1" + :data-mobile="isMobile" +> + <div class="icon"> + <svg height="32" version="1.1" viewBox="0 0 32 32" width="32"> + <path class="tower" d="M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z"></path> + <path class="wave a" d="M4.66,1.04c-0.508-0.508-1.332-0.508-1.84,0c-1.86,1.92-2.8,4.44-2.8,6.94c0,2.52,0.94,5.04,2.8,6.96 c0.5,0.52,1.32,0.52,1.82,0s0.5-1.36,0-1.88C3.28,11.66,2.6,9.82,2.6,7.98S3.28,4.3,4.64,2.9C5.157,2.391,5.166,1.56,4.66,1.04z"></path> + <path class="wave b" d="M9.58,12.22c0.5-0.5,0.5-1.34,0-1.84C8.94,9.72,8.62,8.86,8.62,8s0.32-1.72,0.96-2.38c0.5-0.52,0.5-1.34,0-1.84 C9.346,3.534,9.02,3.396,8.68,3.4c-0.32,0-0.66,0.12-0.9,0.38C6.64,4.94,6.08,6.48,6.08,8s0.58,3.06,1.7,4.22 C8.28,12.72,9.1,12.72,9.58,12.22z"></path> + <path class="wave c" d="M22.42,3.78c-0.5,0.5-0.5,1.34,0,1.84c0.641,0.66,0.96,1.52,0.96,2.38s-0.319,1.72-0.96,2.38c-0.5,0.52-0.5,1.34,0,1.84 c0.487,0.497,1.285,0.505,1.781,0.018c0.007-0.006,0.013-0.012,0.02-0.018c1.139-1.16,1.699-2.7,1.699-4.22s-0.561-3.06-1.699-4.22 c-0.494-0.497-1.297-0.5-1.794-0.007C22.424,3.775,22.422,3.778,22.42,3.78z"></path> + <path class="wave d" d="M29.18,1.06c-0.479-0.502-1.273-0.522-1.775-0.044c-0.016,0.015-0.029,0.029-0.045,0.044c-0.5,0.52-0.5,1.36,0,1.88 c1.361,1.4,2.041,3.24,2.041,5.08s-0.68,3.66-2.041,5.08c-0.5,0.52-0.5,1.36,0,1.88c0.509,0.508,1.332,0.508,1.841,0 c1.86-1.92,2.8-4.44,2.8-6.96C31.99,5.424,30.98,2.931,29.18,1.06z"></path> + </svg> + </div> + <p class="fetching" v-if="fetching">%i18n:desktop.tags.mk-broadcast-home-widget.fetching%<mk-ellipsis/></p> + <h1 v-if="!fetching">{{ broadcasts.length == 0 ? '%i18n:desktop.tags.mk-broadcast-home-widget.no-broadcasts%' : broadcasts[i].title }}</h1> + <p v-if="!fetching"> + <span v-if="broadcasts.length != 0" v-html="broadcasts[i].text"></span> + <template v-if="broadcasts.length == 0">%i18n:desktop.tags.mk-broadcast-home-widget.have-a-nice-day%</template> + </p> + <a v-if="broadcasts.length > 1" @click="next">%i18n:desktop.tags.mk-broadcast-home-widget.next% >></a> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import { lang } from '../../../config'; + +export default define({ + name: 'broadcast', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + i: 0, + fetching: true, + broadcasts: [] + }; + }, + mounted() { + (this as any).os.getMeta().then(meta => { + let broadcasts = []; + if (meta.broadcasts) { + meta.broadcasts.forEach(broadcast => { + if (broadcast[lang]) { + broadcasts.push(broadcast[lang]); + } + }); + } + this.broadcasts = broadcasts; + this.fetching = false; + }); + }, + methods: { + next() { + if (this.i == this.broadcasts.length - 1) { + this.i = 0; + } else { + this.i++; + } + }, + func() { + if (this.props.design == 1) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-broadcast + padding 10px + border solid 1px #4078c0 + border-radius 6px + + &[data-melt] + border none + + &[data-found] + padding-left 50px + + > .icon + display block + + &:after + content "" + display block + clear both + + > .icon + display none + float left + margin-left -40px + + > svg + fill currentColor + color #4078c0 + + > .wave + opacity 1 + + &.a + animation wave 20s ease-in-out 2.1s infinite + &.b + animation wave 20s ease-in-out 2s infinite + &.c + animation wave 20s ease-in-out 2s infinite + &.d + animation wave 20s ease-in-out 2.1s infinite + + @keyframes wave + 0% + opacity 1 + 1.5% + opacity 0 + 3.5% + opacity 0 + 5% + opacity 1 + 6.5% + opacity 0 + 8.5% + opacity 0 + 10% + opacity 1 + + > h1 + margin 0 + font-size 0.95em + font-weight normal + color #4078c0 + + > p + display block + z-index 1 + margin 0 + font-size 0.7em + color #555 + + &.fetching + text-align center + + a + color #555 + text-decoration underline + + > a + display block + font-size 0.7em + + &[data-mobile] + > p + color #fff + +</style> diff --git a/src/client/app/common/views/widgets/calendar.vue b/src/client/app/common/views/widgets/calendar.vue new file mode 100644 index 0000000000..03f69a7597 --- /dev/null +++ b/src/client/app/common/views/widgets/calendar.vue @@ -0,0 +1,201 @@ +<template> +<div class="mkw-calendar" + :data-melt="props.design == 1" + :data-special="special" + :data-mobile="isMobile" +> + <div class="calendar" :data-is-holiday="isHoliday"> + <p class="month-and-year"> + <span class="year">{{ year }}年</span> + <span class="month">{{ month }}月</span> + </p> + <p class="day">{{ day }}日</p> + <p class="week-day">{{ weekDay }}曜日</p> + </div> + <div class="info"> + <div> + <p>今日:<b>{{ dayP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${dayP}%` }"></div> + </div> + </div> + <div> + <p>今月:<b>{{ monthP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${monthP}%` }"></div> + </div> + </div> + <div> + <p>今年:<b>{{ yearP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${yearP}%` }"></div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'calendar', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + now: new Date(), + year: null, + month: null, + day: null, + weekDay: null, + yearP: null, + dayP: null, + monthP: null, + isHoliday: null, + special: null, + clock: null + }; + }, + created() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + func() { + if (this.isMobile) return; + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + }, + tick() { + const now = new Date(); + const nd = now.getDate(); + const nm = now.getMonth(); + const ny = now.getFullYear(); + + this.year = ny; + this.month = nm + 1; + this.day = nd; + this.weekDay = ['日', '月', '火', '水', '木', '金', '土'][now.getDay()]; + + const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime(); + const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/; + const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime(); + const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime(); + const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime(); + const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime(); + + this.dayP = dayNumer / dayDenom * 100; + this.monthP = monthNumer / monthDenom * 100; + this.yearP = yearNumer / yearDenom * 100; + + this.isHoliday = now.getDay() == 0 || now.getDay() == 6; + + this.special = + nm == 0 && nd == 1 ? 'on-new-years-day' : + false; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mkw-calendar + padding 16px 0 + color #777 + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-special='on-new-years-day'] + border-color #ef95a0 + + &[data-melt] + background transparent + border none + + &[data-mobile] + border none + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + &:after + content "" + display block + clear both + + > .calendar + float left + width 60% + text-align center + + &[data-is-holiday] + > .day + color #ef95a0 + + > p + margin 0 + line-height 18px + font-size 14px + + > span + margin 0 4px + + > .day + margin 10px 0 + line-height 32px + font-size 28px + + > .info + display block + float left + width 40% + padding 0 16px 0 0 + + > div + margin-bottom 8px + + &:last-child + margin-bottom 4px + + > p + margin 0 0 2px 0 + font-size 12px + line-height 18px + color #888 + + > b + margin-left 2px + + > .meter + width 100% + overflow hidden + background #eee + border-radius 8px + + > .val + height 4px + background $theme-color + + &:nth-child(1) + > .meter > .val + background #f7796c + + &:nth-child(2) + > .meter > .val + background #a1de41 + + &:nth-child(3) + > .meter > .val + background #41ddde + +</style> diff --git a/src/client/app/common/views/widgets/donation.vue b/src/client/app/common/views/widgets/donation.vue new file mode 100644 index 0000000000..e218df06e1 --- /dev/null +++ b/src/client/app/common/views/widgets/donation.vue @@ -0,0 +1,58 @@ +<template> +<div class="mkw-donation" :data-mobile="isMobile"> + <article> + <h1>%fa:heart%%i18n:desktop.tags.mk-donation-home-widget.title%</h1> + <p> + {{ '%i18n:desktop.tags.mk-donation-home-widget.text%'.substr(0, '%i18n:desktop.tags.mk-donation-home-widget.text%'.indexOf('{')) }} + <a href="https://syuilo.com">@syuilo</a> + {{ '%i18n:desktop.tags.mk-donation-home-widget.text%'.substr('%i18n:desktop.tags.mk-donation-home-widget.text%'.indexOf('}') + 1) }} + </p> + </article> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'donation' +}); +</script> + +<style lang="stylus" scoped> +.mkw-donation + background #fff + border solid 1px #ead8bb + border-radius 6px + + > article + padding 20px + + > h1 + margin 0 0 5px 0 + font-size 1em + color #888 + + > [data-fa] + margin-right 0.25em + + > p + display block + z-index 1 + margin 0 + font-size 0.8em + color #999 + + &[data-mobile] + border none + background #ead8bb + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + > article + > h1 + color #7b8871 + + > p + color #777d71 + +</style> diff --git a/src/client/app/common/views/widgets/index.ts b/src/client/app/common/views/widgets/index.ts new file mode 100644 index 0000000000..e41030e85a --- /dev/null +++ b/src/client/app/common/views/widgets/index.ts @@ -0,0 +1,25 @@ +import Vue from 'vue'; + +import wAccessLog from './access-log.vue'; +import wVersion from './version.vue'; +import wRss from './rss.vue'; +import wServer from './server.vue'; +import wBroadcast from './broadcast.vue'; +import wCalendar from './calendar.vue'; +import wPhotoStream from './photo-stream.vue'; +import wSlideshow from './slideshow.vue'; +import wTips from './tips.vue'; +import wDonation from './donation.vue'; +import wNav from './nav.vue'; + +Vue.component('mkw-nav', wNav); +Vue.component('mkw-calendar', wCalendar); +Vue.component('mkw-photo-stream', wPhotoStream); +Vue.component('mkw-slideshow', wSlideshow); +Vue.component('mkw-tips', wTips); +Vue.component('mkw-donation', wDonation); +Vue.component('mkw-broadcast', wBroadcast); +Vue.component('mkw-server', wServer); +Vue.component('mkw-rss', wRss); +Vue.component('mkw-version', wVersion); +Vue.component('mkw-access-log', wAccessLog); diff --git a/src/client/app/common/views/widgets/nav.vue b/src/client/app/common/views/widgets/nav.vue new file mode 100644 index 0000000000..7bd5a7832f --- /dev/null +++ b/src/client/app/common/views/widgets/nav.vue @@ -0,0 +1,31 @@ +<template> +<div class="mkw-nav"> + <mk-widget-container> + <div :class="$style.body"> + <mk-nav/> + </div> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'nav' +}); +</script> + +<style lang="stylus" module> +.body + padding 16px + font-size 12px + color #aaa + background #fff + + a + color #999 + + i + color #ccc + +</style> diff --git a/src/client/app/common/views/widgets/photo-stream.vue b/src/client/app/common/views/widgets/photo-stream.vue new file mode 100644 index 0000000000..baafd40662 --- /dev/null +++ b/src/client/app/common/views/widgets/photo-stream.vue @@ -0,0 +1,104 @@ +<template> +<div class="mkw-photo-stream" :class="$style.root" :data-melt="props.design == 2"> + <mk-widget-container :show-header="props.design == 0" :naked="props.design == 2"> + <template slot="header">%fa:camera%%i18n:desktop.tags.mk-photo-stream-home-widget.title%</template> + + <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <div :class="$style.stream" v-if="!fetching && images.length > 0"> + <div v-for="image in images" :class="$style.img" :style="`background-image: url(${image.url}?thumbnail&size=256)`"></div> + </div> + <p :class="$style.empty" v-if="!fetching && images.length == 0">%i18n:desktop.tags.mk-photo-stream-home-widget.no-photos%</p> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'photo-stream', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + images: [], + fetching: true, + connection: null, + connectionId: null + }; + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('drive_file_created', this.onDriveFileCreated); + + (this as any).api('drive/stream', { + type: 'image/*', + limit: 9 + }).then(images => { + this.images = images; + this.fetching = false; + }); + }, + beforeDestroy() { + this.connection.off('drive_file_created', this.onDriveFileCreated); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + onDriveFileCreated(file) { + if (/^image\/.+$/.test(file.type)) { + this.images.unshift(file); + if (this.images.length > 9) this.images.pop(); + } + }, + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" module> +.root[data-melt] + .stream + padding 0 + + .img + border solid 4px transparent + border-radius 8px + +.stream + display -webkit-flex + display -moz-flex + display -ms-flex + display flex + justify-content center + flex-wrap wrap + padding 8px + + .img + flex 1 1 33% + width 33% + height 80px + background-position center center + background-size cover + border solid 2px transparent + border-radius 4px + +.fetching +.empty + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/common/views/widgets/rss.vue b/src/client/app/common/views/widgets/rss.vue new file mode 100644 index 0000000000..4d74b2f7a4 --- /dev/null +++ b/src/client/app/common/views/widgets/rss.vue @@ -0,0 +1,93 @@ +<template> +<div class="mkw-rss" :data-mobile="isMobile"> + <mk-widget-container :show-header="!props.compact"> + <template slot="header">%fa:rss-square%RSS</template> + <button slot="func" title="設定" @click="setting">%fa:cog%</button> + + <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <div :class="$style.feed" v-else> + <a v-for="item in items" :href="item.link" target="_blank">{{ item.title }}</a> + </div> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'rss', + props: () => ({ + compact: false + }) +}).extend({ + data() { + return { + url: 'http://news.yahoo.co.jp/pickup/rss.xml', + items: [], + fetching: true, + clock: null + }; + }, + mounted() { + this.fetch(); + this.clock = setInterval(this.fetch, 60000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + func() { + this.props.compact = !this.props.compact; + }, + fetch() { + fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.url}`, { + cache: 'no-cache' + }).then(res => { + res.json().then(feed => { + this.items = feed.items; + this.fetching = false; + }); + }); + }, + setting() { + alert('not implemented yet'); + } + } +}); +</script> + +<style lang="stylus" module> +.feed + padding 12px 16px + font-size 0.9em + + > a + display block + padding 4px 0 + color #666 + border-bottom dashed 1px #eee + + &:last-child + border-bottom none + +.fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +&[data-mobile] + .feed + padding 0 + font-size 1em + + > a + padding 8px 16px + + &:nth-child(even) + background rgba(0, 0, 0, 0.05) + +</style> diff --git a/src/client/app/common/views/widgets/server.cpu-memory.vue b/src/client/app/common/views/widgets/server.cpu-memory.vue new file mode 100644 index 0000000000..d75a142568 --- /dev/null +++ b/src/client/app/common/views/widgets/server.cpu-memory.vue @@ -0,0 +1,127 @@ +<template> +<div class="cpu-memory"> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" preserveAspectRatio="none"> + <defs> + <linearGradient :id="cpuGradientId" x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop> + <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> + </linearGradient> + <mask :id="cpuMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> + <polygon + :points="cpuPolygonPoints" + fill="#fff" + fill-opacity="0.5"/> + <polyline + :points="cpuPolylinePoints" + fill="none" + stroke="#fff" + stroke-width="1"/> + </mask> + </defs> + <rect + x="-1" y="-1" + :width="viewBoxX + 2" :height="viewBoxY + 2" + :style="`stroke: none; fill: url(#${ cpuGradientId }); mask: url(#${ cpuMaskId })`"/> + <text x="1" y="5">CPU <tspan>{{ cpuP }}%</tspan></text> + </svg> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" preserveAspectRatio="none"> + <defs> + <linearGradient :id="memGradientId" x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop> + <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> + </linearGradient> + <mask :id="memMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> + <polygon + :points="memPolygonPoints" + fill="#fff" + fill-opacity="0.5"/> + <polyline + :points="memPolylinePoints" + fill="none" + stroke="#fff" + stroke-width="1"/> + </mask> + </defs> + <rect + x="-1" y="-1" + :width="viewBoxX + 2" :height="viewBoxY + 2" + :style="`stroke: none; fill: url(#${ memGradientId }); mask: url(#${ memMaskId })`"/> + <text x="1" y="5">MEM <tspan>{{ memP }}%</tspan></text> + </svg> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + props: ['connection'], + data() { + return { + viewBoxX: 50, + viewBoxY: 30, + stats: [], + cpuGradientId: uuid(), + cpuMaskId: uuid(), + memGradientId: uuid(), + memMaskId: uuid(), + cpuPolylinePoints: '', + memPolylinePoints: '', + cpuPolygonPoints: '', + memPolygonPoints: '', + cpuP: '', + memP: '' + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + stats.mem.used = stats.mem.total - stats.mem.free; + this.stats.push(stats); + if (this.stats.length > 50) this.stats.shift(); + + this.cpuPolylinePoints = this.stats.map((s, i) => `${this.viewBoxX - ((this.stats.length - 1) - i)},${(1 - s.cpu_usage) * this.viewBoxY}`).join(' '); + this.memPolylinePoints = this.stats.map((s, i) => `${this.viewBoxX - ((this.stats.length - 1) - i)},${(1 - (s.mem.used / s.mem.total)) * this.viewBoxY}`).join(' '); + + this.cpuPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${ this.viewBoxY } ${ this.cpuPolylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; + this.memPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${ this.viewBoxY } ${ this.memPolylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; + + this.cpuP = (stats.cpu_usage * 100).toFixed(0); + this.memP = (stats.mem.used / stats.mem.total * 100).toFixed(0); + } + } +}); +</script> + +<style lang="stylus" scoped> +.cpu-memory + > svg + display block + padding 10px + width 50% + float left + + &:first-child + padding-right 5px + + &:last-child + padding-left 5px + + > text + font-size 5px + fill rgba(0, 0, 0, 0.55) + + > tspan + opacity 0.5 + + &:after + content "" + display block + clear both +</style> diff --git a/src/client/app/common/views/widgets/server.cpu.vue b/src/client/app/common/views/widgets/server.cpu.vue new file mode 100644 index 0000000000..596c856da8 --- /dev/null +++ b/src/client/app/common/views/widgets/server.cpu.vue @@ -0,0 +1,68 @@ +<template> +<div class="cpu"> + <x-pie class="pie" :value="usage"/> + <div> + <p>%fa:microchip%CPU</p> + <p>{{ meta.cpu.cores }} Cores</p> + <p>{{ meta.cpu.model }}</p> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPie from './server.pie.vue'; + +export default Vue.extend({ + components: { + XPie + }, + props: ['connection', 'meta'], + data() { + return { + usage: 0 + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + this.usage = stats.cpu_usage; + } + } +}); +</script> + +<style lang="stylus" scoped> +.cpu + > .pie + padding 10px + height 100px + float left + + > div + float left + width calc(100% - 100px) + padding 10px 10px 10px 0 + + > p + margin 0 + font-size 12px + color #505050 + + &:first-child + font-weight bold + + > [data-fa] + margin-right 4px + + &:after + content "" + display block + clear both + +</style> diff --git a/src/client/app/common/views/widgets/server.disk.vue b/src/client/app/common/views/widgets/server.disk.vue new file mode 100644 index 0000000000..2af1982a96 --- /dev/null +++ b/src/client/app/common/views/widgets/server.disk.vue @@ -0,0 +1,76 @@ +<template> +<div class="disk"> + <x-pie class="pie" :value="usage"/> + <div> + <p>%fa:R hdd%Storage</p> + <p>Total: {{ total | bytes(1) }}</p> + <p>Available: {{ available | bytes(1) }}</p> + <p>Used: {{ used | bytes(1) }}</p> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPie from './server.pie.vue'; + +export default Vue.extend({ + components: { + XPie + }, + props: ['connection'], + data() { + return { + usage: 0, + total: 0, + used: 0, + available: 0 + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + stats.disk.used = stats.disk.total - stats.disk.free; + this.usage = stats.disk.used / stats.disk.total; + this.total = stats.disk.total; + this.used = stats.disk.used; + this.available = stats.disk.available; + } + } +}); +</script> + +<style lang="stylus" scoped> +.disk + > .pie + padding 10px + height 100px + float left + + > div + float left + width calc(100% - 100px) + padding 10px 10px 10px 0 + + > p + margin 0 + font-size 12px + color #505050 + + &:first-child + font-weight bold + + > [data-fa] + margin-right 4px + + &:after + content "" + display block + clear both + +</style> diff --git a/src/client/app/common/views/widgets/server.info.vue b/src/client/app/common/views/widgets/server.info.vue new file mode 100644 index 0000000000..d243629506 --- /dev/null +++ b/src/client/app/common/views/widgets/server.info.vue @@ -0,0 +1,25 @@ +<template> +<div class="info"> + <p>Maintainer: <b><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></b></p> + <p>Machine: {{ meta.machine }}</p> + <p>Node: {{ meta.node }}</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['meta'] +}); +</script> + +<style lang="stylus" scoped> +.info + padding 10px 14px + + > p + margin 0 + font-size 12px + color #505050 +</style> diff --git a/src/client/app/common/views/widgets/server.memory.vue b/src/client/app/common/views/widgets/server.memory.vue new file mode 100644 index 0000000000..834a62671d --- /dev/null +++ b/src/client/app/common/views/widgets/server.memory.vue @@ -0,0 +1,76 @@ +<template> +<div class="memory"> + <x-pie class="pie" :value="usage"/> + <div> + <p>%fa:flask%Memory</p> + <p>Total: {{ total | bytes(1) }}</p> + <p>Used: {{ used | bytes(1) }}</p> + <p>Free: {{ free | bytes(1) }}</p> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPie from './server.pie.vue'; + +export default Vue.extend({ + components: { + XPie + }, + props: ['connection'], + data() { + return { + usage: 0, + total: 0, + used: 0, + free: 0 + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + stats.mem.used = stats.mem.total - stats.mem.free; + this.usage = stats.mem.used / stats.mem.total; + this.total = stats.mem.total; + this.used = stats.mem.used; + this.free = stats.mem.free; + } + } +}); +</script> + +<style lang="stylus" scoped> +.memory + > .pie + padding 10px + height 100px + float left + + > div + float left + width calc(100% - 100px) + padding 10px 10px 10px 0 + + > p + margin 0 + font-size 12px + color #505050 + + &:first-child + font-weight bold + + > [data-fa] + margin-right 4px + + &:after + content "" + display block + clear both + +</style> diff --git a/src/client/app/common/views/widgets/server.pie.vue b/src/client/app/common/views/widgets/server.pie.vue new file mode 100644 index 0000000000..ce2cff1d00 --- /dev/null +++ b/src/client/app/common/views/widgets/server.pie.vue @@ -0,0 +1,61 @@ +<template> +<svg viewBox="0 0 1 1" preserveAspectRatio="none"> + <circle + :r="r" + cx="50%" cy="50%" + fill="none" + stroke-width="0.1" + stroke="rgba(0, 0, 0, 0.05)"/> + <circle + :r="r" + cx="50%" cy="50%" + :stroke-dasharray="Math.PI * (r * 2)" + :stroke-dashoffset="strokeDashoffset" + fill="none" + stroke-width="0.1" + :stroke="color"/> + <text x="50%" y="50%" dy="0.05" text-anchor="middle">{{ (value * 100).toFixed(0) }}%</text> +</svg> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + type: Number, + required: true + } + }, + data() { + return { + r: 0.4 + }; + }, + computed: { + color(): string { + return `hsl(${180 - (this.value * 180)}, 80%, 70%)`; + }, + strokeDashoffset(): number { + return (1 - this.value) * (Math.PI * (this.r * 2)); + } + } +}); +</script> + +<style lang="stylus" scoped> +svg + display block + height 100% + + > circle + transform-origin center + transform rotate(-90deg) + transition stroke-dashoffset 0.5s ease + + > text + font-size 0.15px + fill rgba(0, 0, 0, 0.6) + +</style> diff --git a/src/client/app/common/views/widgets/server.uptimes.vue b/src/client/app/common/views/widgets/server.uptimes.vue new file mode 100644 index 0000000000..06713d83ce --- /dev/null +++ b/src/client/app/common/views/widgets/server.uptimes.vue @@ -0,0 +1,46 @@ +<template> +<div class="uptimes"> + <p>Uptimes</p> + <p>Process: {{ process ? process.toFixed(0) : '---' }}s</p> + <p>OS: {{ os ? os.toFixed(0) : '---' }}s</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['connection'], + data() { + return { + process: 0, + os: 0 + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + this.process = stats.process_uptime; + this.os = stats.os_uptime; + } + } +}); +</script> + +<style lang="stylus" scoped> +.uptimes + padding 10px 14px + + > p + margin 0 + font-size 12px + color #505050 + + &:first-child + font-weight bold +</style> diff --git a/src/client/app/common/views/widgets/server.vue b/src/client/app/common/views/widgets/server.vue new file mode 100644 index 0000000000..3d5248998f --- /dev/null +++ b/src/client/app/common/views/widgets/server.vue @@ -0,0 +1,93 @@ +<template> +<div class="mkw-server"> + <mk-widget-container :show-header="props.design == 0" :naked="props.design == 2"> + <template slot="header">%fa:server%%i18n:desktop.tags.mk-server-home-widget.title%</template> + <button slot="func" @click="toggle" title="%i18n:desktop.tags.mk-server-home-widget.toggle%">%fa:sort%</button> + + <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <template v-if="!fetching"> + <x-cpu-memory v-show="props.view == 0" :connection="connection"/> + <x-cpu v-show="props.view == 1" :connection="connection" :meta="meta"/> + <x-memory v-show="props.view == 2" :connection="connection"/> + <x-disk v-show="props.view == 3" :connection="connection"/> + <x-uptimes v-show="props.view == 4" :connection="connection"/> + <x-info v-show="props.view == 5" :connection="connection" :meta="meta"/> + </template> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import XCpuMemory from './server.cpu-memory.vue'; +import XCpu from './server.cpu.vue'; +import XMemory from './server.memory.vue'; +import XDisk from './server.disk.vue'; +import XUptimes from './server.uptimes.vue'; +import XInfo from './server.info.vue'; + +export default define({ + name: 'server', + props: () => ({ + design: 0, + view: 0 + }) +}).extend({ + components: { + XCpuMemory, + XCpu, + XMemory, + XDisk, + XUptimes, + XInfo + }, + data() { + return { + fetching: true, + meta: null, + connection: null, + connectionId: null + }; + }, + mounted() { + (this as any).os.getMeta().then(meta => { + this.meta = meta; + this.fetching = false; + }); + + this.connection = (this as any).os.streams.serverStream.getConnection(); + this.connectionId = (this as any).os.streams.serverStream.use(); + }, + beforeDestroy() { + (this as any).os.streams.serverStream.dispose(this.connectionId); + }, + methods: { + toggle() { + if (this.props.view == 5) { + this.props.view = 0; + } else { + this.props.view++; + } + }, + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" module> +.fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/common/views/widgets/slideshow.vue b/src/client/app/common/views/widgets/slideshow.vue new file mode 100644 index 0000000000..ad32299f37 --- /dev/null +++ b/src/client/app/common/views/widgets/slideshow.vue @@ -0,0 +1,159 @@ +<template> +<div class="mkw-slideshow" :data-mobile="isMobile"> + <div @click="choose"> + <p v-if="props.folder === undefined"> + <template v-if="isCustomizeMode">フォルダを指定するには、カスタマイズモードを終了してください</template> + <template v-else>クリックしてフォルダを指定してください</template> + </p> + <p v-if="props.folder !== undefined && images.length == 0 && !fetching">このフォルダには画像がありません</p> + <div ref="slideA" class="slide a"></div> + <div ref="slideB" class="slide b"></div> + </div> +</div> +</template> + +<script lang="ts"> +import * as anime from 'animejs'; +import define from '../../../common/define-widget'; +export default define({ + name: 'slideshow', + props: () => ({ + folder: undefined, + size: 0 + }) +}).extend({ + data() { + return { + images: [], + fetching: true, + clock: null + }; + }, + mounted() { + this.$nextTick(() => { + this.applySize(); + }); + + if (this.props.folder !== undefined) { + this.fetch(); + } + + this.clock = setInterval(this.change, 10000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + func() { + this.resize(); + }, + applySize() { + let h; + + if (this.props.size == 1) { + h = 250; + } else { + h = 170; + } + + this.$el.style.height = `${h}px`; + }, + resize() { + if (this.props.size == 1) { + this.props.size = 0; + } else { + this.props.size++; + } + + this.applySize(); + }, + change() { + if (this.images.length == 0) return; + + const index = Math.floor(Math.random() * this.images.length); + const img = `url(${ this.images[index].url }?thumbnail&size=1024)`; + + (this.$refs.slideB as any).style.backgroundImage = img; + + anime({ + targets: this.$refs.slideB, + opacity: 1, + duration: 1000, + easing: 'linear', + complete: () => { + // 既にこのウィジェットがunmountされていたら要素がない + if ((this.$refs.slideA as any) == null) return; + + (this.$refs.slideA as any).style.backgroundImage = img; + anime({ + targets: this.$refs.slideB, + opacity: 0, + duration: 0 + }); + } + }); + }, + fetch() { + this.fetching = true; + + (this as any).api('drive/files', { + folderId: this.props.folder, + type: 'image/*', + limit: 100 + }).then(images => { + this.images = images; + this.fetching = false; + (this.$refs.slideA as any).style.backgroundImage = ''; + (this.$refs.slideB as any).style.backgroundImage = ''; + this.change(); + }); + }, + choose() { + (this as any).apis.chooseDriveFolder().then(folder => { + this.props.folder = folder ? folder.id : null; + this.fetch(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-slideshow + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-mobile] + border none + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + > div + width 100% + height 100% + cursor pointer + + > p + display block + margin 1em + text-align center + color #888 + + > * + pointer-events none + + > .slide + position absolute + top 0 + left 0 + width 100% + height 100% + background-size cover + background-position center + + &.b + opacity 0 + +</style> diff --git a/src/client/app/common/views/widgets/tips.vue b/src/client/app/common/views/widgets/tips.vue new file mode 100644 index 0000000000..bdecc068e1 --- /dev/null +++ b/src/client/app/common/views/widgets/tips.vue @@ -0,0 +1,108 @@ +<template> +<div class="mkw-tips"> + <p ref="tip">%fa:R lightbulb%<span v-html="tip"></span></p> +</div> +</template> + +<script lang="ts"> +import * as anime from 'animejs'; +import define from '../../../common/define-widget'; + +const tips = [ + '<kbd>t</kbd>でタイムラインにフォーカスできます', + '<kbd>p</kbd>または<kbd>n</kbd>で投稿フォームを開きます', + '投稿フォームにはファイルをドラッグ&ドロップできます', + '投稿フォームにクリップボードにある画像データをペーストできます', + 'ドライブにファイルをドラッグ&ドロップしてアップロードできます', + 'ドライブでファイルをドラッグしてフォルダ移動できます', + 'ドライブでフォルダをドラッグしてフォルダ移動できます', + 'ホームは設定からカスタマイズできます', + 'MisskeyはMIT Licenseです', + 'タイムマシンウィジェットを利用すると、簡単に過去のタイムラインに遡れます', + '投稿の ... をクリックして、投稿をユーザーページにピン留めできます', + 'ドライブの容量は(デフォルトで)1GBです', + '投稿に添付したファイルは全てドライブに保存されます', + 'ホームのカスタマイズ中、ウィジェットを右クリックしてデザインを変更できます', + 'タイムライン上部にもウィジェットを設置できます', + '投稿をダブルクリックすると詳細が見れます', + '「**」でテキストを囲むと**強調表示**されます', + 'チャンネルウィジェットを利用すると、よく利用するチャンネルを素早く確認できます', + 'いくつかのウィンドウはブラウザの外に切り離すことができます', + 'カレンダーウィジェットのパーセンテージは、経過の割合を示しています', + 'APIを利用してbotの開発なども行えます', + 'MisskeyはLINEを通じてでも利用できます', + 'まゆかわいいよまゆ', + 'Misskeyは2014年にサービスを開始しました', + '対応ブラウザではMisskeyを開いていなくても通知を受け取れます' +] + +export default define({ + name: 'tips' +}).extend({ + data() { + return { + tip: null, + clock: null + }; + }, + mounted() { + this.$nextTick(() => { + this.set(); + }); + + this.clock = setInterval(this.change, 20000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + set() { + this.tip = tips[Math.floor(Math.random() * tips.length)]; + }, + change() { + anime({ + targets: this.$refs.tip, + opacity: 0, + duration: 500, + easing: 'linear', + complete: this.set + }); + + setTimeout(() => { + anime({ + targets: this.$refs.tip, + opacity: 1, + duration: 500, + easing: 'linear' + }); + }, 500); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-tips + overflow visible !important + + > p + display block + margin 0 + padding 0 12px + text-align center + font-size 0.7em + color #999 + + > [data-fa] + margin-right 4px + + kbd + display inline + padding 0 6px + margin 0 2px + font-size 1em + font-family inherit + border solid 1px #999 + border-radius 2px + +</style> diff --git a/src/client/app/common/views/widgets/version.vue b/src/client/app/common/views/widgets/version.vue new file mode 100644 index 0000000000..30b632b396 --- /dev/null +++ b/src/client/app/common/views/widgets/version.vue @@ -0,0 +1,29 @@ +<template> +<p>ver {{ version }} ({{ codename }})</p> +</template> + +<script lang="ts"> +import { version, codename } from '../../../config'; +import define from '../../../common/define-widget'; +export default define({ + name: 'version' +}).extend({ + data() { + return { + version, + codename + }; + } +}); +</script> + +<style lang="stylus" scoped> +p + display block + margin 0 + padding 0 12px + text-align center + font-size 0.7em + color #aaa + +</style> |